Skip to content
Closed
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
82 changes: 82 additions & 0 deletions packages/cli/src/__tests__/pi-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,88 @@ describe('Maka Pi TUI transcript', () => {
assert.equal(state.entries.at(-1)?.kind, 'notice');
});

test('shows the byte size of an oversized live tool result instead of no output', () => {
const state = createMakaPiTranscriptState();
applyMakaSessionEventToTranscript(
state,
event({
type: 'tool_start',
toolUseId: 'big-1',
toolName: 'Bash',
args: { command: 'npm test' },
}),
);
applyMakaSessionEventToTranscript(
state,
event({
type: 'tool_result',
toolUseId: 'big-1',
isError: false,
durationMs: 2500,
content: { kind: 'text', text: '' },
contentBytes: 100_000,
}),
);

const compact = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n');
assert.match(compact, /100000 bytes/);
assert.doesNotMatch(compact, /no output/);

assert.equal(toggleAllToolExpansion(state), true);
const expanded = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n');
assert.match(expanded, /too large to show live: 100000 bytes/);
});

test('the terminal reconcile replaces an oversized placeholder with the durable content', () => {
const state = createMakaPiTranscriptState();
applyMakaSessionEventToTranscript(
state,
event({
type: 'tool_start',
toolUseId: 'big-1',
toolName: 'Bash',
args: { command: 'npm test' },
}),
);
applyMakaSessionEventToTranscript(
state,
event({
type: 'tool_result',
toolUseId: 'big-1',
isError: false,
content: { kind: 'text', text: '' },
contentBytes: 100_000,
}),
);

assert.equal(
reconcileToolsWithStoredMessages(state, 'turn-1', [
{
type: 'tool_call',
id: 'big-1',
turnId: 'turn-1',
ts: 1,
toolName: 'Bash',
args: { command: 'npm test' },
},
{
type: 'tool_result',
id: 'big-1-result',
turnId: 'turn-1',
ts: 2,
toolUseId: 'big-1',
isError: false,
content: { kind: 'text', text: 'all 3 suites passed' },
},
]),
true,
);

const row = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n');
assert.match(row, /all 3 suites passed|1 line · 19 bytes/);
assert.doesNotMatch(row, /100000 bytes/);
});

