fix(desktop): settle a tool run whose calls never resolved

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 17:29:06 -05:00
parent f7d6c1be8f
commit f96997c36f
4 changed files with 143 additions and 26 deletions

View file

@ -849,16 +849,28 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState {
const cache = useRef<null | { signature: string; value: ToolRunState }>(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)
}
}
}

View file

@ -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 })
})
})

View file

@ -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<RunCategory, ToolCallLike[]>()

View file

@ -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<ThreadMessage>({
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(<GroupHarness message={abandonedRunMessage()} />)
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(<GroupHarness message={movedOnMessage()} />)
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(<GroupHarness message={groupedPendingMessage()} />)