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.
This commit is contained in:
Neil
2026-09-07 23:24:43 -07:00
committed by GitHub
parent 28d936e030
commit ef6ad22431
8 changed files with 415 additions and 18 deletions
@@ -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<typeof EditNormalization>()
return {
...actual,
editFilesFromToolPair: (...args: Parameters<typeof actual.editFilesFromToolPair>) => {
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 (
<NativeChatMessageList
session={session}
isWorking
expandSignal
fontScale={1}
showTurnStatus={false}
/>
)
}
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(<Transcript items={[...items, tail]} />)
expect(cost.edits).toBe(20)
cost.edits = 0
cost.milliseconds = 0
for (let frame = 0; frame < 20; frame += 1) {
rerender(
<Transcript
items={[
...items,
{
...tail,
revision: frame + 2,
body: {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: `answer ${frame}` }]
}
}
]}
/>
)
}
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(
<Transcript
items={[
row(0, {
kind: 'tool-call',
name: 'Edit',
state: 'completed',
input: { file_path: 'file-0.ts', old_string: oldContent, new_string: 'Revised edit' }
}),
...items.slice(1),
tail
]}
/>
)
expect(screen.getByText('Revised edit')).toBeTruthy()
expect(screen.getAllByText('changed line 200')).toHaveLength(19)
})
@@ -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
@@ -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)
})
@@ -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<string, NativeChatMessage>()
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
}
}
@@ -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)
})
@@ -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<AgentJournalRenderItem, NativeChatMessage | null>()
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]
)
}
@@ -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<AgentSessionConversationCommandResult>(
'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<typeof outboxController.send>) =>
!commandPending.current && outboxController.send(...input),
@@ -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 => ({