From 24a025e9066191a987cabf4c572f9ff5d1e638cf Mon Sep 17 00:00:00 2001 From: colafornia Date: Fri, 28 Aug 2026 17:29:54 +0800 Subject: [PATCH] perf(desktop): batch live tool output Publish high-frequency tool output once per animation frame while preserving synchronous lifecycle ordering.\n\nBound pending output with the existing live-turn limits and discard queued events when a session is cleared.\n\nGenerated-by: Codex --- .../main/__tests__/streaming-handoff.test.ts | 106 ++++++++++++++++++ .../src/renderer/app-shell-session-events.ts | 42 ++++++- apps/desktop/src/renderer/app-shell.tsx | 2 + 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 53fb370497..ba7690b948 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -380,6 +380,112 @@ describe('single live-turn handoff', () => { assert.equal(publications, 2); }); + it('bounds tool output queued for one animation frame', () => { + const liveTurns = createStateSetter>({ + 'session-1': armLiveTurn('turn-1'), + }); + const liveTurnBySessionRef = { current: liveTurns.get() }; + const interactions = createStateSetter({}); + const frames: Array<() => void> = []; + const displayBatch = createAppShellSessionDisplayBatch(); + let publications = 0; + const handlers = createAppShellSessionEventHandlers({ + uiLocale: 'zh', + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: (updater) => { + publications += 1; + liveTurns.set(updater); + liveTurnBySessionRef.current = liveTurns.get(); + }, + setInteractionBySession: interactions.set, + showModelSetupToast: () => {}, + toastApi: { error: () => {} }, + scheduleFrame: (callback) => { frames.push(callback); }, + displayBatch, + }); + + handlers.handleEvent('session-1', { + type: 'tool_start', id: 'start', turnId: 'turn-1', toolUseId: 'tool-1', + toolName: 'Bash', args: {}, ts: 0, + }); + publications = 0; + for (let index = 0; index <= 200; index += 1) { + handlers.handleEvent('session-1', { + type: 'tool_output_delta', id: `output-${index}`, turnId: 'turn-1', + sessionId: 'session-1', toolCallId: 'tool-1', toolUseId: 'tool-1', + seq: index, stream: 'stdout', chunk: 'x', redacted: false, + createdAt: index + 1, ts: index + 1, + }); + } + + assert.equal(publications, 0); + assert.equal(frames.length, 1); + assert.equal(displayBatch.pendingEvents.get('session-1')?.length, 200); + frames.shift()?.(); + assert.equal(publications, 1); + const chunks = liveTurns.get()['session-1']?.steps[0]?.tools[0]?.outputChunks; + assert.equal(chunks?.length, 200); + assert.equal(chunks?.[0]?.seq, 1); + assert.equal(chunks?.at(-1)?.seq, 200); + + for (let index = 201; index <= 203; index += 1) { + handlers.handleEvent('session-1', { + type: 'tool_output_delta', id: `output-${index}`, turnId: 'turn-1', + sessionId: 'session-1', toolCallId: 'tool-1', toolUseId: 'tool-1', + seq: index, stream: 'stdout', chunk: 'y'.repeat(8 * 1024), redacted: false, + createdAt: index + 1, ts: index + 1, + }); + } + const pending = displayBatch.pendingEvents.get('session-1'); + assert.equal(publications, 1); + assert.equal(pending?.length, 2); + assert.equal(pending?.[0]?.type === 'tool_output_delta' ? pending[0].seq : undefined, 202); + assert.equal(pending?.[1]?.type === 'tool_output_delta' ? pending[1].seq : undefined, 203); + assert.equal(frames.length, 1); + frames.shift()?.(); + assert.equal(publications, 2); + }); + + it('does not publish queued output after its session is cleared', () => { + const liveTurns = createStateSetter>({ + 'session-1': armLiveTurn('turn-1'), + }); + const liveTurnBySessionRef = { current: liveTurns.get() }; + const interactions = createStateSetter({}); + const frames: Array<() => void> = []; + const handlers = createAppShellSessionEventHandlers({ + uiLocale: 'zh', + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: (updater) => { + liveTurns.set(updater); + liveTurnBySessionRef.current = liveTurns.get(); + }, + setInteractionBySession: interactions.set, + showModelSetupToast: () => {}, + toastApi: { error: () => {} }, + scheduleFrame: (callback) => { frames.push(callback); }, + }); + + handlers.handleEvent('session-1', { + type: 'tool_output_delta', id: 'output', turnId: 'turn-1', + sessionId: 'session-1', toolCallId: 'tool-1', toolUseId: 'tool-1', + seq: 0, stream: 'stdout', chunk: 'late', redacted: false, + createdAt: 1, ts: 1, + }); + handlers.dropDisplayEvents('session-1'); + liveTurns.set(() => ({})); + liveTurnBySessionRef.current = liveTurns.get(); + + frames.shift()?.(); + assert.equal(liveTurns.get()['session-1'], undefined); + }); + it('applies catch-up deltas immediately until the returning session is seeded', () => { const liveTurns = createStateSetter>({ 'session-1': armLiveTurn('turn-1'), diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 5ce1ecc885..fde66da7ca 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -28,6 +28,8 @@ import { enqueueInteraction, reconcileTerminalLiveTurn, settleLiveTurnStep, + TOOL_STREAM_MAX_CHUNKS, + TOOL_STREAM_MAX_TOTAL_CHARS, type LiveTurnProjection, type InteractionQueues, type TransientUserMessageProjection, @@ -62,6 +64,7 @@ export interface AppShellSessionEventHandlers { reconcilePersistedMessages(sessionId: string, messages: readonly StoredMessage[]): void; settleAssistantStreaming(sessionId: string, messageId?: string): Promise; flushDisplayEvents(sessionId: string): void; + dropDisplayEvents(sessionId: string): void; markDisplayPending(sessionId: string): void; markDisplayReady(sessionId: string): void; } @@ -173,9 +176,28 @@ export function createAppShellSessionEventHandlers(options: { } function scheduleDisplayEvent(sessionId: string, event: SessionEvent): void { - const events = displayBatch.pendingEvents.get(sessionId); - if (events) events.push(event); - else displayBatch.pendingEvents.set(sessionId, [event]); + const events = displayBatch.pendingEvents.get(sessionId) ?? []; + events.push(event); + displayBatch.pendingEvents.set(sessionId, events); + if (event.type === 'tool_output_delta') { + let chunks = 0; + let chars = 0; + for (let index = events.length - 1; index >= 0; index -= 1) { + const candidate = events[index]; + if ( + candidate?.type === 'tool_output_delta' + && candidate.turnId === event.turnId + && candidate.toolUseId === event.toolUseId + ) { + chunks += 1; + chars += candidate.chunk.length; + if ( + chunks > TOOL_STREAM_MAX_CHUNKS + || chars > TOOL_STREAM_MAX_TOTAL_CHARS + ) events.splice(index, 1); + } + } + } if (displayBatch.framePending || !scheduleFrame) return; displayBatch.framePending = true; scheduleFrame(() => { @@ -193,6 +215,11 @@ export function createAppShellSessionEventHandlers(options: { updateLiveTurn(sessionId, events); } + function dropDisplayEvents(sessionId: string): void { + displayBatch.pendingEvents.delete(sessionId); + displayBatch.displayPendingSessions.delete(sessionId); + } + function markDisplayPending(sessionId: string): void { displayBatch.displayPendingSessions.add(sessionId); } @@ -276,11 +303,17 @@ export function createAppShellSessionEventHandlers(options: { } function handleEvent(sessionId: string, event: SessionEvent): void { + // Only unbounded, append-only display streams may wait for paint. Every + // lifecycle/readiness event stays synchronous and flushes these first. if ( scheduleFrame && activeIdRef.current === sessionId && canBatchDisplayEvents(sessionId) - && (event.type === 'text_delta' || event.type === 'thinking_delta') + && ( + event.type === 'text_delta' + || event.type === 'thinking_delta' + || event.type === 'tool_output_delta' + ) ) { scheduleDisplayEvent(sessionId, event); return; @@ -434,6 +467,7 @@ export function createAppShellSessionEventHandlers(options: { reconcilePersistedMessages, settleAssistantStreaming, flushDisplayEvents, + dropDisplayEvents, markDisplayPending, markDisplayReady, }; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 2c61228a42..f7d0a7e6ed 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -937,6 +937,7 @@ function AppShellContent({ } function clearSessionRendererState(sessionId: string): void { + dropDisplayEvents(sessionId); clearOwnedSessionState(sessionId); turnActionRegistry.clearForSession(sessionId); permissionModeChangeRegistry.keysRef.current.delete(sessionId); @@ -2191,6 +2192,7 @@ function AppShellContent({ reconcilePersistedMessages, settleAssistantStreaming, flushDisplayEvents, + dropDisplayEvents, markDisplayPending, markDisplayReady, } = useStableActions(createAppShellSessionEventHandlers, {