diff --git a/src/renderer/components/WorkbenchControls.tsx b/src/renderer/components/WorkbenchControls.tsx index 6d8695d..ca221a8 100644 --- a/src/renderer/components/WorkbenchControls.tsx +++ b/src/renderer/components/WorkbenchControls.tsx @@ -8,7 +8,7 @@ import { import { BracketButton } from './BracketButton'; import { FigureFrame } from './FigureFrame'; import { Markdown } from './Markdown'; -import type { Entry } from '../timeline'; +import { toolStatus, type Entry } from '../timeline'; import type { ModelInfo, SlashCommand } from '../api'; const icons = import.meta.glob('../assets/workbench/*.svg', { @@ -446,7 +446,7 @@ export const TranscriptRow = memo(function TranscriptRow({ ? 'activity-terminal' : entry.tool === 'read' ? 'file' - : entry.ok + : entry.ok && !entry.interrupted ? 'check' : 'file' } @@ -455,10 +455,14 @@ export const TranscriptRow = memo(function TranscriptRow({ {entry.tool} {entry.args ? ` / ${entry.args}` : ''} - - {entry.running ? 'Running' : entry.ok ? 'Done' : 'Failed'} - + {toolStatus(entry)} + {entry.interrupted && ( +

+ No completion result was recorded. The tool may have made changes; + check before retrying. +

+ )} {entry.oldText !== undefined || entry.newText !== undefined ? ( ): string { - if (entry.running) return 'Running'; - return entry.ok ? 'Done' : 'Failed'; -} - function toolHeading(entry: Extract): string { const args = entry.args.length > 120 ? `${entry.args.slice(0, 120)}...` : entry.args; @@ -60,11 +55,14 @@ function summarySection(entries: Entry[]): string { `- ${messages} ${messages === 1 ? 'message' : 'messages'}: ${users} from you, ${assistants} from Pi.`, ]; if (tools.length > 0) { - const done = tools.filter((e) => !e.running && e.ok).length; - const failed = tools.filter((e) => !e.running && !e.ok).length; - const running = tools.filter((e) => e.running).length; + const done = tools.filter((e) => toolStatus(e) === 'Done').length; + const failed = tools.filter((e) => toolStatus(e) === 'Failed').length; + const running = tools.filter((e) => toolStatus(e) === 'Running').length; + const interrupted = tools.filter( + (e) => toolStatus(e) === 'Interrupted', + ).length; lines.push( - `- ${tools.length} ${tools.length === 1 ? 'tool call' : 'tool calls'}: ${done} done, ${failed} failed, ${running} running.`, + `- ${tools.length} ${tools.length === 1 ? 'tool call' : 'tool calls'}: ${done} done, ${failed} failed, ${running} running${interrupted ? `, ${interrupted} interrupted` : ''}.`, `- Tools used: ${[...new Set(tools.map((e) => e.tool))].join(', ')}.`, ); } diff --git a/src/renderer/timeline.ts b/src/renderer/timeline.ts index cb50726..c525d1b 100644 --- a/src/renderer/timeline.ts +++ b/src/renderer/timeline.ts @@ -15,6 +15,7 @@ type Entry = output: string; running: boolean; ok: boolean; + interrupted?: boolean; }; type AgentContent = { @@ -142,6 +143,7 @@ function entriesFromMessages(messages: unknown[]): Entry[] { output: '', running: false, ok: true, + interrupted: true, }; tools.set(p.id, tool); entries.push(tool); @@ -154,6 +156,7 @@ function entriesFromMessages(messages: unknown[]): Entry[] { if (tool) { tool.output = resultText(m.content); tool.ok = m.isError !== true; + tool.interrupted = false; } } } @@ -181,6 +184,11 @@ function applyEvent( break; case 'agent_end': running = false; + for (let i = 0; i < next.length; i++) { + const entry = next[i]; + if (entry.kind === 'tool' && entry.running) + next[i] = { ...entry, running: false, interrupted: true }; + } break; case 'message_start': { const message = event.message as AgentMessage | undefined; @@ -249,6 +257,7 @@ function applyEvent( output: resultText(result?.content), running: false, ok: event.isError !== true, + interrupted: false, } as Entry; } break; @@ -257,5 +266,11 @@ function applyEvent( return { entries: next, running }; } -export { entriesFromMessages, applyEvent }; +function toolStatus(entry: Extract): string { + if (entry.running) return 'Running'; + if (entry.interrupted) return 'Interrupted'; + return entry.ok ? 'Done' : 'Failed'; +} + +export { entriesFromMessages, applyEvent, toolStatus }; export type { Entry, ToolLine }; diff --git a/src/renderer/useWorkbench.ts b/src/renderer/useWorkbench.ts index 56c72b5..34d0be7 100644 --- a/src/renderer/useWorkbench.ts +++ b/src/renderer/useWorkbench.ts @@ -166,7 +166,12 @@ export function useWorkbench() { setTabs((prev) => prev.map((tab) => tab.runtimeId === sessionId - ? { ...tab, running: false, exited: true } + ? { + ...tab, + entries: applyEvent(tab.entries, { type: 'agent_end' }).entries, + running: false, + exited: true, + } : tab, ), ); diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index 0672533..7c65016 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -706,6 +706,10 @@ html[data-reduced-motion='true'] .wb-shell *::after { font-size: 12px; color: var(--copy); } +.wb-tool-notice { + margin: 0; + padding: 0 12px 10px; +} .wb-tool-section h3 { margin: 0; padding: 8px 12px 0; diff --git a/tests/e2e/app.test.ts b/tests/e2e/app.test.ts index fd70f63..ccc9f89 100644 --- a/tests/e2e/app.test.ts +++ b/tests/e2e/app.test.ts @@ -399,6 +399,24 @@ describe('Figma workbench in Electron', () => { expect(errors).toEqual([]); }); + it('settles a stopped tool as interrupted and preserves partial output', async () => { + await win.getByRole('button', { name: 'New session', exact: true }).click(); + await win.getByLabel('Message pi').fill('Start interrupted tool fixture'); + await win.getByRole('button', { name: 'Send', exact: true }).click(); + const card = win.locator('.wb-tool').filter({ hasText: 'build-project' }); + await card.locator('summary').getByText('Running', { exact: true }).waitFor(); + await win.getByRole('button', { name: 'Stop', exact: true }).click(); + await card.locator('summary').getByText('Interrupted', { exact: true }).waitFor(); + await card.locator('summary').click(); + await card.getByText('Building project...', { exact: true }).waitFor(); + await card.getByText(/check before retrying/).waitFor(); + expect(await card.locator('summary').textContent()).not.toContain('Done'); + const screenshot = path.join(shots, '10-interrupted-tool.png'); + await win.screenshot({ path: screenshot }); + await fs.mkdir(path.join(ROOT, '.tmp/proof'), { recursive: true }); + await fs.copyFile(screenshot, path.join(ROOT, '.tmp/proof/interrupted-tool.png')); + }); + it('keeps the desktop layouts usable at the minimum window size', async () => { await win.getByRole('button', { name: 'New session', exact: true }).click(); await win.setViewportSize({ width: 900, height: 600 }); diff --git a/tests/fixtures/pi-rpc.cjs b/tests/fixtures/pi-rpc.cjs index 7591876..8cfcded 100755 --- a/tests/fixtures/pi-rpc.cjs +++ b/tests/fixtures/pi-rpc.cjs @@ -275,6 +275,15 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { reply(); streaming = true; event('agent_start'); + if (command.message === 'Start interrupted tool fixture') { + event('tool_execution_start', { + toolCallId: 'interrupted-build', toolName: 'bash', args: { command: 'build-project' }, + }); + event('tool_execution_update', { + toolCallId: 'interrupted-build', partialResult: { content: [{ type: 'text', text: 'Building project...' }] }, + }); + break; + } event('message_start', { message: { role: 'assistant' } }); event('message_update', { assistantMessageEvent: { diff --git a/tests/integration/ToolCard.test.tsx b/tests/integration/ToolCard.test.tsx index 708ee6e..640ffcb 100644 --- a/tests/integration/ToolCard.test.tsx +++ b/tests/integration/ToolCard.test.tsx @@ -154,4 +154,16 @@ describe(' tool card', () => { expect(container.textContent).toContain('Running'); expect(container.textContent).toContain('Waiting for output'); }); + + it('shows interrupted calls with partial output and no success claim', () => { + const entry = { ...toolEntry, interrupted: true, output: 'Partial progress' }; + const { container, getByText } = render( + , + ); + expect(getByText('Interrupted')).toBeTruthy(); + expect(container.querySelector('summary')?.textContent).not.toContain('Done'); + expect(container.textContent).toContain('check before retrying'); + expect(container.querySelectorAll('.wb-tool-section pre')[1]?.textContent).toBe('Partial progress'); + expect(container.querySelector('summary img')?.getAttribute('src')).not.toContain('/check.svg'); + }); }); diff --git a/tests/integration/useWorkbench.test.tsx b/tests/integration/useWorkbench.test.tsx index cebc493..7f08544 100644 --- a/tests/integration/useWorkbench.test.tsx +++ b/tests/integration/useWorkbench.test.tsx @@ -64,6 +64,64 @@ afterEach(() => { }); describe('workbench root switching', () => { + it('settles unfinished tool cards when their runtime exits', async () => { + const fixture = apiFixture(); + const openSession = vi + .fn() + .mockResolvedValue({ sessionId: 'runtime' }); + const sessionCommands = vi + .fn() + .mockResolvedValue({ commands: [] }); + Object.assign(fixture, { openSession, sessionCommands }); + const { result } = renderHook(useWorkbench); + await waitFor(() => expect(result.current.ws?.name).toBe('api')); + act(() => + result.current.showSession({ + cwd: '/code/api', + id: 'archive', + file: '/sessions/archive.jsonl', + title: 'Build', + model: '', + timestamp: '', + messageCount: 0, + }), + ); + await waitFor(() => expect(result.current.tabs).toHaveLength(1)); + const onEvent = fixture.onSessionEvent.mock.calls[0][0]; + const onExit = fixture.onSessionExit.mock.calls[0][0]; + act(() => { + onEvent({ + sessionId: 'runtime', + event: { + type: 'tool_execution_start', + toolCallId: 'build', + toolName: 'bash', + args: { command: 'build' }, + }, + }); + onEvent({ + sessionId: 'runtime', + event: { + type: 'tool_execution_update', + toolCallId: 'build', + partialResult: { content: [{ type: 'text', text: 'Partial build' }] }, + }, + }); + onExit({ sessionId: 'other-runtime' }); + }); + expect(result.current.tabs[0].entries[0]).toMatchObject({ running: true }); + act(() => onExit({ sessionId: 'runtime' })); + expect(result.current.tabs[0]).toMatchObject({ + running: false, + exited: true, + }); + expect(result.current.tabs[0].entries[0]).toMatchObject({ + running: false, + interrupted: true, + output: 'Partial build', + }); + }); + it('rejects late catalog, file, model and defaults results from a previous root', async () => { const fixture = apiFixture(); const oldCatalog = deferred(); diff --git a/tests/unit/export-session.test.ts b/tests/unit/export-session.test.ts index 2222f21..2dc9ead 100644 --- a/tests/unit/export-session.test.ts +++ b/tests/unit/export-session.test.ts @@ -111,6 +111,16 @@ describe('sessionExportMarkdown', () => { expect(running).toContain('## bash / npm test (Running)'); }); + it('counts interrupted tools separately and retains their partial output', () => { + const markdown = sessionExportMarkdown({ + title: 'Interrupted build', cwd: '/repo/app', model: '', exportedAt, + entries: [bashDone, { ...bashDone, key: 'interrupted', interrupted: true, output: 'Building...' }], + }); + expect(markdown).toContain('2 tool calls: 1 done, 0 failed, 0 running, 1 interrupted.'); + expect(markdown).toContain('## bash / npm test (Interrupted)'); + expect(markdown).toContain('Building...'); + }); + it('keeps an empty input and output readable', () => { const markdown = sessionExportMarkdown({ title: 'T', diff --git a/tests/unit/timeline.test.ts b/tests/unit/timeline.test.ts index 0a93e82..81710d3 100644 --- a/tests/unit/timeline.test.ts +++ b/tests/unit/timeline.test.ts @@ -114,6 +114,20 @@ describe('entriesFromMessages', () => { } }); + it('marks archive calls interrupted until a matching result is recorded', () => { + const call = { + role: 'assistant', + content: [{ type: 'toolCall', id: 'incomplete', name: 'bash', arguments: { command: 'build' } }], + }; + expect(entriesFromMessages([call])[0]).toMatchObject({ + running: false, interrupted: true, + }); + // An empty result still establishes completion. + expect(entriesFromMessages([call, { + role: 'toolResult', toolCallId: 'incomplete', content: [], + }])[0]).toMatchObject({ running: false, interrupted: false, ok: true }); + }); + it('keeps full edit input with old and new text', () => { const messages = [ { @@ -248,6 +262,26 @@ describe('applyEvent', () => { expect(running).toBe(false); }); + it('agent_end interrupts only unfinished calls and retains partial output', () => { + const tool: Extract = { + key: 'pending', kind: 'tool', toolId: 'pending', tool: 'bash', + args: 'build', input: 'build', output: 'Building...', running: true, ok: true, + }; + const done = { ...tool, key: 'done', toolId: 'done', running: false }; + const failed = { ...done, key: 'failed', toolId: 'failed', ok: false }; + const { entries } = applyEvent([tool, done, failed], { type: 'agent_end' }); + expect(entries[0]).toMatchObject({ running: false, interrupted: true, output: 'Building...' }); + expect(entries[1]).toBe(done); + expect(entries[2]).toBe(failed); + expect(tool.running).toBe(true); + expect(applyEvent(entries, { type: 'agent_end' }).entries).toEqual(entries); + const completed = applyEvent(entries, { + type: 'tool_execution_end', toolCallId: 'pending', + result: { content: [{ type: 'text', text: 'Build complete' }] }, + }).entries; + expect(completed[0]).toMatchObject({ interrupted: false, running: false, ok: true, output: 'Build complete' }); + }); + it('message_start (assistant) pushes a streaming assistant entry', () => { const { entries } = applyEvent(empty, { type: 'message_start', @@ -494,4 +528,4 @@ describe('applyEvent', () => { expect.fail('expected tool'); } }); -}); \ No newline at end of file +});