test('removes a live poll card that the durable transcript folds into its Bash parent', () => {
const state = createMakaPiTranscriptState();
for (const tool of [
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/pi-transcript-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,15 @@ function renderExpandedToolBlock(entry: MakaPiToolEntry, width: number): string[
}
lines.push(...renderToolStreams(entry.outputDeltas.values(), width));
}
if (entry.result || entry.output) {
if (entry.resultBytes !== undefined && !plainResultText(entry)) {
lines.push(
...renderIndented(
ansi.dim(`Result too large to show live: ${entry.resultBytes} bytes`),
width,
2,
),
);
} else if (entry.result || entry.output) {
lines.push(...renderToolResult(entry, width));
}
if (
Expand Down Expand Up @@ -258,6 +266,11 @@ function pipeOutputLineCount(output: { stdout?: string; stderr?: string }): numb

function compactToolSummary(entry: MakaPiToolEntry): CompactToolSummary | undefined {
const result = entry.result;
// An oversized live result carried only its byte size (#3521): show the
// truthful size rather than the empty placeholder's `no output`.
if (entry.resultBytes !== undefined && !plainResultText(entry)) {
return { text: `${entry.resultBytes} bytes`, protect: true };
}
if (result?.kind === 'shell_run') {
if (entry.toolName === 'WriteStdin') {
return { text: formatPtyControlOperation(result.operation, entry.input) };
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/pi-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ export type MakaPiTranscriptEntry =
output?: string;
/** In-memory revision for render-cache invalidation when a result is replaced. */
resultVersion: number;
/**
* Serialized size of a settled result whose content exceeded the live
* frame budget and was omitted (#3521). Cleared when real content lands
* (live or via the terminal reconcile).
*/
resultBytes?: number;
progress: BoundedChunkBuffer<string>;
outputDeltas: BoundedChunkBuffer<MakaPiToolOutputDelta>;
durationMs?: number;
Expand Down Expand Up @@ -406,6 +412,7 @@ export function reconcileToolsWithStoredMessages(
entry.input = structuredClone(durable.input);
entry.result = durable.result ? structuredClone(durable.result) : undefined;
entry.output = durable.output;
delete entry.resultBytes;
entry.durationMs = durable.durationMs;
entry.status = durable.status;
entry.hidden = durable.hidden;
Expand Down Expand Up @@ -634,6 +641,7 @@ export function applyMakaSessionEventToTranscript(
result: event.content,
output: formatToolResultContent(event.content),
resultVersion: 1,
...(event.contentBytes === undefined ? {} : { resultBytes: event.contentBytes }),
durationMs: event.durationMs,
status: event.isError ? 'error' : 'done',
expanded: state.expandAllTools,
Expand Down Expand Up @@ -681,6 +689,8 @@ export function applyMakaSessionEventToTranscript(
tool.status = toolResultTranscriptStatus(event.content, event.isError);
tool.result = event.content;
tool.output = formatToolResultContent(event.content);
if (event.contentBytes === undefined) delete tool.resultBytes;
else tool.resultBytes = event.contentBytes;
tool.durationMs = event.durationMs;
tool.resultVersion += 1;
}
Expand All @@ -696,6 +706,7 @@ export function applyMakaSessionEventToTranscript(
result: event.content,
output: formatToolResultContent(event.content),
resultVersion: 1,
...(event.contentBytes === undefined ? {} : { resultBytes: event.contentBytes }),
durationMs: event.durationMs,
status: toolResultTranscriptStatus(event.content, event.isError),
expanded: state.expandAllTools,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,12 @@ export interface ToolResultEvent extends BaseEvent, ToolActivityIdentity {
isError: boolean;
content: ToolResultContent;
durationMs?: number;
/**
* Live-broadcast only: the serialized byte size of a result whose content
* exceeded the live frame budget and was omitted (content is an empty
* placeholder in that case). Runtime-emitted events never set this.
*/
contentBytes?: number;
}

type ShellRunResultMetadata = {
Expand Down
28 changes: 24 additions & 4 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ describe('Runtime Host bootstrap protocol', () => {
);
});

test('decodes only privacy-normalized bounded subscription live frames', () => {
test('decodes bounded live frames carrying tool args and result content', () => {
const envelope = {
kind: 'subscription.session_event' as const,
hostEpoch: 'epoch-1',
Expand All @@ -308,6 +308,12 @@ describe('Runtime Host bootstrap protocol', () => {
toolName: 'read',
displayName: 'Read file',
},
{
...identity,
type: 'tool_start',
toolName: 'read',
args: { path: '/repo/README.md' },
},
{
...identity,
type: 'tool_output_delta',
Expand All @@ -319,6 +325,19 @@ describe('Runtime Host bootstrap protocol', () => {
},
{ ...identity, type: 'tool_progress', chunk: 'working' },
{ ...identity, type: 'tool_result', status: 'completed', durationMs: 3 },
{
...identity,
type: 'tool_result',
status: 'completed',
durationMs: 3,
content: { kind: 'text', text: 'settled output' },
},
{
...identity,
type: 'tool_result',
status: 'completed',
contentBytes: 100_000,
},
{
...identity,
type: 'tool_result',
Expand All @@ -344,9 +363,10 @@ describe('Runtime Host bootstrap protocol', () => {
for (const event of [
{
...identity,
type: 'tool_start',
toolName: 'read',
args: { path: '/private' },
type: 'tool_result',
status: 'completed',
content: { kind: 'text', text: 'both fields' },
contentBytes: 100_000,
},
{
...identity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
import {
decodeSubscriptionFrame,
SESSION_LIVE_DELTA_MAX_BYTES,
SESSION_LIVE_TOOL_ARGS_MAX_BYTES,
SESSION_LIVE_TOOL_RESULT_MAX_BYTES,
} from '../protocol/session-continuity.js';
import type { ConnectionContext } from '../server/operation-dispatcher.js';
import {
Expand Down Expand Up @@ -1829,6 +1831,138 @@ test('tool_result clears retained tool_result_preview so a later open does not s
coordinator.close();
});

test('broadcasts tool_start args in the live frame', async () => {
const coordinator = new SessionContinuityCoordinator(
HOST_EPOCH,
async () => canonical(),
new SessionAdmissionGate(),
);
const sink = new RecordingSink();
const connection = coordinator.attachConnection('connection-1', sink);
const opened = await open(coordinator, 'connection-1');
connection.activate(opened.subscriptionId);

await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', {
type: 'tool_start',
id: 'start-1',
turnId: 'turn-1',
ts: 1,
toolUseId: 'tool-1',
toolName: 'Read',
args: { path: '/repo/README.md' },
});
await waitFor(() => sink.frames.length === 1);

const [frame] = sink.frames;
assert.equal(frame?.kind, 'subscription.session_event');
if (frame?.kind !== 'subscription.session_event') return;
assert.deepEqual(frame.event, {
type: 'tool_start',
id: 'start-1',
turnId: 'turn-1',
ts: 1,
toolUseId: 'tool-1',
toolName: 'Read',
args: { path: '/repo/README.md' },
});

connection.abort(opened.subscriptionId);
coordinator.close();
});

test('broadcasts the tool result content in the live frame', async () => {
const coordinator = new SessionContinuityCoordinator(
HOST_EPOCH,
async () => canonical(),
new SessionAdmissionGate(),
);
const sink = new RecordingSink();
const connection = coordinator.attachConnection('connection-1', sink);
const opened = await open(coordinator, 'connection-1');
connection.activate(opened.subscriptionId);

await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', {
type: 'tool_result',
id: 'result-1',
turnId: 'turn-1',
ts: 2,
toolUseId: 'tool-1',
isError: false,
durationMs: 7,
content: { kind: 'text', text: 'settled output' },
});
await waitFor(() => sink.frames.length === 1);

const [frame] = sink.frames;
assert.equal(frame?.kind, 'subscription.session_event');
if (frame?.kind !== 'subscription.session_event') return;
assert.deepEqual(frame.event, {
type: 'tool_result',
id: 'result-1',
turnId: 'turn-1',
ts: 2,
toolUseId: 'tool-1',
status: 'completed',
durationMs: 7,
content: { kind: 'text', text: 'settled output' },
});

connection.abort(opened.subscriptionId);
coordinator.close();
});

test('omits oversized live args and result content, keeping the content byte size', async () => {
const coordinator = new SessionContinuityCoordinator(
HOST_EPOCH,
async () => canonical(),
new SessionAdmissionGate(),
);
const sink = new RecordingSink();
const connection = coordinator.attachConnection('connection-1', sink);
const opened = await open(coordinator, 'connection-1');
connection.activate(opened.subscriptionId);

const oversizedArgs = { command: `run ${'x'.repeat(SESSION_LIVE_TOOL_ARGS_MAX_BYTES)}` };
await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', {
type: 'tool_start',
id: 'start-1',
turnId: 'turn-1',
ts: 1,
toolUseId: 'tool-1',
toolName: 'Bash',
args: oversizedArgs,
});
const oversizedText = 'y'.repeat(SESSION_LIVE_TOOL_RESULT_MAX_BYTES + 1024);
await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', {
type: 'tool_result',
id: 'result-1',
turnId: 'turn-1',
ts: 2,
toolUseId: 'tool-1',
isError: false,
content: { kind: 'text', text: oversizedText },
});
await waitFor(() => sink.frames.length === 2);

const [startFrame, resultFrame] = sink.frames;
if (startFrame?.kind !== 'subscription.session_event') throw new Error('expected event frame');
if (startFrame.event.type !== 'tool_start') throw new Error('expected tool_start');
assert.equal(startFrame.event.args, undefined);

if (resultFrame?.kind !== 'subscription.session_event') throw new Error('expected event frame');
if (resultFrame.event.type !== 'tool_result') throw new Error('expected tool_result');
assert.equal(resultFrame.event.content, undefined);
assert.equal(
resultFrame.event.contentBytes,
Buffer.byteLength(JSON.stringify({ kind: 'text', text: oversizedText }), 'utf8'),
);
// The whole frame must stay decodable under the subscription frame cap.
assert.doesNotThrow(() => decodeSubscriptionFrame(JSON.parse(JSON.stringify(resultFrame))));

connection.abort(opened.subscriptionId);
coordinator.close();
});

test('publishes only the minimal sandbox failure reason from a tool result', async () => {
const coordinator = new SessionContinuityCoordinator(
HOST_EPOCH,
Expand Down Expand Up @@ -1866,6 +2000,11 @@ test('publishes only the minimal sandbox failure reason from a tool result', asy
toolUseId: 'tool-1',
status: 'errored',
sandboxFailureReason: 'sandbox_boundary_required',
content: {
kind: 'text',
text: 'sensitive tool output',
sandboxFailure: { reason: 'sandbox_boundary_required' },
},
});

connection.abort(opened.subscriptionId);
Expand Down
Loading