From f96997c36face3e0db92954fd6c0724ebd78c6c8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 17:29:06 -0500 Subject: [PATCH] fix(desktop): settle a tool run whose calls never resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run inferred "still working" from a missing result alone, so a call left unresolved — by an interrupted turn, or an agent that moved on — pinned its run as live forever. That stranded the summary in the present tense ("Exploring 2 files" on a finished turn) and, because a live run withholds its toggle so approvals can't hide, left the run permanently expanded with no way to collapse it. Qualify liveness the way ToolEntry already qualifies a row's: a missing result only means pending while the run is the tail of a running message. Liveness is now passed into summarizeToolRun rather than read off the calls, since it isn't a property of the calls. --- .../components/assistant-ui/tool/fallback.tsx | 20 +++- .../assistant-ui/tool/run-summary.test.ts | 43 ++++---- .../assistant-ui/tool/run-summary.ts | 9 +- .../assistant-ui/tool/tool-group.test.tsx | 97 +++++++++++++++++++ 4 files changed, 143 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 8a0a5434025..799a87df3ee 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -849,16 +849,28 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { const cache = useRef(null) return useAuiState(state => { - const tools = state.message.parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart) - const signature = tools.map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`).join('|') + const parts = state.message.parts + const tools = parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart) + // A missing result only means "still working" while this run is the tail of + // a running message — the same qualification ToolEntry puts on a row's + // pending state. A turn that ends, or an agent that moves on to later + // parts, can leave a call unresolved forever; treating that as live would + // strand the run in the present tense AND leave it uncollapsible, since a + // live run deliberately withholds its toggle. + const live = + selectMessageRunning(state) && endIndex >= parts.length - 1 && tools.some(tool => tool.result === undefined) + const signature = tools + .map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`) + .concat(String(live)) + .join('|') if (cache.current?.signature !== signature) { cache.current = { signature, value: { key: tools[0]?.toolCallId ?? '', - live: tools.some(tool => tool.result === undefined), - summary: summarizeToolRun(tools) + live, + summary: summarizeToolRun(tools, live) } } } diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts index ff36dcda30f..be298640ce1 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts @@ -10,54 +10,57 @@ const edited = (path: string, diff = '') => tool('write_file', { path }, { path, const read = (path: string) => tool('read_file', { path }, { content: '' }) const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 }) +const settled = (tools: ToolCallLike[]) => summarizeToolRun(tools, false) +const running = (tools: ToolCallLike[]) => summarizeToolRun(tools, true) + describe('summarizeToolRun', () => { it('names a lone edit and counts the rest', () => { - expect( - summarizeToolRun([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text - ).toBe('Edited use-preview-routing.ts, explored 3 files') + expect(settled([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text).toBe( + 'Edited use-preview-routing.ts, explored 3 files' + ) }) it('orders clauses edit, explore, run regardless of call order', () => { expect( - summarizeToolRun([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')]) - .text + settled([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')]).text ).toBe('Edited attachments.tsx, explored 2 files, ran 3 commands') }) it('counts commands rather than naming them once they have run', () => { - expect(summarizeToolRun([ran('git status')]).text).toBe('Ran 1 command') - expect(summarizeToolRun([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe( + expect(settled([ran('git status')]).text).toBe('Ran 1 command') + expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe( 'Explored status.ts, ran 5 commands' ) }) it('counts a multi-file edit', () => { - expect(summarizeToolRun([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe( - 'Edited 2 files, explored c.ts' - ) + expect(settled([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe('Edited 2 files, explored c.ts') }) it('puts the running category in the present tense and leaves the rest past', () => { - const live = [edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')] - - expect(summarizeToolRun(live).text).toBe('Editing 2 files, ran 2 commands') + expect(running([edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')]).text).toBe( + 'Editing 2 files, ran 2 commands' + ) }) it('names the command that is still running', () => { - expect(summarizeToolRun([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /) + expect(running([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /) + }) + + // A turn can end — or the agent can simply move on — with a call that never + // got a result. The run is history at that point and has to read as history, + // or it narrates work that stopped happening and never offers its toggle. + it('reads a run the turn left unresolved as finished', () => { + expect(settled([read('a.ts'), tool('search_files', { query: 'toolRuns' })]).text).toBe('Explored 2 files') }) it('sums diff stats across the edits in the run', () => { - const summary = summarizeToolRun([ - edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'), - edited('b.tsx', '+three'), - ran('ls') - ]) + const summary = settled([edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'), edited('b.tsx', '+three'), ran('ls')]) expect(summary).toMatchObject({ added: 3, removed: 1 }) }) it('reports no diff stats for a run that changed nothing', () => { - expect(summarizeToolRun([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 }) + expect(settled([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 }) }) }) diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts index 7127a8f1efe..96458435ed4 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -137,9 +137,14 @@ function lowerFirst(text: string): string { * — "Edited wiring.tsx, explored 3 files, ran 5 commands". The category holding * the still-running tool speaks in the present tense so a live run reads as * work in progress rather than work already done. + * + * Whether the run is `live` is the caller's to say, not something readable off + * the calls: a call can be left without a result by a turn that ended or an + * agent that moved on, and a run like that has to read as finished rather than + * narrate work that stopped happening. */ -export function summarizeToolRun(tools: readonly ToolCallLike[]): RunSummary { - const running = tools.find(isPending) +export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): RunSummary { + const running = live ? tools.find(isPending) : undefined const liveCategory = running ? toolCategory(running.toolName) : null const byCategory = new Map() diff --git a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx index 25c46fbe3fd..75595a248fe 100644 --- a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx @@ -220,6 +220,83 @@ function settledRunMessage(): ThreadMessage { } as ThreadMessage } +// A finished turn that left a call without a result — interrupted, or the +// result landed elsewhere. The run is history and has to behave like it. +function abandonedRunMessage(): ThreadMessage { + return { + id: 'assistant-abandoned-run', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-3', + toolName: 'read_file', + args: { path: '/repo/src/status.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/status.tsx' }), + result: { content: 'export const Status = () => null' } + }, + { + type: 'tool-call', + toolCallId: 'search-1', + toolName: 'search_files', + args: { query: 'toolRuns' }, + argsText: JSON.stringify({ query: 'toolRuns' }) + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +// Still streaming, but the agent has moved past its first run and left both of +// its calls unresolved. Only the run at the tail is still live. +function movedOnMessage(): ThreadMessage { + return { + id: 'assistant-moved-on', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-4', + toolName: 'read_file', + args: { path: '/repo/src/status.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/status.tsx' }) + }, + { + type: 'tool-call', + toolCallId: 'search-2', + toolName: 'search_files', + args: { query: 'toolRuns' }, + argsText: JSON.stringify({ query: 'toolRuns' }) + }, + { type: 'text', text: 'Let me read the rest of the file.' }, + { + type: 'tool-call', + toolCallId: 'term-2', + toolName: 'terminal', + args: { command: 'ls' }, + argsText: JSON.stringify({ command: 'ls' }) + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + function GroupHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ messages: [message], @@ -297,6 +374,26 @@ describe('live tool run', () => { }) }) +// A run whose calls never resolved used to read as live forever, which stranded +// it in the present tense and — because a live run withholds its toggle — left +// it permanently expanded with no way to collapse it. +describe('tool run left unresolved', () => { + it('settles with the turn rather than narrating work that stopped', async () => { + const { container } = render() + + expect(await screen.findByText('Explored 2 files')).toBeTruthy() + expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0) + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull() + }) + + it('settles once the agent moves on, even mid-turn', async () => { + const { container } = render() + + expect(await screen.findByText('Explored 2 files')).toBeTruthy() + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull() + }) +}) + describe('flat tool list approval surfacing', () => { it('renders no inline approval bar when there is no live approval', async () => { const { container } = render()