diff --git a/.changeset/fix-web-long-stream-backlog.md b/.changeset/fix-web-long-stream-backlog.md new file mode 100644 index 0000000000..b20868a2e6 --- /dev/null +++ b/.changeset/fix-web-long-stream-backlog.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Prevent long streaming responses from stalling after a tab is backgrounded. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 5ff57e741a..1d895d3aa5 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -1434,6 +1434,18 @@ export class DaemonKimiWebApi implements KimiWebApi { const { type, seq, session_id: sessionId, payload, offset } = frame; const appEvents = projector.project(type, payload, sessionId, { offset }); for (const appEvent of appEvents) { + const turnId = (payload as { turnId?: unknown } | null)?.turnId; + const stream = + appEvent.type === 'assistantDelta' && + typeof turnId === 'number' && + typeof offset === 'number' && + (type === 'assistant.delta' || type === 'thinking.delta') + ? { + turnId, + offset, + kind: type === 'assistant.delta' ? ('text' as const) : ('thinking' as const), + } + : undefined; // historyCompacted from the projector is either a compaction signal // (reason auto_compact — no reload, the divider marker handles it) or // a delta-gap recovery (reason delta_gap — a real resync, routed to @@ -1441,7 +1453,7 @@ export class DaemonKimiWebApi implements KimiWebApi { if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) { handlers.onResync(sessionId, seq); } - handlers.onEvent(appEvent, { sessionId, seq }); + handlers.onEvent(appEvent, { sessionId, seq, stream }); } }, diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 29b662c76e..459487b1ca 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -507,7 +507,7 @@ export interface AppSessionSnapshot { } export interface KimiEventHandlers { - onEvent(event: AppEvent, meta: { sessionId: string; seq: number }): void; + onEvent(event: AppEvent, meta: KimiEventMeta): void; onResync(sessionId: string, currentSeq: number, epoch?: string): void; onError(code: number, msg: string, fatal: boolean): void; onConnectionChange(connected: boolean): void; @@ -515,6 +515,18 @@ export interface KimiEventHandlers { onTerminalExit?(sessionId: string, terminalId: string, exitCode: number | null): void; } +/** Raw stream coordinates are present only for kap-server assistant/thinking + deltas. They let the render queue merge chunks without guessing continuity. */ +export interface KimiEventMeta { + sessionId: string; + seq: number; + stream?: { + turnId: number; + offset: number; + kind: 'text' | 'thinking'; + }; +} + export interface KimiEventConnection { subscribe(sessionId: string, cursor?: AppSessionCursor): void; unsubscribe(sessionId: string): void; diff --git a/apps/kimi-web/src/composables/client/eventBatcher.ts b/apps/kimi-web/src/composables/client/eventBatcher.ts index a42d028365..1c0b0702e5 100644 --- a/apps/kimi-web/src/composables/client/eventBatcher.ts +++ b/apps/kimi-web/src/composables/client/eventBatcher.ts @@ -1,13 +1,13 @@ // apps/kimi-web/src/composables/client/eventBatcher.ts -// Coalesce high-frequency streaming events onto the next animation frame. +// Coalesce high-frequency streaming events and apply them in bounded slices. // -// Pure logic (no Vue, no DOM) so it is unit-testable in isolation. See -// useKimiWebClient.ts for where it is wired into the WS event pipeline. +// Pure logic (no Vue) so the queue, ordering, and scheduler fallback can be +// tested directly. See useKimiWebClient.ts for the WS pipeline wiring. -import type { AppEvent } from '../../api/types'; +import type { AppEvent, KimiEventMeta } from '../../api/types'; // Events that merely append a chunk to something already streaming. They can -// arrive dozens to hundreds of times per second, so they are worth coalescing. +// arrive dozens to hundreds of times per second, so they are worth batching. const RENDER_EVENT_TYPES: ReadonlySet = new Set([ 'assistantDelta', 'agentDelta', @@ -15,66 +15,316 @@ const RENDER_EVENT_TYPES: ReadonlySet = new Set void): number { - return typeof requestAnimationFrame === 'function' - ? requestAnimationFrame(cb) - : (setTimeout(cb, 16) as unknown as number); +export interface EventBatcherScheduler { + /** Request the next visual frame. Return null when frames are unavailable. */ + requestFrame(callback: () => void): number | null; + cancelFrame(handle: number): void; + /** Request a task that still runs when animation frames are suspended. */ + requestTask(callback: () => void): number; + cancelTask(handle: number): void; +} + +const FALLBACK_TASK_DELAY_MS = 50; +const DEFAULT_MAX_ITEMS_PER_SLICE = 100; +/** Keep each append passed to the reducer small enough that concatenation and + * Markdown invalidation remain bounded. Offsets use JS string lengths, so the + * limit is measured in UTF-16 code units too. */ +const MAX_COALESCED_STREAM_CHARS = 32 * 1024; + +const defaultScheduler: EventBatcherScheduler = { + requestFrame(callback) { + return typeof requestAnimationFrame === 'function' + ? requestAnimationFrame(callback) + : null; + }, + cancelFrame(handle) { + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle); + }, + requestTask(callback) { + return setTimeout(callback, FALLBACK_TASK_DELAY_MS) as unknown as number; + }, + cancelTask(handle) { + clearTimeout(handle); + }, +}; + +export interface EventBatcherOptions { + /** Merge the new item into the last pending item, or return undefined. */ + coalesce?: (previous: T, next: T) => T | undefined; + /** Maximum queued groups processed by one scheduled or synchronous slice. */ + maxItemsPerSlice?: number; + scheduler?: EventBatcherScheduler; } /** - * Coalesce batchable items onto a single scheduled callback, while applying - * non-batchable items immediately. - * - * A non-batchable item first drains any pending batchable items (in arrival - * order) so overall ordering is preserved — a lifecycle event never overtakes - * the deltas that arrived before it. - * - * The returned handle is itself callable (enqueue) and also exposes `flush()` - * to synchronously drain pending batchable items. Callers that replace state - * authoritatively (e.g. applying a server snapshot) must `flush()` first so - * stale queued deltas are not applied on top of the new state. + * Queue batchable items until the next frame (or task fallback), while keeping + * control events in arrival order. A control event triggers one bounded slice: + * short/coalesced queues still settle immediately, while a large queue resumes + * on later frames instead of becoming one long main-thread task. */ export interface EventBatcher { (item: T): void; - /** Synchronously drain any pending batchable items in arrival order. */ + /** Synchronously drain every pending item. Reserved for authoritative state replacement. */ flush(): void; + /** Drop queued items that no longer have a valid owner. */ + discard(predicate: (item: T) => boolean): void; + /** Cancel scheduled work and permanently discard this batcher's queue. */ + dispose(): void; } export function createEventBatcher( process: (item: T) => void, isBatchable: (item: T) => boolean, - schedule: (cb: () => void) => number = defaultScheduleFrame, + options: EventBatcherOptions = {}, ): EventBatcher { - let pending: T[] = []; - let handle: number | null = null; - - const drain = (): void => { - handle = null; - if (pending.length === 0) return; - const batch = pending; - pending = []; - for (const item of batch) process(item); + const scheduler = options.scheduler ?? defaultScheduler; + const maxItemsPerSlice = Math.max( + 1, + Math.floor(options.maxItemsPerSlice ?? DEFAULT_MAX_ITEMS_PER_SLICE), + ); + const pending: T[] = []; + let head = 0; + let frameHandle: number | null = null; + let taskHandle: number | null = null; + let scheduleVersion = 0; + let disposed = false; + + const countPending = (): number => pending.length - head; + + const cancelScheduled = (): void => { + scheduleVersion += 1; + if (frameHandle !== null) { + scheduler.cancelFrame(frameHandle); + frameHandle = null; + } + if (taskHandle !== null) { + scheduler.cancelTask(taskHandle); + taskHandle = null; + } + }; + + const compactQueue = (): void => { + if (head === pending.length) { + pending.length = 0; + head = 0; + } else if (head >= 1024) { + pending.splice(0, head); + head = 0; + } + }; + + let drainSlice: () => void; + + const scheduleDrain = (): void => { + if ( + disposed || + frameHandle !== null || + taskHandle !== null || + countPending() === 0 + ) { + return; + } + const version = ++scheduleVersion; + const run = (): void => { + if (version !== scheduleVersion) return; + drainSlice(); + }; + frameHandle = scheduler.requestFrame(run); + taskHandle = scheduler.requestTask(run); + }; + + drainSlice = (): void => { + cancelScheduled(); + let processed = 0; + while (!disposed && processed < maxItemsPerSlice && head < pending.length) { + const item = pending[head++]!; + process(item); + processed += 1; + } + compactQueue(); + scheduleDrain(); }; const enqueue = ((item: T) => { + if (disposed) return; if (isBatchable(item)) { - pending.push(item); - if (handle === null) handle = schedule(drain); + const previous = pending.length > head ? pending.at(-1) : undefined; + const merged = previous === undefined ? undefined : options.coalesce?.(previous, item); + if (merged === undefined) pending.push(item); + else pending[pending.length - 1] = merged; + scheduleDrain(); + return; + } + + if (countPending() === 0) { + process(item); return; } - // Immediate item: flush pending batchables first to preserve order. - drain(); - process(item); + + // Keep the control event behind everything that arrived before it. Process + // one bounded slice now so a short/coalesced stream still completes without + // waiting for another frame; schedule the remainder when the budget is hit. + pending.push(item); + drainSlice(); }) as EventBatcher; - enqueue.flush = drain; + enqueue.flush = (): void => { + if (disposed) return; + cancelScheduled(); + while (!disposed && head < pending.length) process(pending[head++]!); + compactQueue(); + }; + enqueue.discard = (predicate): void => { + if (disposed || countPending() === 0) return; + let write = head; + for (let read = head; read < pending.length; read += 1) { + const item = pending[read]!; + if (!predicate(item)) pending[write++] = item; + } + pending.length = write; + compactQueue(); + if (countPending() === 0) cancelScheduled(); + else scheduleDrain(); + }; + enqueue.dispose = (): void => { + if (disposed) return; + disposed = true; + cancelScheduled(); + pending.length = 0; + head = 0; + }; return enqueue; } + +export interface PendingAppEvent { + appEvent: AppEvent; + meta: KimiEventMeta; +} + +interface AssistantChunk { + kind: 'text' | 'thinking'; + value: string; +} + +function assistantChunk(event: AppEvent): AssistantChunk | undefined { + if (event.type !== 'assistantDelta') return undefined; + if (event.delta.text !== undefined && event.delta.thinking === undefined) { + return { kind: 'text', value: event.delta.text }; + } + if (event.delta.thinking !== undefined && event.delta.text === undefined) { + return { kind: 'thinking', value: event.delta.thinking }; + } + return undefined; +} + +/** + * A single server frame can already contain a large coalesced delta. Split it + * before enqueueing so the per-group cap also holds for that case. Every part + * keeps the wire seq and advances only the raw stream offset. + */ +export function splitOversizedAppRenderEvent( + item: PendingAppEvent, +): readonly PendingAppEvent[] { + if (item.appEvent.type !== 'assistantDelta') return [item]; + const appEvent = item.appEvent; + const stream = item.meta.stream; + const chunk = assistantChunk(appEvent); + if ( + stream === undefined || + chunk === undefined || + stream.kind !== chunk.kind || + chunk.value.length <= MAX_COALESCED_STREAM_CHARS + ) { + return [item]; + } + + const parts: PendingAppEvent[] = []; + let start = 0; + while (start < chunk.value.length) { + let end = Math.min(start + MAX_COALESCED_STREAM_CHARS, chunk.value.length); + // Do not expose an unpaired surrogate in an intermediate render. Moving + // the boundary back by one still preserves offset continuity because raw + // offsets are counted in UTF-16 code units. + if ( + end < chunk.value.length && + end > start && + /[\uD800-\uDBFF]/u.test(chunk.value[end - 1]!) && + /[\uDC00-\uDFFF]/u.test(chunk.value[end]!) + ) { + end -= 1; + } + const value = chunk.value.slice(start, end); + parts.push({ + appEvent: { + ...appEvent, + delta: chunk.kind === 'text' ? { text: value } : { thinking: value }, + }, + meta: { + ...item.meta, + stream: { ...stream, offset: stream.offset + start }, + }, + }); + start = end; + } + return parts; +} + +/** + * Merge adjacent main-assistant text/thinking deltas only when their complete + * stream identity and offsets prove that concatenation is lossless. + * + * Protocol/stub events without raw stream metadata deliberately stay separate. + * Control events and other render-event types are never merged. + */ +export function coalesceAppRenderEvents( + previous: PendingAppEvent, + next: PendingAppEvent, +): PendingAppEvent | undefined { + if (previous.appEvent.type !== 'assistantDelta' || next.appEvent.type !== 'assistantDelta') { + return undefined; + } + const previousStream = previous.meta.stream; + const nextStream = next.meta.stream; + const previousChunk = assistantChunk(previous.appEvent); + const nextChunk = assistantChunk(next.appEvent); + if ( + previousStream === undefined || + nextStream === undefined || + previousChunk === undefined || + nextChunk === undefined || + previous.meta.sessionId !== next.meta.sessionId || + previous.appEvent.sessionId !== next.appEvent.sessionId || + previous.appEvent.messageId !== next.appEvent.messageId || + previous.appEvent.contentIndex !== next.appEvent.contentIndex || + previousStream.turnId !== nextStream.turnId || + previousStream.kind !== nextStream.kind || + previousChunk.kind !== nextChunk.kind || + previousStream.kind !== previousChunk.kind || + nextStream.kind !== nextChunk.kind || + nextStream.offset !== previousStream.offset + previousChunk.value.length || + previousChunk.value.length + nextChunk.value.length > MAX_COALESCED_STREAM_CHARS + ) { + return undefined; + } + + const value = previousChunk.value + nextChunk.value; + return { + appEvent: { + ...previous.appEvent, + delta: previousChunk.kind === 'text' ? { text: value } : { thinking: value }, + }, + // Advance the durable watermark with the newest frame while preserving the + // first offset of the merged chunk for the next continuity check. + meta: { + ...next.meta, + stream: { ...previousStream }, + }, + }; +} diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 7d91dfcc71..a82bd5b767 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -29,7 +29,13 @@ import { saveWorkspaceSort, STORAGE_KEYS, } from '../lib/storage'; -import { createEventBatcher, isRenderEvent } from './client/eventBatcher'; +import { + coalesceAppRenderEvents, + createEventBatcher, + isRenderEvent, + splitOversizedAppRenderEvent, + type PendingAppEvent, +} from './client/eventBatcher'; import { useAppearance } from './client/useAppearance'; import { useNotification, shouldNotifyCompletion } from './client/useNotification'; import { useSoundNotification } from './client/useSoundNotification'; @@ -64,6 +70,7 @@ import type { AppWorkspace, ApprovalDecision, KimiEventConnection, + KimiEventMeta, ThinkingLevel, } from '../api/types'; import { createInitialState, reduceAppEvent, type CompactionStatus, type KimiClientState } from '../api/daemon/eventReducer'; @@ -573,13 +580,11 @@ function forgetSession(sessionId: string): void { // per-session maps we are about to delete. eventConn?.unsubscribe(sessionId); dropWsSubscription(sessionId); - // Drain the streaming-event batcher too. unsubscribe() stops future server - // frames, but events already queued for the next animation frame would - // otherwise survive and be reduced AFTER the maps below are cleared — - // recreating entries like messagesBySession[id] and lastSeqBySession[id]. - // That would make hasLoadedMessages() treat the stale empty cache as - // authoritative and skip the next snapshot fetch for this id. - enqueueEvent.flush(); + // Drop this session's queued render AND control events. Flushing them here is + // unsafe: a delayed idle event can drain a queued prompt into the session + // after the archive request succeeded. Other sessions keep their own ordered + // backlog and scheduled continuation. + enqueueEvent.discard(({ meta }) => meta.sessionId === sessionId); removeSession(sessionId); removeSessionMessages(sessionId); delete rawState.approvalsBySession[sessionId]; @@ -857,17 +862,13 @@ function applyEvent(event: ReturnType, sessionId: string, seq // synchronously triggers a full Vue re-render per event, which saturates the // main thread and makes the stream look janky (see messagesToTurns / Markdown). // -// We coalesce those render-only events onto the next animation frame so Vue -// commits a single render per frame. Lifecycle / control-flow events -// (sessionStatusChanged, messageCreated, approval*, question*, ...) are applied -// immediately: they are infrequent, and some (e.g. sessionStatusChanged idle) -// drive turn-end cleanup that must not be delayed by a throttled rAF in a -// background tab. Ordering is preserved by draining any pending render events -// before applying an immediate event. - -type PendingEvent = { appEvent: AppEvent; meta: { sessionId: string; seq: number } }; +// Adjacent, offset-contiguous assistant/thinking deltas are merged before they +// reach the reducer. The remaining ordered groups are processed with a fixed +// per-frame budget and a task fallback, so a hidden tab cannot turn the entire +// backlog into one unbounded rAF drain. Lifecycle / control-flow events remain +// strict ordering barriers and are never dropped or merged. -function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number }): void { +function processEvent(appEvent: AppEvent, meta: KimiEventMeta): void { // Capture BEFORE applyEvent advances lastSeqBySession: turn-end side // effects below only run when this event actually moves the durable cursor // forward. A late duplicate idle (e.g. replayed after a snapshot already @@ -947,9 +948,10 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number } } -const enqueueEvent = createEventBatcher( +const enqueueEvent = createEventBatcher( ({ appEvent, meta }) => processEvent(appEvent, meta), ({ appEvent }) => isRenderEvent(appEvent), + { coalesce: coalesceAppRenderEvents }, ); // --------------------------------------------------------------------------- @@ -980,10 +982,11 @@ function connectEventsIfNeeded(): void { return; } - // Coalesce high-frequency render events onto the next animation frame; - // everything else is applied immediately. See createEventBatcher / - // processEvent above. - enqueueEvent({ appEvent, meta }); + // Merge safe streaming chunks, then process the ordered queue in bounded + // slices. See createEventBatcher / processEvent above. + for (const pendingEvent of splitOversizedAppRenderEvent({ appEvent, meta })) { + enqueueEvent(pendingEvent); + } }, onResync(sessionId: string, currentSeq: number, epoch?: string) { @@ -1589,7 +1592,10 @@ function stopSessionTimeClock(): void { } if (import.meta.hot) { - import.meta.hot.dispose(stopSessionTimeClock); + import.meta.hot.dispose(() => { + stopSessionTimeClock(); + enqueueEvent.dispose(); + }); } /** Build DiffLine[] from old_text/new_text strings */ diff --git a/apps/kimi-web/test/daemon-client.test.ts b/apps/kimi-web/test/daemon-client.test.ts index 1d452ff54b..cfc2fc034b 100644 --- a/apps/kimi-web/test/daemon-client.test.ts +++ b/apps/kimi-web/test/daemon-client.test.ts @@ -1,12 +1,42 @@ // apps/kimi-web/test/daemon-client.test.ts -// DaemonKimiWebApi public REST adapter: session export binary/error contracts -// and getSessionGoal wire → app mapping. +// DaemonKimiWebApi public REST adapter: session export binary/error contracts, +// getSessionGoal wire → app mapping, and raw stream-coordinate delivery. +// Wiring: real client/projector; fetch or WebSocket is stubbed at the network boundary. +// Run: pnpm --filter @moonshot-ai/kimi-web exec vitest run test/daemon-client.test.ts import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DaemonKimiWebApi } from '../src/api/daemon/client'; import { DaemonApiError, DaemonNetworkError } from '../src/api/errors'; import { clearTrace, traceToJsonl } from '../src/debug/trace'; +import type { AppEvent, KimiEventConnection, KimiEventMeta } from '../src/api/types'; + +class FakeWebSocket { + static readonly OPEN = 1; + static instances: FakeWebSocket[] = []; + + readonly OPEN = FakeWebSocket.OPEN; + readyState = FakeWebSocket.OPEN; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((event?: CloseEvent) => void) | null = null; + + constructor(_url: string, _protocols?: string | string[]) { + FakeWebSocket.instances.push(this); + } + + send(_data: string): void {} + + close(): void { + this.readyState = 3; + this.onclose?.(); + } + + emit(frame: unknown): void { + this.onmessage?.({ data: JSON.stringify(frame) } as MessageEvent); + } +} function envelope(data: unknown): Response { return new Response(JSON.stringify({ code: 0, msg: '', data }), { @@ -177,3 +207,92 @@ describe('DaemonKimiWebApi.getSessionGoal', () => { ); }); }); + +describe('DaemonKimiWebApi.connectEvents', () => { + let connection: KimiEventConnection | undefined; + + afterEach(() => { + connection?.close(); + connection = undefined; + vi.unstubAllGlobals(); + }); + + it('delivers raw assistant stream coordinates with the projected delta', () => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket as unknown as typeof WebSocket); + const received: Array<{ event: AppEvent; meta: KimiEventMeta }> = []; + connection = createApi().connectEvents({ + onEvent(event, meta) { + received.push({ event, meta }); + }, + onResync() {}, + onError() {}, + onConnectionChange() {}, + }); + const socket = FakeWebSocket.instances[0]!; + + socket.emit({ type: 'server_hello', payload: { protocol_version: 2 } }); + socket.emit({ + type: 'turn.started', + seq: 1, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + payload: { agentId: 'main', turnId: 7 }, + }); + socket.emit({ + type: 'turn.step.started', + seq: 2, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + payload: { agentId: 'main', turnId: 7, step: 1 }, + }); + socket.emit({ + type: 'assistant.delta', + seq: 2, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + volatile: true, + offset: 0, + payload: { agentId: 'main', turnId: 7, delta: 'hello' }, + }); + socket.emit({ + type: 'thinking.delta', + seq: 2, + session_id: 'session-1', + timestamp: '2026-01-01T00:00:00.000Z', + volatile: true, + offset: 0, + payload: { agentId: 'main', turnId: 7, delta: 'thought' }, + }); + + const delta = received.find(({ event }) => event.type === 'assistantDelta'); + expect(delta).toMatchObject({ + event: { + type: 'assistantDelta', + sessionId: 'session-1', + delta: { text: 'hello' }, + }, + meta: { + sessionId: 'session-1', + seq: 2, + stream: { turnId: 7, offset: 0, kind: 'text' }, + }, + }); + + const thinking = received.find( + ({ event }) => event.type === 'assistantDelta' && event.delta.thinking !== undefined, + ); + expect(thinking).toMatchObject({ + event: { + type: 'assistantDelta', + sessionId: 'session-1', + delta: { thinking: 'thought' }, + }, + meta: { + sessionId: 'session-1', + seq: 2, + stream: { turnId: 7, offset: 0, kind: 'thinking' }, + }, + }); + }); +}); diff --git a/apps/kimi-web/test/event-batcher.test.ts b/apps/kimi-web/test/event-batcher.test.ts index 09360e6db7..be219ee9d3 100644 --- a/apps/kimi-web/test/event-batcher.test.ts +++ b/apps/kimi-web/test/event-batcher.test.ts @@ -1,148 +1,793 @@ -// apps/kimi-web/test/event-batcher.test.ts -// Unit tests for the streaming-event coalescing logic. -// -// These verify the batcher's behaviour (coalesce + preserve order + immediate -// passthrough for non-batchable items). They deliberately do NOT try to assert -// "Vue renders once" — that is a property of Vue's scheduler and is covered by -// manual perf verification, not by a unit test. - -import { describe, expect, it } from 'vitest'; -import { createEventBatcher, isRenderEvent } from '../src/composables/client/eventBatcher'; -import type { AppEvent } from '../src/api/types'; - -interface FakeSchedule { - schedule: (cb: () => void) => number; - calls: () => number; - flush: () => void; +/** + * Scenario: high-frequency WebSocket render events reach the browser queue. + * Responsibilities: preserve ordering, merge only proven-contiguous assistant + * streams, bound each drain, keep a hidden-tab task fallback, and cancel stale + * callbacks after flush. Wiring: real batcher/coalescer/reducer with only the + * browser scheduler replaced by a manual public scheduler. + * Run: pnpm --filter @moonshot-ai/kimi-web exec vitest run test/event-batcher.test.ts + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { createInitialState, reduceAppEvent, type KimiClientState } from '../src/api/daemon/eventReducer'; +import type { + AppEvent, + AppMessage, + AppSession, + AppSessionSnapshot, + KimiEventConnection, + KimiEventHandlers, + KimiWebApi, +} from '../src/api/types'; +import { + coalesceAppRenderEvents, + createEventBatcher, + isRenderEvent, + splitOversizedAppRenderEvent, + type EventBatcher, + type EventBatcherScheduler, + type PendingAppEvent, +} from '../src/composables/client/eventBatcher'; + +const clientApiMock = vi.hoisted(() => ({})); +const REASONABLE_MAX_STREAM_GROUP_CHARS = 64 * 1024; + +vi.mock('../src/api', () => ({ + getKimiWebApi: () => clientApiMock, +})); + +interface ManualScheduler extends EventBatcherScheduler { + flushFrame(): void; + flushTask(): void; + pendingFrames(): number; + pendingTasks(): number; +} + +interface OwnedQueueItem { + owner: string; + kind: 'render' | 'control'; + id: string; +} + +function manualScheduler(): ManualScheduler { + let nextHandle = 1; + const frames = new Map void>(); + const tasks = new Map void>(); + + function takeOne(callbacks: Map void>): void { + const entry = callbacks.entries().next().value as [number, () => void] | undefined; + if (entry === undefined) return; + callbacks.delete(entry[0]); + entry[1](); + } + + return { + requestFrame(callback) { + const handle = nextHandle++; + frames.set(handle, callback); + return handle; + }, + cancelFrame(handle) { + frames.delete(handle); + }, + requestTask(callback) { + const handle = nextHandle++; + tasks.set(handle, callback); + return handle; + }, + cancelTask(handle) { + tasks.delete(handle); + }, + flushFrame() { + takeOne(frames); + }, + flushTask() { + takeOne(tasks); + }, + pendingFrames: () => frames.size, + pendingTasks: () => tasks.size, + }; +} + +interface DeltaOptions { + sessionId?: string; + messageId?: string; + contentIndex?: number; + turnId?: number; + kind?: 'text' | 'thinking'; + stream?: boolean; + seq?: number; } -// A synchronous, manually-triggered scheduler. Stores the most recent callback; -// `flush()` runs it. Lets tests drive the batcher without real rAF / timers. -function fakeSchedule(): FakeSchedule { - let cb: (() => void) | null = null; - let count = 0; +function pendingDelta(value: string, offset: number, options: DeltaOptions = {}): PendingAppEvent { + const sessionId = options.sessionId ?? 'session-1'; + const kind = options.kind ?? 'text'; return { - schedule(fn) { - count += 1; - cb = fn; - return count; + appEvent: { + type: 'assistantDelta', + sessionId, + messageId: options.messageId ?? 'message-1', + contentIndex: options.contentIndex ?? 0, + delta: kind === 'text' ? { text: value } : { thinking: value }, }, - calls: () => count, - flush() { - const fn = cb; - cb = null; - fn?.(); + meta: { + sessionId, + seq: options.seq ?? offset + value.length, + stream: + options.stream === false + ? undefined + : { + turnId: options.turnId ?? 1, + offset, + kind, + }, }, }; } -describe('createEventBatcher', () => { - it('coalesces consecutive batchable items into one scheduled flush, in order', () => { +function enqueueAppEvent( + enqueue: EventBatcher, + item: PendingAppEvent, +): void { + for (const part of splitOversizedAppRenderEvent(item)) enqueue(part); +} + +function assistantState(content: AppMessage['content'] = [{ type: 'text', text: '' }]): KimiClientState { + const state = createInitialState(); + state.messagesBySession['session-1'] = [ + { + id: 'message-1', + sessionId: 'session-1', + role: 'assistant', + content, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]; + return state; +} + +describe('createEventBatcher (ordered bounded scheduling)', () => { + it('processes queued render items in arrival order when the frame runs', () => { const processed: string[] = []; - const f = fakeSchedule(); - const enqueue = createEventBatcher((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule); + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + (item) => item.startsWith('d'), + { scheduler }, + ); enqueue('d1'); enqueue('d2'); enqueue('d3'); - expect(processed).toEqual([]); // nothing processed yet - expect(f.calls()).toBe(1); // scheduled exactly once + expect(processed).toEqual([]); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + scheduler.flushFrame(); - f.flush(); expect(processed).toEqual(['d1', 'd2', 'd3']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); }); - it('applies a non-batchable item immediately when the queue is empty', () => { + it('processes a control item synchronously when no render item is pending', () => { const processed: string[] = []; - const f = fakeSchedule(); - const enqueue = createEventBatcher((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule); + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + (item) => item.startsWith('d'), + { scheduler }, + ); - enqueue('X'); + enqueue('control'); - expect(processed).toEqual(['X']); - expect(f.calls()).toBe(0); // never scheduled + expect(processed).toEqual(['control']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); }); - it('drains pending batchables before applying an immediate item', () => { + it('keeps a control item behind prior render items when one slice is enough', () => { const processed: string[] = []; - const f = fakeSchedule(); - const enqueue = createEventBatcher((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule); + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + (item) => item.startsWith('d'), + { scheduler, maxItemsPerSlice: 10 }, + ); enqueue('d1'); enqueue('d2'); - enqueue('X'); // immediate → must flush d1, d2 first + enqueue('control'); - expect(processed).toEqual(['d1', 'd2', 'X']); - - // The rAF scheduled for d1 is now stale; firing it must be a harmless no-op. - f.flush(); - expect(processed).toEqual(['d1', 'd2', 'X']); + expect(processed).toEqual(['d1', 'd2', 'control']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); }); - it('preserves arrival order across mixed batchable and immediate items', () => { + it('continues on a later callback when a control item follows more than one slice', () => { const processed: string[] = []; - const f = fakeSchedule(); - const enqueue = createEventBatcher((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule); + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + (item) => item.startsWith('d'), + { scheduler, maxItemsPerSlice: 2 }, + ); + + enqueue('d1'); + enqueue('d2'); + enqueue('d3'); + enqueue('control'); + enqueue('d4'); + + expect(processed).toEqual(['d1', 'd2']); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); - enqueue('d1'); // queued - enqueue('d2'); // queued - enqueue('A'); // immediate → drains d1, d2, then A - enqueue('d3'); // queued again - f.flush(); // drains d3 + scheduler.flushFrame(); - expect(processed).toEqual(['d1', 'd2', 'A', 'd3']); + expect(processed).toEqual(['d1', 'd2', 'd3', 'control']); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + scheduler.flushTask(); + + expect(processed).toEqual(['d1', 'd2', 'd3', 'control', 'd4']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); }); - it('reschedules after a flush when new batchable items arrive', () => { + it('uses the task fallback when animation frames do not run', () => { const processed: string[] = []; - const f = fakeSchedule(); - const enqueue = createEventBatcher((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule); + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); enqueue('d1'); - f.flush(); - expect(processed).toEqual(['d1']); - enqueue('d2'); - expect(f.calls()).toBe(2); // scheduled a second time + scheduler.flushTask(); - f.flush(); expect(processed).toEqual(['d1', 'd2']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); }); - it('flush() drains pending batchables synchronously without the scheduler', () => { + it('cancels scheduled callbacks when flush drains the queue authoritatively', () => { const processed: string[] = []; - const f = fakeSchedule(); - const enqueue = createEventBatcher((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule); + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); enqueue('d1'); enqueue('d2'); - expect(processed).toEqual([]); + enqueue.flush(); - enqueue.flush(); // synchronous drain, no scheduler callback needed expect(processed).toEqual(['d1', 'd2']); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); }); - it('flush() on an empty queue is a no-op', () => { + it('discards a removed owner control item that remains beyond the slice budget', () => { const processed: string[] = []; - const f = fakeSchedule(); - const enqueue = createEventBatcher((s) => processed.push(s), (s) => s.startsWith('d'), f.schedule); + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item.id), + (item) => item.kind === 'render', + { scheduler, maxItemsPerSlice: 2 }, + ); + + enqueue({ owner: 'session-2', kind: 'render', id: 'session-2:d1' }); + enqueue({ owner: 'session-2', kind: 'render', id: 'session-2:d2' }); + enqueue({ owner: 'session-1', kind: 'render', id: 'session-1:d1' }); + enqueue({ owner: 'session-1', kind: 'control', id: 'session-1:idle' }); + expect(processed).toEqual(['session-2:d1', 'session-2:d2']); + + enqueue.discard((item) => item.owner === 'session-1'); + scheduler.flushFrame(); + scheduler.flushTask(); + expect(processed).toEqual(['session-2:d1', 'session-2:d2']); + }); + + it('preserves the order of items not discarded', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); + + enqueue('session-1:d1'); + enqueue('session-2:d1'); + enqueue('session-1:d2'); + enqueue('session-2:d2'); + enqueue.discard((item) => item.startsWith('session-1:')); + scheduler.flushFrame(); + + expect(processed).toEqual(['session-2:d1', 'session-2:d2']); + }); + + it('cancels scheduled callbacks when discard empties the queue', () => { + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + () => {}, + () => true, + { scheduler }, + ); + + enqueue('session-1:d1'); + enqueue('session-1:d2'); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + enqueue.discard((item) => item.startsWith('session-1:')); + + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('cancels scheduled work once when dispose is repeated', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const cancelFrame = vi.spyOn(scheduler, 'cancelFrame'); + const cancelTask = vi.spyOn(scheduler, 'cancelTask'); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); + + enqueue('d1'); + enqueue('d2'); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + enqueue.dispose(); + enqueue.dispose(); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + expect(cancelFrame).toHaveBeenCalledTimes(1); + expect(cancelTask).toHaveBeenCalledTimes(1); + + scheduler.flushFrame(); + scheduler.flushTask(); + expect(processed).toEqual([]); + }); + + it('ignores items enqueued after dispose without scheduling callbacks', () => { + const processed: string[] = []; + const scheduler = manualScheduler(); + const enqueue = createEventBatcher( + (item) => processed.push(item), + () => true, + { scheduler }, + ); + + enqueue.dispose(); + enqueue('d3'); enqueue.flush(); + expect(processed).toEqual([]); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); +}); + +describe('coalesceAppRenderEvents (lossless stream grouping)', () => { + it('reduces 10,000 contiguous deltas in capped groups while preserving every character', () => { + const scheduler = manualScheduler(); + let state = assistantState(); + let reducerCalls = 0; + const groupLengths: number[] = []; + const groupOffsets: number[] = []; + const enqueue = createEventBatcher( + ({ appEvent, meta }) => { + reducerCalls += 1; + groupLengths.push( + appEvent.type === 'assistantDelta' ? (appEvent.delta.text?.length ?? 0) : 0, + ); + groupOffsets.push(meta.stream?.offset ?? -1); + state = reduceAppEvent(state, appEvent, meta); + }, + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + for (let index = 0; index < 10_000; index += 1) { + enqueueAppEvent(enqueue, pendingDelta('abcdefghijklmnop', index * 16)); + } + + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + scheduler.flushFrame(); + + expect(reducerCalls).toBeGreaterThan(1); + expect(reducerCalls).toBeLessThan(100); + expect( + groupLengths.every((length) => length <= REASONABLE_MAX_STREAM_GROUP_CHARS), + ).toBe(true); + let expectedOffset = 0; + for (let index = 0; index < groupOffsets.length; index += 1) { + expect(groupOffsets[index]).toBe(expectedOffset); + expectedOffset += groupLengths[index]!; + } + expect(state.lastSeqBySession['session-1']).toBe(160_000); + expect(state.messagesBySession['session-1']?.[0]?.content).toEqual([ + { type: 'text', text: 'abcdefghijklmnop'.repeat(10_000) }, + ]); + }); + + it('keeps a 10,000-delta hidden-tab backlog in a few capped groups', () => { + const scheduler = manualScheduler(); + const processed: PendingAppEvent[] = []; + const enqueue = createEventBatcher( + (item) => processed.push(item), + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + for (let index = 0; index < 10_000; index += 1) { + enqueueAppEvent(enqueue, pendingDelta('abcdefghijklmnop', index * 16)); + } + + expect(processed).toEqual([]); + expect(scheduler.pendingFrames()).toBe(1); + expect(scheduler.pendingTasks()).toBe(1); + + scheduler.flushTask(); + + expect(processed.length).toBeGreaterThan(1); + expect(processed.length).toBeLessThan(100); + expect( + processed.every( + ({ appEvent }) => + appEvent.type === 'assistantDelta' && + (appEvent.delta.text?.length ?? 0) <= REASONABLE_MAX_STREAM_GROUP_CHARS, + ), + ).toBe(true); + let expectedOffset = 0; + for (const item of processed) { + expect(item.meta.stream?.offset).toBe(expectedOffset); + if (item.appEvent.type === 'assistantDelta') { + expectedOffset += item.appEvent.delta.text?.length ?? 0; + } + } + expect( + processed + .map(({ appEvent }) => + appEvent.type === 'assistantDelta' ? (appEvent.delta.text ?? '') : '', + ) + .join(''), + ).toBe('abcdefghijklmnop'.repeat(10_000)); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); + + it('splits one oversized incoming delta without breaking a surrogate pair', () => { + const value = '\ud83d\ude00'.repeat(50_000) + 'tail'; + + const parts = splitOversizedAppRenderEvent(pendingDelta(value, 7)); + + expect(parts.length).toBeGreaterThan(1); + expect(parts.length).toBeLessThan(100); + let expectedOffset = 7; + for (const part of parts) { + expect(part.meta.stream?.offset).toBe(expectedOffset); + expect(part.meta.seq).toBe(7 + value.length); + if (part.appEvent.type === 'assistantDelta') { + const text = part.appEvent.delta.text ?? ''; + expect(text.length).toBeLessThanOrEqual(REASONABLE_MAX_STREAM_GROUP_CHARS); + expect(/[\uD800-\uDBFF]$/u.test(text)).toBe(false); + expect(/^[\uDC00-\uDFFF]/u.test(text)).toBe(false); + expectedOffset += text.length; + } + } + expect( + parts.every( + ({ appEvent }) => + appEvent.type === 'assistantDelta' && + (appEvent.delta.text?.length ?? 0) <= REASONABLE_MAX_STREAM_GROUP_CHARS, + ), + ).toBe(true); + expect( + parts + .map(({ appEvent }) => + appEvent.type === 'assistantDelta' ? (appEvent.delta.text ?? '') : '', + ) + .join(''), + ).toBe(value); + }); + + it.each([ + ['session differs', pendingDelta('b', 1, { sessionId: 'session-2' })], + ['turn differs', pendingDelta('b', 1, { turnId: 2 })], + ['message differs', pendingDelta('b', 1, { messageId: 'message-2' })], + ['content index differs', pendingDelta('b', 1, { contentIndex: 1 })], + ['delta kind differs', pendingDelta('b', 1, { kind: 'thinking' })], + ['offset is not contiguous', pendingDelta('b', 2)], + ['stream coordinates are missing', pendingDelta('b', 1, { stream: false })], + ])('keeps adjacent deltas separate when %s', (_condition, next) => { + const scheduler = manualScheduler(); + let processed = 0; + const enqueue = createEventBatcher( + () => { + processed += 1; + }, + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + enqueue(pendingDelta('a', 0)); + enqueue(next); + scheduler.flushFrame(); + + expect(processed).toBe(2); + }); + + it('coalesces contiguous thinking deltas into the existing thinking block', () => { + const scheduler = manualScheduler(); + let state = assistantState([{ type: 'thinking', thinking: 'seed' }]); + const enqueue = createEventBatcher( + ({ appEvent, meta }) => { + state = reduceAppEvent(state, appEvent, meta); + }, + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + enqueue(pendingDelta(' one', 0, { kind: 'thinking' })); + enqueue(pendingDelta(' two', 4, { kind: 'thinking' })); + scheduler.flushFrame(); + + expect(state.messagesBySession['session-1']?.[0]?.content).toEqual([ + { type: 'thinking', thinking: 'seed one two', signature: undefined }, + ]); + }); + + it('does not reapply a pre-snapshot delta after the snapshot seeds live text', () => { + const scheduler = manualScheduler(); + let state = assistantState(); + const enqueue = createEventBatcher( + ({ appEvent, meta }) => { + state = reduceAppEvent(state, appEvent, meta); + }, + ({ appEvent }) => isRenderEvent(appEvent), + { scheduler, coalesce: coalesceAppRenderEvents }, + ); + + enqueue(pendingDelta('stale', 0)); + enqueue.flush(); + state = assistantState([{ type: 'text', text: 'snapshot' }]); + enqueue(pendingDelta(' live', 8)); + scheduler.flushFrame(); + + expect(state.messagesBySession['session-1']?.[0]?.content).toEqual([ + { type: 'text', text: 'snapshot live' }, + ]); + expect(scheduler.pendingFrames()).toBe(0); + expect(scheduler.pendingTasks()).toBe(0); + }); +}); + +describe('useKimiWebClient (resync integration)', () => { + it('flushes queued deltas around an authoritative snapshot before live streaming resumes', async () => { + vi.stubGlobal('WebSocket', class {}); + + const sessionId = 'session-1'; + const session: AppSession = { + id: sessionId, + title: 'Session', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: 'running', + archived: false, + currentPromptId: 'prompt-1', + cwd: '/workspace', + model: 'model-1', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 1, + }, + messageCount: 1, + lastSeq: 10, + workspaceId: 'workspace-1', + }; + const snapshot = (text: string, asOfSeq: number, epoch: string): AppSessionSnapshot => ({ + asOfSeq, + epoch, + session: { ...session, lastSeq: asOfSeq }, + messages: [ + { + id: 'message-1', + sessionId, + role: 'assistant', + content: [{ type: 'text', text }], + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + hasMoreMessages: false, + inFlightTurn: { + turnId: 1, + assistantText: text, + thinkingText: '', + runningTools: [], + promptId: 'prompt-1', + }, + subagents: [], + pendingApprovals: [], + pendingQuestions: [], + }); + const initialSnapshot = snapshot('seed', 10, 'epoch-1'); + const authoritativeSnapshot = snapshot('snapshot', 20, 'epoch-2'); + + let handlers: KimiEventHandlers | undefined; + let resolveSnapshotRequest!: () => void; + const snapshotRequested = new Promise((resolve) => { + resolveSnapshotRequest = resolve; + }); + let resolveAuthoritativeSnapshot!: (value: AppSessionSnapshot) => void; + const authoritativeSnapshotResponse = new Promise((resolve) => { + resolveAuthoritativeSnapshot = resolve; + }); + let resolveSnapshotApplied!: () => void; + const snapshotApplied = new Promise((resolve) => { + resolveSnapshotApplied = resolve; + }); + let snapshotCalls = 0; + const getSessionSnapshot = vi.fn((_id: string) => { + snapshotCalls += 1; + if (snapshotCalls === 1) return Promise.resolve(initialSnapshot); + resolveSnapshotRequest(); + return authoritativeSnapshotResponse; + }); + let seedCalls = 0; + const connection: KimiEventConnection = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + bindNextPromptId: vi.fn(), + seedSnapshot: vi.fn(() => { + seedCalls += 1; + if (seedCalls === 2) resolveSnapshotApplied(); + }), + abort: vi.fn(), + terminalAttach: vi.fn(), + terminalInput: vi.fn(), + terminalResize: vi.fn(), + terminalDetach: vi.fn(), + terminalClose: vi.fn(), + markSideChannelAgent: vi.fn(), + health: () => ({ connected: true, open: true, stale: false }), + reconnect: vi.fn(), + close: vi.fn(), + }; + const api: Partial = { + getAuth: vi.fn(async () => ({ + ready: true, + defaultModel: 'model-1', + managedProvider: null, + })), + getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })), + getMeta: vi.fn(async () => ({ + serverVersion: '0.0.0', + serverId: 'server-1', + startedAt: '2026-01-01T00:00:00.000Z', + capabilities: {}, + openInApps: [], + dangerousBypassAuth: false, + backend: 'v2', + })), + getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'model-1' })), + listModels: vi.fn(async () => []), + listProviders: vi.fn(async () => []), + listWorkspaces: vi.fn(async () => [ + { + id: 'workspace-1', + root: '/workspace', + name: 'Workspace', + isGitRepo: false, + sessionCount: 1, + }, + ]), + getFsHome: vi.fn(async () => ({ home: '/home/test', recentRoots: [] })), + listSessions: vi.fn(async () => ({ items: [session], hasMore: false })), + getSessionSnapshot, + getSessionStatus: vi.fn(async () => ({ + model: 'model-1', + thinkingEffort: 'high', + permission: 'manual', + planMode: false, + swarmMode: false, + contextTokens: 0, + maxContextTokens: 0, + contextUsage: 0, + })), + getSessionGoal: vi.fn(async () => null), + getSessionWarnings: vi.fn(async () => []), + getGitStatus: vi.fn(async () => ({ + branch: '', + ahead: 0, + behind: 0, + entries: {}, + additions: 0, + deletions: 0, + pullRequest: null, + })), + listTasks: vi.fn(async () => []), + listSkills: vi.fn(async () => []), + listSkillsForWorkspace: vi.fn(async () => []), + getFileUrl: (fileId) => `file:${fileId}`, + connectEvents: vi.fn((nextHandlers) => { + handlers = nextHandlers; + return connection; + }), + }; + for (const key of Object.keys(clientApiMock)) delete clientApiMock[key]; + Object.assign(clientApiMock, api); + + try { + const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); + const client = useKimiWebClient(); + await client.load(); + const assistantText = (): string | undefined => + client.turns.value.find((turn) => turn.role === 'assistant')?.text; + + expect(assistantText()).toBe('seed'); + expect(handlers).toBeDefined(); + + const beforeResync = pendingDelta('before', 4, { seq: 11 }); + handlers!.onEvent(beforeResync.appEvent, beforeResync.meta); + handlers!.onResync(sessionId, 11, 'epoch-2'); + + // onResync synchronously drains pre-resync text onto the old state. + expect(assistantText()).toBe('seedbefore'); + await snapshotRequested; + + // A frame can race the REST request. The second flush must consume it on + // the old state before the authoritative snapshot replaces that state. + const duringSnapshot = pendingDelta('old', 10, { seq: 12 }); + handlers!.onEvent(duringSnapshot.appEvent, duringSnapshot.meta); + resolveAuthoritativeSnapshot(authoritativeSnapshot); + await snapshotApplied; + expect(assistantText()).toBe('snapshot'); + + const live = pendingDelta(' live', 8, { seq: 21 }); + handlers!.onEvent(live.appEvent, live.meta); + handlers!.onEvent( + { type: 'sessionMetaUpdated', sessionId, title: 'Session' }, + { sessionId, seq: 22 }, + ); + + expect(assistantText()).toBe('snapshot live'); + } finally { + connection.close(); + vi.unstubAllGlobals(); + } }); }); -describe('isRenderEvent', () => { +describe('isRenderEvent (queue classification)', () => { it.each(['assistantDelta', 'agentDelta', 'toolOutput', 'taskProgress'])( - 'treats %s as batchable', + 'classifies %s as a render event', (type) => { expect(isRenderEvent({ type } as AppEvent)).toBe(true); }, ); it.each(['messageCreated', 'messageUpdated', 'sessionStatusChanged', 'approvalRequested', 'configChanged'])( - 'treats %s as immediate', + 'classifies %s as a control event', (type) => { expect(isRenderEvent({ type } as AppEvent)).toBe(false); },