Skip to content

Commit cd65bf3

Browse files
committed
Show pi json tool output progress
1 parent fe2f04b commit cd65bf3

2 files changed

Lines changed: 74 additions & 6 deletions

File tree

src/state/sessionStore.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@ function cleanHeadlessOutput(output: string, command: string) {
3434
}
3535

3636
type PiJsonDetection = 'unknown' | 'plain' | 'pi-json';
37+
type PiJsonStreamingProgressKind = 'thinking' | 'toolcall' | 'tool-output';
3738

3839
interface PiJsonRunState {
3940
pending: string;
4041
detection: PiJsonDetection;
4142
progress: string;
4243
assistantText: string;
4344
preJsonText: string;
44-
streamingProgressKind: 'thinking' | 'toolcall' | null;
45+
streamingProgressKind: PiJsonStreamingProgressKind | null;
46+
toolOutputTextByCallId: Record<string, string>;
4547
}
4648

4749
const piJsonStates = new Map<string, PiJsonRunState>();
@@ -63,7 +65,7 @@ function appendProgressLine(state: PiJsonRunState, line: string) {
6365
state.streamingProgressKind = null;
6466
}
6567

66-
function appendProgressDelta(state: PiJsonRunState, kind: 'thinking' | 'toolcall', value: string) {
68+
function appendProgressDelta(state: PiJsonRunState, kind: PiJsonStreamingProgressKind, value: string) {
6769
const clean = stripAnsi(value);
6870
if (!clean) return;
6971
if (state.streamingProgressKind !== kind && state.progress && !state.progress.endsWith('\n')) {
@@ -92,10 +94,53 @@ function getDeltaText(value: unknown) {
9294
}
9395
}
9496

97+
function extractTextContent(value: unknown): string {
98+
if (!value) return '';
99+
if (typeof value === 'string') return value;
100+
if (Array.isArray(value)) return value.map(extractTextContent).filter(Boolean).join('');
101+
if (!isRecord(value)) return '';
102+
if (typeof value.text === 'string') return value.text;
103+
if (typeof value.content === 'string') return value.content;
104+
if (Array.isArray(value.content)) return value.content.map(extractTextContent).filter(Boolean).join('');
105+
return '';
106+
}
107+
108+
function formatToolName(event: Record<string, unknown>) {
109+
return getString(event.toolName) || getString(event.name) || 'Tool';
110+
}
111+
112+
function formatToolInvocation(event: Record<string, unknown>) {
113+
const toolName = formatToolName(event);
114+
const args = isRecord(event.args) ? event.args : isRecord(event.input) ? event.input : null;
115+
const command = args ? getString(args.command) : '';
116+
const path = args ? getString(args.path) : '';
117+
if (command) return `${toolName}: ${command}`;
118+
if (path) return `${toolName}: ${path}`;
119+
return toolName;
120+
}
121+
122+
function appendToolExecutionUpdate(state: PiJsonRunState, event: Record<string, unknown>) {
123+
const text = extractTextContent(event.partialResult ?? event.result ?? event.output);
124+
if (!text.trim()) return;
125+
const key = getString(event.toolCallId) || getString(event.id) || formatToolName(event);
126+
const previous = state.toolOutputTextByCallId[key] ?? '';
127+
let delta = '';
128+
if (text.startsWith(previous)) {
129+
delta = text.slice(previous.length);
130+
state.toolOutputTextByCallId[key] = text;
131+
} else if (previous && previous.endsWith(text)) {
132+
return;
133+
} else {
134+
delta = text;
135+
state.toolOutputTextByCallId[key] = appendBounded(previous, text, PI_JSON_PROGRESS_MAX_CHARS);
136+
}
137+
appendProgressDelta(state, 'tool-output', delta.replace(/^\n+/, ''));
138+
}
139+
95140
function isPiJsonEvent(value: unknown): boolean {
96141
if (!isRecord(value)) return false;
97142
const type = getString(value.type);
98-
if (['agent_start', 'turn_start', 'message_update', 'tool_execution_start', 'tool_execution_end', 'message_end', 'turn_end', 'agent_end', 'auto_retry_start', 'compaction_start', 'compaction_end'].includes(type)) return true;
143+
if (['agent_start', 'turn_start', 'message_update', 'tool_execution_start', 'tool_execution_update', 'tool_execution_end', 'message_end', 'turn_end', 'agent_end', 'auto_retry_start', 'compaction_start', 'compaction_end'].includes(type)) return true;
99144
if (isRecord(value.assistantMessageEvent)) return true;
100145
if (isRecord(value.message) || Array.isArray(value.messages)) return true;
101146
return false;
@@ -136,8 +181,12 @@ function formatPiJsonEvent(event: unknown, state: PiJsonRunState): Partial<Headl
136181
const type = getString(event.type);
137182
if (type === 'agent_start') appendProgressLine(state, 'Agent started');
138183
else if (type === 'turn_start') appendProgressLine(state, 'Thinking…');
139-
else if (type === 'tool_execution_start') appendProgressLine(state, `Running ${getString(event.toolName) || getString(event.name) || 'tool'}…`);
140-
else if (type === 'tool_execution_end') appendProgressLine(state, `${getString(event.toolName) || getString(event.name) || 'Tool'} ${event.isError ? 'failed' : 'completed'}`);
184+
else if (type === 'tool_execution_start') appendProgressLine(state, `Running ${formatToolInvocation(event)}…`);
185+
else if (type === 'tool_execution_update') appendToolExecutionUpdate(state, event);
186+
else if (type === 'tool_execution_end') {
187+
appendToolExecutionUpdate(state, event);
188+
appendProgressLine(state, `${formatToolName(event)} ${event.isError ? 'failed' : 'completed'}`);
189+
}
141190
else if (type === 'auto_retry_start') appendProgressLine(state, 'Retrying…');
142191
else if (type === 'compaction_start') appendProgressLine(state, 'Compacting context…');
143192
else if (type === 'compaction_end') appendProgressLine(state, 'Context compacted');
@@ -174,7 +223,7 @@ function commandLooksLikePiJson(command: string) {
174223
function getPiJsonState(id: string, command: string) {
175224
let state = piJsonStates.get(id);
176225
if (!state) {
177-
state = { pending: '', detection: commandLooksLikePiJson(command) ? 'pi-json' : 'unknown', progress: '', assistantText: '', preJsonText: '', streamingProgressKind: null };
226+
state = { pending: '', detection: commandLooksLikePiJson(command) ? 'pi-json' : 'unknown', progress: '', assistantText: '', preJsonText: '', streamingProgressKind: null, toolOutputTextByCallId: {} };
178227
piJsonStates.set(id, state);
179228
}
180229
return state;

tests/sessionStore.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,25 @@ describe('sessionStore active fallback', () => {
181181
expect(run.output).toBe('Runtime answer');
182182
});
183183

184+
it('formats Pi JSON tool execution updates without raw JSON or duplicate partials', async () => {
185+
const store = await loadStore();
186+
const session = await store.getState().createSession({ workspaceId: 'workspace-a', workspaceName: 'A', workspacePath: 'C:/a', profileId: 'powershell', startupCommand: 'pi --mode json -p "hello"', headless: true });
187+
188+
store.getState().appendHeadlessOutput(session.id, `${JSON.stringify({ type: 'tool_execution_start', toolCallId: 'call-1', toolName: 'bash', args: { command: 'npm test' } })}\n`);
189+
store.getState().appendHeadlessOutput(session.id, `${JSON.stringify({ type: 'tool_execution_update', toolCallId: 'call-1', toolName: 'bash', partialResult: { content: [{ type: 'text', text: 'line one\n' }] } })}\n`);
190+
store.getState().appendHeadlessOutput(session.id, `${JSON.stringify({ type: 'tool_execution_update', toolCallId: 'call-1', toolName: 'bash', partialResult: { content: [{ type: 'text', text: 'line one\nline two\n' }] } })}\n`);
191+
store.getState().appendHeadlessOutput(session.id, `${JSON.stringify({ type: 'tool_execution_end', toolCallId: 'call-1', toolName: 'bash' })}\n`);
192+
193+
const run = store.getState().headlessRuns.find((item) => item.id === session.id)!;
194+
expect(run.outputFormat).toBe('pi-json');
195+
expect(run.progress).toContain('Running bash: npm test…');
196+
expect(run.progress).toContain('line one');
197+
expect(run.progress).toContain('line two');
198+
expect(run.progress).toContain('bash completed');
199+
expect(run.progress).not.toContain('tool_execution_update');
200+
expect(run.progress?.match(/line one/g) ?? []).toHaveLength(1);
201+
});
202+
184203
it('buffers split Pi JSON lines before parsing', async () => {
185204
const store = await loadStore();
186205
const session = await store.getState().createSession({ workspaceId: 'workspace-a', workspaceName: 'A', workspacePath: 'C:/a', profileId: 'powershell', startupCommand: 'pi --mode json -p "hello"', headless: true });

0 commit comments

Comments
 (0)