Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/renderer/components/WorkbenchControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('../assets/workbench/*.svg', {
Expand Down Expand Up @@ -446,7 +446,7 @@ export const TranscriptRow = memo(function TranscriptRow({
? 'activity-terminal'
: entry.tool === 'read'
? 'file'
: entry.ok
: entry.ok && !entry.interrupted
? 'check'
: 'file'
}
Expand All @@ -455,10 +455,14 @@ export const TranscriptRow = memo(function TranscriptRow({
{entry.tool}
{entry.args ? ` / ${entry.args}` : ''}
</span>
<small>
{entry.running ? 'Running' : entry.ok ? 'Done' : 'Failed'}
</small>
<small>{toolStatus(entry)}</small>
</summary>
{entry.interrupted && (
<p className="wb-tool-notice wb-muted">
No completion result was recorded. The tool may have made changes;
check before retrying.
</p>
)}
{entry.oldText !== undefined || entry.newText !== undefined ? (
<ToolDiffSection
text={entry.input || 'No input.'}
Expand Down
18 changes: 8 additions & 10 deletions src/renderer/export-session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Entry } from './timeline';
import { toolStatus, type Entry } from './timeline';

export type SessionExportInput = {
title: string;
Expand All @@ -19,11 +19,6 @@ function fenced(text: string): string {
return `${fence}\n${text.replace(/\n$/, '')}\n${fence}`;
}

function toolStatus(entry: Extract<Entry, { kind: 'tool' }>): string {
if (entry.running) return 'Running';
return entry.ok ? 'Done' : 'Failed';
}

function toolHeading(entry: Extract<Entry, { kind: 'tool' }>): string {
const args =
entry.args.length > 120 ? `${entry.args.slice(0, 120)}...` : entry.args;
Expand Down Expand Up @@ -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(', ')}.`,
);
}
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Entry =
output: string;
running: boolean;
ok: boolean;
interrupted?: boolean;
};

type AgentContent = {
Expand Down Expand Up @@ -142,6 +143,7 @@ function entriesFromMessages(messages: unknown[]): Entry[] {
output: '',
running: false,
ok: true,
interrupted: true,
};
tools.set(p.id, tool);
entries.push(tool);
Expand All @@ -154,6 +156,7 @@ function entriesFromMessages(messages: unknown[]): Entry[] {
if (tool) {
tool.output = resultText(m.content);
tool.ok = m.isError !== true;
tool.interrupted = false;
}
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -249,6 +257,7 @@ function applyEvent(
output: resultText(result?.content),
running: false,
ok: event.isError !== true,
interrupted: false,
} as Entry;
}
break;
Expand All @@ -257,5 +266,11 @@ function applyEvent(
return { entries: next, running };
}

export { entriesFromMessages, applyEvent };
function toolStatus(entry: Extract<Entry, { kind: 'tool' }>): string {
if (entry.running) return 'Running';
if (entry.interrupted) return 'Interrupted';
return entry.ok ? 'Done' : 'Failed';
}

export { entriesFromMessages, applyEvent, toolStatus };
export type { Entry, ToolLine };
7 changes: 6 additions & 1 deletion src/renderer/useWorkbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
);
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/workbench.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions tests/e2e/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
9 changes: 9 additions & 0 deletions tests/fixtures/pi-rpc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/ToolCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,16 @@ describe('<TranscriptRow> 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(
<TranscriptRow entry={entry} expanded={new Map([[entry.key, true]])} />,
);
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');
});
});
58 changes: 58 additions & 0 deletions tests/integration/useWorkbench.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PidexApi['openSession']>()
.mockResolvedValue({ sessionId: 'runtime' });
const sessionCommands = vi
.fn<PidexApi['sessionCommands']>()
.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<WorkspaceCatalog>();
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/export-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -248,6 +262,26 @@ describe('applyEvent', () => {
expect(running).toBe(false);
});

it('agent_end interrupts only unfinished calls and retains partial output', () => {
const tool: Extract<Entry, { kind: 'tool' }> = {
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',
Expand Down Expand Up @@ -494,4 +528,4 @@ describe('applyEvent', () => {
expect.fail('expected tool');
}
});
});
});
Loading