From ef6ad22431c851abba31a25da547c0c8a67a31f4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:24:43 -0700 Subject: [PATCH] perf(native-chat): preserve historical tool rows while streaming (#19364) * perf(native-chat): preserve historical tool rows while streaming * perf(native-chat): short-circuit identical rows and lock producer immutability Most folded rows come back as the input object, so compare identity before scanning fields and blocks. Add a regression test for the invariant the reuse cache depends on: ordering and folding never rewrite producer-owned messages or blocks, which reused rows alias. --- ...eChatMessageList.tool-stream-cost.test.tsx | 117 ++++++++++++++++++ .../native-chat/NativeChatMessageList.tsx | 15 ++- ...ative-chat-message-list-projection.test.ts | 84 +++++++++++++ .../native-chat-message-list-projection.ts | 45 +++++++ ...structured-agent-session-messages.test.tsx | 114 +++++++++++++++++ .../use-structured-agent-session-messages.ts | 37 ++++++ .../use-structured-agent-session.ts | 16 +-- ...ctured-agent-session-message-projection.ts | 5 +- 8 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-message-list-projection.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx new file mode 100644 index 00000000000..0a2c18d5045 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type * as EditNormalization from '../../../../shared/native-chat-edit-normalize' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages' + +const cost = vi.hoisted(() => ({ edits: 0, milliseconds: 0 })) +vi.mock('../../../../shared/native-chat-edit-normalize', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + editFilesFromToolPair: (...args: Parameters) => { + cost.edits += 1 + const start = performance.now() + const result = actual.editFilesFromToolPair(...args) + cost.milliseconds += performance.now() - start + return result + } + } +}) +const { NativeChatMessageList } = await import('./NativeChatMessageList') +afterEach(cleanup) + +const EMPTY: never[] = [] +const loadEarlier = () => {} +function Transcript({ items }: { items: AgentJournalRenderItem[] }) { + const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY) + const session: NativeChatLiveSession = { + messages, + status: 'working', + sessionId: 'session', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + loadEarlier, + readPhase: 'ready' + } + return ( + + ) +} + +function row(index: number, body: AgentJournalRenderItem['body']): AgentJournalRenderItem { + return { itemId: `item-${index}`, revision: 1, sequence: index, observedAt: index, body } +} + +it('does not re-diff expanded historical edits when an unrelated answer streams', () => { + const oldContent = Array.from({ length: 400 }, (_, index) => `old line ${index}`).join('\n') + const newContent = oldContent.replace('old line 200', 'changed line 200') + const items = Array.from({ length: 20 }, (_, index) => + row(index, { + kind: 'tool-call', + name: 'Edit', + state: 'completed', + input: { file_path: `file-${index}.ts`, old_string: oldContent, new_string: newContent } + }) + ) + items.push( + row(20, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Next task' }] }) + ) + const tail = row(21, { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'answer' }] + }) + const { rerender } = render() + expect(cost.edits).toBe(20) + cost.edits = 0 + cost.milliseconds = 0 + for (let frame = 0; frame < 20; frame += 1) { + rerender( + + ) + } + console.info('Historical edit work over 20 stream frames:', { ...cost }) + expect(cost.edits).toBe(0) + expect(screen.getByText('answer 19')).toBeTruthy() + expect(screen.getAllByText('changed line 200')).toHaveLength(20) + + rerender( + + ) + expect(screen.getByText('Revised edit')).toBeTruthy() + expect(screen.getAllByText('changed line 200')).toHaveLength(19) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 69b81ffd82f..641e193d7e7 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -3,9 +3,7 @@ import { ArrowDown } from 'lucide-react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { translate } from '@/i18n/i18n' import type { NativeChatLiveSession } from './use-native-chat-live-session' -import { orderNativeChatMessages } from './native-chat-message-grouping' -import { stripNoiseMessages } from './native-chat-noise' -import { foldToolMessages } from './native-chat-tool-fold' +import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll' import { MessageRow } from './NativeChatMessageRow' import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' @@ -79,10 +77,15 @@ export function NativeChatMessageList({ stuckToBottomRef.current = stuckToBottom const { hasMore, loadingEarlier, loadEarlier } = session - // Keep hidden harness turns as fold boundaries, then strip them before render. + const projectMessages = useMemo( + () => createNativeChatMessageListProjection(), + // Rebound sessions must release the previous transcript's cached rows. + // eslint-disable-next-line react-hooks/exhaustive-deps + [session.agent, session.sessionId] + ) const messages = useMemo( - () => stripNoiseMessages(foldToolMessages(orderNativeChatMessages(session.messages))), - [session.messages] + () => projectMessages(session.messages), + [projectMessages, session.messages] ) const showTypingIndicator = showTurnStatus ? isWorking diff --git a/src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts b/src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts new file mode 100644 index 00000000000..89c114613e5 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-message-list-projection.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' +import { orderNativeChatMessages } from './native-chat-message-grouping' +import { stripNoiseMessages } from './native-chat-noise' +import { foldToolMessages } from './native-chat-tool-fold' + +function message( + id: string, + timestamp: number, + blocks: NativeChatMessage['blocks'], + role: NativeChatMessage['role'] = 'assistant' +): NativeChatMessage { + return { id, timestamp, blocks, role, source: 'transcript' } +} + +it('retains settled folded runs while exposing changed tools, metadata, and attribution boundaries', () => { + const project = createNativeChatMessageListProjection() + const prose = message('prose', 1, [{ type: 'text', text: 'Inspecting the workspace' }]) + const call = message('call', 2, [{ type: 'tool-call', name: 'shell', input: { command: 'pwd' } }]) + const result = message('result', 3, [{ type: 'tool-result', output: '/workspace' }], 'tool') + const prompt = message('prompt', 4, [{ type: 'text', text: 'Next task' }], 'user') + const tail = message('tail', 5, [{ type: 'text', text: 'Answer' }]) + const initial = project([prose, call, result, prompt, tail]) + expect(project([prose, call, result, prompt, tail])).toBe(initial) + const streamed = project([ + prose, + call, + result, + prompt, + { ...tail, blocks: [{ type: 'text', text: 'Answer grows' }] } + ]) + expect(streamed[0]).toBe(initial[0]) + expect(streamed.at(-1)).not.toBe(initial.at(-1)) + + const lateResult = { ...result, blocks: [{ type: 'tool-result' as const, output: '/different' }] } + const interruption = message( + 'interrupt', + 2.5, + [{ type: 'text', text: '[Request interrupted by user]' }], + 'user' + ) + const earlier = message('earlier', 0, [{ type: 'text', text: 'Earlier task' }], 'user') + const scenarios = [ + [prose, call, lateResult, prompt, tail], + [prose, call, interruption, result, prompt, tail], + [tail, result, prompt, call, prose, earlier], + [prose, result, prompt, tail], + [prose, call, result], + [{ ...prose, source: 'hook' as const, turnId: 'different' }, call, result], + [{ ...prose, timestamp: 4 }, call, result, prompt, tail], + structuredClone([prose, call, result, prompt, tail]), + [] + ] + for (const messages of scenarios) { + expect(project(messages)).toEqual( + stripNoiseMessages(foldToolMessages(orderNativeChatMessages(messages))) + ) + } + expect(project([prose, call, result])[0]).not.toBe(initial[0]) +}) + +// A reused row aliases producer-owned block objects (a journal item's `body.blocks`), +// so an in-place rewrite here would freeze what the transcript renders. +it('leaves producer-owned messages and blocks untouched', () => { + const project = createNativeChatMessageListProjection() + const prose = message('prose', 1, [{ type: 'text', text: 'Working' }]) + const call = message('call', 2, [{ type: 'tool-call', name: 'shell', input: { command: 'pwd' } }]) + const result = message('result', 3, [{ type: 'tool-result', output: '/workspace' }], 'tool') + const later = message( + 'later', + 4, + [{ type: 'tool-call', name: 'read', input: { path: 'a.ts' } }], + 'tool' + ) + const input = [prose, call, result, later] + const snapshot = structuredClone(input) + const folded = project(input) + expect(folded[0]?.blocks).toHaveLength(4) + expect(folded[0]?.blocks[0]).toBe(prose.blocks[0]) + project([...input, message('tail', 5, [{ type: 'text', text: 'Answer' }])]) + expect(input).toEqual(snapshot) + expect(prose.blocks).toHaveLength(1) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-message-list-projection.ts b/src/renderer/src/components/native-chat/native-chat-message-list-projection.ts new file mode 100644 index 00000000000..53c85bb7129 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-message-list-projection.ts @@ -0,0 +1,45 @@ +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { orderNativeChatMessages } from './native-chat-message-grouping' +import { stripNoiseMessages } from './native-chat-noise' +import { foldToolMessages } from './native-chat-tool-fold' + +function sameMessage(left: NativeChatMessage, right: NativeChatMessage): boolean { + // Folding only clones the assistant rows that absorb a tool run; every other row + // comes back as the input object, so most rows settle without a field scan. + if (left === right) { + return true + } + const keys = Object.keys(left) as (keyof NativeChatMessage)[] + return ( + keys.length === Object.keys(right).length && + keys.every( + (key) => Object.hasOwn(right, key) && (key === 'blocks' || left[key] === right[key]) + ) && + left.blocks.length === right.blocks.length && + left.blocks.every((block, index) => block === right.blocks[index]) + ) +} + +export function createNativeChatMessageListProjection(): ( + messages: NativeChatMessage[] +) => NativeChatMessage[] { + let previous: NativeChatMessage[] = [] + let byId = new Map() + return (messages) => { + const folded = stripNoiseMessages(foldToolMessages(orderNativeChatMessages(messages))) + const next = folded.map((message) => { + const prior = byId.get(message.id) + // Folding clones historical tool runs even when every contributing block is unchanged. + return prior && sameMessage(prior, message) ? prior : message + }) + if ( + next.length === previous.length && + next.every((message, index) => message === previous[index]) + ) { + return previous + } + previous = next + byId = new Map(next.map((message) => [message.id, message])) + return next + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx new file mode 100644 index 00000000000..68e388de58c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages' + +afterEach(cleanup) +const EMPTY: never[] = [] +function tool(id: string, sequence: number): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + observedAt: sequence, + sequence, + body: { kind: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'running' } + } +} + +it('retains only unchanged item projections across updates, reorder, deletion, and rehydration', () => { + const first = tool('first', 1) + const second = tool('second', 2) + const { result, rerender } = renderHook( + (items: AgentJournalRenderItem[]) => useStructuredAgentSessionMessages(items, EMPTY, EMPTY), + { initialProps: [first, second] } + ) + const initial = result.current + rerender([first, second]) + expect(result.current[0]).toBe(initial[0]) + expect(result.current[1]).toBe(initial[1]) + const completed: AgentJournalRenderItem = { + ...second, + revision: 2, + body: { + kind: 'tool-call', + name: 'shell', + input: { command: 'pwd' }, + state: 'completed', + output: { head: '/workspace', truncated: false, byteLength: 10, digest: 'a' } + } + } + for (const items of [ + [first, completed], + [completed, first], + [completed], + [structuredClone(completed)] + ]) { + rerender(items) + expect(result.current).toEqual(projectStructuredAgentSessionMessages(items, EMPTY, EMPTY)) + expect(result.current.find((message) => message.id === 'second')).not.toBe(initial[1]) + } + const replacement = { + ...first, + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'Another session with the same item id' }] + } + } + rerender([replacement]) + expect(result.current).toEqual(projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY)) + expect(result.current[0]).not.toBe(initial[0]) +}) + +it('keeps optimistic sends and their settlement identical to uncached projection', () => { + const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'send', + sessionId: 'session', + text: 'Send this', + attachments: [], + queuedAt: 1 + }) + const submission: AgentJournalSubmission = { + clientMessageId: 'send', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + const { result, rerender } = renderHook( + ({ + items, + submissions + }: { + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + }) => useStructuredAgentSessionMessages(items, [entry], submissions), + { initialProps: { items: [tool('tool', 1)], submissions: [submission] } } + ) + for (const dispatchState of ['pending', 'unknown', 'accepted'] as const) { + const props = { items: [tool('tool', 1)], submissions: [{ ...submission, dispatchState }] } + rerender(props) + expect(result.current).toEqual( + projectStructuredAgentSessionMessages(props.items, [entry], props.submissions) + ) + } +}) + +it('does no transcript projection work on a status-only render', () => { + const items = [tool('tool', 1)] + const { result, rerender } = renderHook(() => + useStructuredAgentSessionMessages(items, EMPTY, EMPTY) + ) + const initial = result.current + rerender() + expect(result.current).toBe(initial) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts new file mode 100644 index 00000000000..c44ff16fba3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +export function useStructuredAgentSessionMessages( + items: readonly AgentJournalRenderItem[], + outbox: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +) { + const projectItems = useMemo(() => { + // Journal revisions replace item objects; weak keys release removed history. + const byItem = new WeakMap() + return (rows: readonly AgentJournalRenderItem[]): NativeChatMessage[] => { + const messages: NativeChatMessage[] = [] + for (const row of rows) { + if (!byItem.has(row)) { + byItem.set(row, projectStructuredItemToNativeChat(row)) + } + const message = byItem.get(row) + if (message) { + messages.push(message) + } + } + return messages + } + }, []) + return useMemo( + () => projectStructuredAgentSessionMessages(items, outbox, submissions, projectItems), + [items, outbox, submissions, projectItems] + ) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index d32ad3383dd..aa7efcb7a4e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -33,10 +33,10 @@ import { import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' import { - projectStructuredAgentSessionMessages, pendingStructuredSessionPrompts, type StructuredPromptItem } from './structured-agent-session-message-projection' +import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages' import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' @@ -250,6 +250,8 @@ export function useStructuredAgentSession(args: { ) const prompts = pendingStructuredSessionPrompts(state.items) + const { outbox } = outboxController + const messages = useStructuredAgentSessionMessages(state.items, outbox, state.submissions) return { conversationCommands: conversationSupport?.sessionId === sessionId ? conversationSupport.commands : [], @@ -257,9 +259,7 @@ export function useStructuredAgentSession(args: { conversationCommands.sendStructuredConversationCommand({ command, pending: commandPending, - blocked: Boolean( - turnId || prompts.length || isMonitoringBackgroundTasks || outboxController.outbox.length - ), + blocked: Boolean(turnId || prompts.length || isMonitoringBackgroundTasks || outbox.length), send: (command) => mutate( 'agentSession.conversationCommand', @@ -267,18 +267,14 @@ export function useStructuredAgentSession(args: { { command } ) }), - messages: projectStructuredAgentSessionMessages( - state.items, - outboxController.outbox, - state.submissions - ), + messages, status: state.status, error: state.error ?? writeError ?? outboxController.error, hasOlder: state.hasOlder, loadingOlder, loadOlder, prompts, - outbox: outboxController.outbox, + outbox, blockedClientMessageId: outboxController.blockedClientMessageId, send: (...input: Parameters) => !commandPending.current && outboxController.send(...input), diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index c6735a8c772..fe3d8d764a3 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -10,12 +10,13 @@ import { projectStructuredItemsToNativeChat } from './structured-agent-session-p export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions) const journalled = new Set(items.map((item) => item.itemId)) return [ - ...projectStructuredItemsToNativeChat(items), + ...projectItems(items), ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) .map((entry): NativeChatMessage => ({