feat(desktop): summarize a run of tool calls as one line

Adds the grammar behind "Edited wiring.tsx, explored 3 files, ran 5
commands": one clause per category of work, a name when the category
holds a single thing and a count otherwise, and the present tense for
whichever category is still running.

The continuity test is the load-bearing part. Tool grouping was reverted
once because it reshuffled the moment a turn settled, so this replays the
same turn twice — as the gateway event stream the live view builds
bubbles from, and as the rows toChatMessages rehydrates on resume — and
asserts both produce the same runs.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 15:38:59 -05:00
parent 9f02bb207d
commit 97d790d8b1
4 changed files with 483 additions and 2 deletions

View file

@ -65,7 +65,7 @@ function fileEditPath(args: Record<string, unknown>, result: Record<string, unkn
)
}
function fileEditBasename(path: string): string {
export function fileEditBasename(path: string): string {
const normalized = path.replace(/\\/g, '/').trim()
return normalized.split('/').filter(Boolean).pop() || normalized
@ -585,7 +585,7 @@ function summarizeBrowserSnapshot(snapshot: string): string {
return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats
}
function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
export function firstStringField(record: Record<string, unknown>, keys: readonly string[]): string {
for (const key of keys) {
const value = record[key]

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { summarizeToolRun, type ToolCallLike } from './run-summary'
function tool(toolName: string, args: Record<string, unknown> = {}, result?: unknown): ToolCallLike {
return { args, result, toolCallId: `${toolName}-${Math.random()}`, toolName }
}
const edited = (path: string, diff = '') => tool('write_file', { path }, { path, inline_diff: diff })
const read = (path: string) => tool('read_file', { path }, { content: '' })
const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 })
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')
})
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
).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(
'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'
)
})
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')
})
it('names the command that is still running', () => {
expect(summarizeToolRun([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /)
})
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')
])
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 })
})
})

View file

@ -0,0 +1,168 @@
import { summarizeShellCommand } from '@/lib/summarize-command'
import {
countDiffLineStats,
fileEditBasename,
firstStringField,
inlineDiffFromResult,
isFileEditTool,
parseMaybeObject
} from './fallback-model'
/**
* The little a summary needs from a tool call, stated structurally so both
* shapes of tool part satisfy it the stored `ChatMessagePart` and the live
* one assistant-ui hands to a renderer.
*/
export interface ToolCallLike {
args?: unknown
result?: unknown
toolCallId?: string
toolName: string
}
export function isToolCallPart<T extends { type: string }>(part: T): part is Extract<T, { type: 'tool-call' }> {
return part.type === 'tool-call'
}
type RunCategory = 'delegate' | 'edit' | 'explore' | 'other' | 'run'
export interface RunSummary {
added: number
removed: number
text: string
}
// Clause order is fixed so the same run always reads the same way, whichever
// category happens to be live.
const CATEGORY_ORDER: readonly RunCategory[] = ['edit', 'explore', 'run', 'delegate', 'other']
const CATEGORY_COPY: Record<RunCategory, { noun: [string, string]; past: string; present: string }> = {
delegate: { noun: ['task', 'tasks'], past: 'Delegated', present: 'Delegating' },
edit: { noun: ['file', 'files'], past: 'Edited', present: 'Editing' },
explore: { noun: ['file', 'files'], past: 'Explored', present: 'Exploring' },
other: { noun: ['tool', 'tools'], past: 'Used', present: 'Using' },
run: { noun: ['command', 'commands'], past: 'Ran', present: 'Running' }
}
const EXPLORE_TOOLS = new Set([
'list_files',
'read_file',
'search_files',
'session_search_recall',
'vision_analyze',
'web_extract',
'web_search'
])
function toolCategory(toolName: string): RunCategory {
if (isFileEditTool(toolName)) {
return 'edit'
}
if (toolName === 'terminal' || toolName === 'execute_code') {
return 'run'
}
if (toolName === 'delegate_task') {
return 'delegate'
}
if (EXPLORE_TOOLS.has(toolName) || toolName.startsWith('browser_')) {
return 'explore'
}
return 'other'
}
function isPending(tool: ToolCallLike): boolean {
return tool.result === undefined
}
/** The thing a tool acted on, as the header should name it. */
function toolTarget(tool: ToolCallLike): string {
const args = parseMaybeObject(tool.args)
if (toolCategory(tool.toolName) === 'run') {
return summarizeShellCommand(firstStringField(args, ['command', 'code']))
}
const path = firstStringField(args, ['path', 'file', 'filepath'])
return path ? fileEditBasename(path) : firstStringField(args, ['query', 'url'])
}
function diffStats(tools: readonly ToolCallLike[]): { added: number; removed: number } {
let added = 0
let removed = 0
for (const tool of tools) {
if (!isFileEditTool(tool.toolName)) {
continue
}
const stats = countDiffLineStats(inlineDiffFromResult(tool.result))
added += stats.added
removed += stats.removed
}
return { added, removed }
}
/**
* One clause per category. A category holding a single thing says what it was
* ("Edited wiring.tsx"); anything else counts ("explored 3 files"). A settled
* command is the exception "ran 5 commands" is the useful reading, and a
* command line only earns its space while it's the thing you're waiting on.
*/
function clause(category: RunCategory, tools: ToolCallLike[], live: boolean): string {
const copy = CATEGORY_COPY[category]
const verb = live ? copy.present : copy.past
const target = tools.length === 1 ? toolTarget(tools[0]) : ''
if (target && (live || category !== 'run')) {
return `${verb} ${target}`
}
return `${verb} ${tools.length} ${copy.noun[tools.length === 1 ? 0 : 1]}`
}
function lowerFirst(text: string): string {
return text.charAt(0).toLowerCase() + text.slice(1)
}
/**
* Collapse a run of tool calls into the single grey line that stands in for it
* "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.
*/
export function summarizeToolRun(tools: readonly ToolCallLike[]): RunSummary {
const running = tools.find(isPending)
const liveCategory = running ? toolCategory(running.toolName) : null
const byCategory = new Map<RunCategory, ToolCallLike[]>()
for (const tool of tools) {
const category = toolCategory(tool.toolName)
const group = byCategory.get(category)
if (group) {
group.push(tool)
} else {
byCategory.set(category, [tool])
}
}
const clauses = CATEGORY_ORDER.flatMap(category => {
const group = byCategory.get(category)
return group ? [clause(category, group, category === liveCategory)] : []
})
return {
...diffStats(tools),
text: clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ')
}
}

