diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 6bcd06a63a9..5a7a5066c62 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -65,7 +65,7 @@ function fileEditPath(args: Record, result: Record, keys: readonly string[]): string { +export function firstStringField(record: Record, keys: readonly string[]): string { for (const key of keys) { const value = record[key] 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 new file mode 100644 index 00000000000..ff36dcda30f --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { summarizeToolRun, type ToolCallLike } from './run-summary' + +function tool(toolName: string, args: Record = {}, 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 }) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts new file mode 100644 index 00000000000..7127a8f1efe --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -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(part: T): part is Extract { + 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 = { + 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() + + 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(', ') + } +} diff --git a/apps/desktop/src/lib/tool-run-continuity.test.ts b/apps/desktop/src/lib/tool-run-continuity.test.ts new file mode 100644 index 00000000000..a009053db3d --- /dev/null +++ b/apps/desktop/src/lib/tool-run-continuity.test.ts @@ -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 = { + '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']]) + }) +})