View file

@ -0,0 +1,250 @@
import { describe, expect, it } from 'vitest'
import { isToolCallPart, type ToolCallLike } from '@/components/assistant-ui/tool/run-summary'
import type { SessionMessage } from '@/types/hermes'
import type { ChatMessage, ChatMessagePart } from './chat-messages'
import {
appendAssistantTextPart,
appendReasoningPart,
assistantTextPart,
mergeFinalAssistantText,
toChatMessages,
upsertToolPart
} from './chat-messages'
import { coalesceToolOnlyAssistants, createToolMergeCache } from './chat-runtime'
/**
* A turn described once, replayed two ways: as the gateway event stream the
* live view builds bubbles from, and as the persisted rows `toChatMessages`
* rehydrates on resume. Grouping is only stable if both produce the same runs.
*/
type TurnStep =
| { kind: 'interim'; text: string }
| { kind: 'final'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'text'; text: string }
| { kind: 'tool'; id: string; name: string }
// Mirrors use-message-stream: deltas and tool events accumulate on one pending
// bubble; `message.interim` seals it in place and starts a fresh one; and
// `message.complete` merges the final text onto whatever bubble is open.
function replayLive(steps: TurnStep[]): ChatMessage[] {
const messages: ChatMessage[] = []
let streamIndex = 0
let open: ChatMessage | null = null
const openBubble = (): ChatMessage => {
if (open) {
return open
}
streamIndex += 1
open = { id: `assistant-stream-${streamIndex}`, role: 'assistant', parts: [], pending: true }
messages.push(open)
return open
}
const seal = (text: string, interim: boolean) => {
const bubble = open ?? openBubble()
bubble.parts = mergeFinalAssistantText(bubble.parts, text)
bubble.pending = false
bubble.interim = interim
open = null
}
for (const step of steps) {
switch (step.kind) {
case 'interim':
seal(step.text, true)
break
case 'final':
seal(step.text, false)
break
case 'reasoning': {
const bubble = openBubble()
bubble.parts = appendReasoningPart(bubble.parts, step.text)
break
}
case 'text': {
const bubble = openBubble()
bubble.parts = appendAssistantTextPart(bubble.parts, step.text)
break
}
case 'tool': {
const bubble = openBubble()
bubble.parts = upsertToolPart(bubble.parts, { tool_id: step.id, name: step.name }, 'complete')
break
}
}
}
return coalesceToolOnlyAssistants(messages, createToolMergeCache())
}
// The same turn as the gateway persists it: one row per agent iteration. A row
// is a single API response, so its reasoning and content always precede its own
// tool_calls — anything the agent says after a tool ran belongs to the next row.
function replayStored(steps: TurnStep[]): ChatMessage[] {
const rows: SessionMessage[] = []
let timestamp = 0
let row: (SessionMessage & { tool_calls?: unknown[] }) | null = null
const openRow = (afterTools: boolean) => {
if (row && !(afterTools && row.tool_calls)) {
return row
}
timestamp += 1
row = { role: 'assistant', content: '', timestamp }
rows.push(row)
return row
}
for (const step of steps) {
switch (step.kind) {
case 'interim':
case 'final':
case 'text': {
const current = openRow(true)
current.content = `${current.content ?? ''}${step.text}`
if (step.kind !== 'text') {
row = null
}
break
}
case 'reasoning':
openRow(true).reasoning = step.text
break
case 'tool': {
const current = openRow(false)
current.tool_calls = [
...(current.tool_calls ?? []),
{ id: step.id, function: { name: step.name, arguments: '{}' } }
]
timestamp += 1
rows.push({ role: 'tool', tool_call_id: step.id, tool_name: step.name, content: '{}', timestamp })
break
}
}
}
return coalesceToolOnlyAssistants(toChatMessages(rows), createToolMergeCache())
}
/**
* Maximal spans of back-to-back tool calls the same rule assistant-ui applies
* when it hands `ToolGroupSlot` a range, restated here so these tests check our
* two part streams against each other rather than against the renderer.
*/
function toolRuns(parts: ChatMessagePart[]): ToolCallLike[][] {
const runs: ToolCallLike[][] = []
let previousWasTool = false
for (const part of parts) {
if (!isToolCallPart(part)) {
previousWasTool = false
continue
}
if (previousWasTool) {
runs[runs.length - 1].push(part)
} else {
runs.push([part])
}
previousWasTool = true
}
return runs
}
function runsAcross(messages: ChatMessage[]): string[][] {
return messages
.filter(message => message.role === 'assistant')
.flatMap(message => toolRuns(message.parts))
.map(run => run.map(tool => tool.toolCallId ?? ''))
}
const TURNS: Record<string, TurnStep[]> = {
'narration between two tool runs': [
{ kind: 'interim', text: 'Let me check the config.' },
{ kind: 'tool', id: 'a', name: 'read_file' },
{ kind: 'tool', id: 'b', name: 'read_file' },
{ kind: 'interim', text: 'Now let me edit it.' },
{ kind: 'tool', id: 'c', name: 'write_file' },
{ kind: 'final', text: 'Done.' }
],
'reasoning between two tool runs': [
{ kind: 'tool', id: 'a', name: 'terminal' },
{ kind: 'reasoning', text: 'That failed, try the other path.' },
{ kind: 'tool', id: 'b', name: 'terminal' },
{ kind: 'final', text: 'Fixed.' }
],
'unbroken run of tool calls': [
{ kind: 'tool', id: 'a', name: 'read_file' },
{ kind: 'tool', id: 'b', name: 'read_file' },
{ kind: 'tool', id: 'c', name: 'search_files' },
{ kind: 'final', text: 'Here is what I found.' }
],
'reasoning then tools then final': [
{ kind: 'reasoning', text: 'The user wants the lint config.' },
{ kind: 'tool', id: 'a', name: 'search_files' },
{ kind: 'tool', id: 'b', name: 'read_file' },
{ kind: 'final', text: 'It lives in eslint.config.js.' }
],
'tools with no narration at all': [
{ kind: 'tool', id: 'a', name: 'terminal' },
{ kind: 'final', text: 'Clean.' }
]
}
describe('tool run segmentation survives rehydration', () => {
for (const [name, steps] of Object.entries(TURNS)) {
it(name, () => {
expect(runsAcross(replayLive(steps))).toEqual(runsAcross(replayStored(steps)))
})
}
})
describe('run identity', () => {
const tool = (id: string, name: string): ChatMessagePart =>
({ args: {}, toolCallId: id, toolName: name, type: 'tool-call' }) as ChatMessagePart
it('keeps the first tool call at the head as the run grows', () => {
const [run] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file')])
const [grown] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file'), tool('c', 'terminal')])
expect(grown[0].toolCallId).toBe(run[0].toolCallId)
expect(grown).toHaveLength(3)
})
it('breaks a run on any non-tool part', () => {
const runs = toolRuns([tool('a', 'read_file'), assistantTextPart('Now editing.'), tool('b', 'write_file')])
expect(runs.map(run => run.map(t => t.toolCallId))).toEqual([['a'], ['b']])
})
})