diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index e1cab2d10b..11ae251f66 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -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 [ diff --git a/packages/cli/src/pi-transcript-tools.ts b/packages/cli/src/pi-transcript-tools.ts index 40ac11ed18..f422d17d65 100644 --- a/packages/cli/src/pi-transcript-tools.ts +++ b/packages/cli/src/pi-transcript-tools.ts @@ -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 ( @@ -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) }; diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index b5f3e17455..38c43c4962 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -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; outputDeltas: BoundedChunkBuffer; durationMs?: number; @@ -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; @@ -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, @@ -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; } @@ -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, diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 18311065de..f817099292 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -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 = { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 4c1c6f0d45..a94c70a814 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -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', @@ -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', @@ -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', @@ -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, diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index f996dfe35d..583d106a96 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -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 { @@ -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, @@ -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); diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 4ffad9e9cd..c29923011f 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -262,6 +262,88 @@ test('does not replay settled transcript steps when the active step reaches term ); }); +test('projects live tool events with their args and settled content', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + [], + ); + const identity = { id: 'event-1', turnId: 'turn-1', ts: 1, toolUseId: 'tool-1' }; + + const started = projector.accept({ + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { type: 'tool_start', ...identity, toolName: 'Read', args: { path: '/repo/a.md' } }, + }).events; + assert.deepEqual(started, [ + { + type: 'tool_start', + ...identity, + toolName: 'Read', + args: { path: '/repo/a.md' }, + }, + ]); + + const settled = projector.accept({ + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'tool_result', + ...identity, + status: 'completed', + durationMs: 5, + content: { kind: 'text', text: 'file body' }, + }, + }).events; + assert.deepEqual(settled, [ + { + type: 'tool_result', + ...identity, + isError: false, + durationMs: 5, + content: { kind: 'text', text: 'file body' }, + }, + ]); +}); + +test('projects an oversized live tool result as an empty body with its byte size', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + [], + ); + const identity = { id: 'event-1', turnId: 'turn-1', ts: 1, toolUseId: 'tool-1' }; + + const settled = projector.accept({ + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { type: 'tool_result', ...identity, status: 'completed', contentBytes: 100_000 }, + }).events; + assert.deepEqual(settled, [ + { + type: 'tool_result', + ...identity, + isError: false, + content: { kind: 'text', text: '' }, + contentBytes: 100_000, + }, + ]); +}); + function deltaFrame( sequence: number, startOffset: number, diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 79b9efe440..bedcf6e47a 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -492,7 +492,7 @@ function projectToolEvent( type: 'tool_start', ...base, toolName: event.toolName, - args: undefined, + args: event.args, ...(event.operationId ? { operationId: event.operationId } : {}), ...(event.activityKind ? { activityKind: event.activityKind } : {}), ...(event.displayName ? { displayName: event.displayName } : {}), @@ -525,15 +525,22 @@ function projectToolEvent( type: 'tool_result', ...base, isError: event.status === 'errored', - content: { - kind: 'text', - text: '', - ...(event.sandboxFailureReason - ? { sandboxFailure: { reason: event.sandboxFailureReason } } - : {}), - }, + // Settled content arrives with the live frame (#3521). An oversized result + // omits it (contentBytes carries the size); keep the empty-body + // placeholder — plus the normalized sandbox-failure signal — for that case. + content: + event.content === undefined + ? { + kind: 'text', + text: '', + ...(event.sandboxFailureReason + ? { sandboxFailure: { reason: event.sandboxFailureReason } } + : {}), + } + : structuredClone(event.content), ...(event.operationId ? { operationId: event.operationId } : {}), ...(event.durationMs === undefined ? {} : { durationMs: event.durationMs }), + ...(event.contentBytes === undefined ? {} : { contentBytes: event.contentBytes }), }; } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index a05afbcbd5..f51a32b112 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 39 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 40 as const; +// 40: Live tool_start/tool_result frames carry bounded args and settled +// content so a transcript can render the final card at settle time (#3521). // 39: Client Capability tool descriptors carry trusted activity semantics and // invocations can stream bounded progress frames. // 38: `execute` is no longer a permission mode. Frame decoders reject it, so a diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 0060c0bdfd..87827d32d7 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -18,7 +18,11 @@ */ import { TOOL_ACTIVITY_KINDS, TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events'; -import type { SandboxBoundaryFailureSignal, ToolResultPreviewContent } from '@maka/core/events'; +import type { + SandboxBoundaryFailureSignal, + ToolResultContent, + ToolResultPreviewContent, +} from '@maka/core/events'; import { decodeToolResultPreviewContent } from '@maka/core/tool-result-preview'; import type { ToolActivityKind } from '@maka/core/events'; import type { SessionStatus } from '@maka/core/session'; @@ -29,6 +33,7 @@ import { requireExactRecord, requireId, requireRecord, + requireUtf8String, } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; import { decodeSessionStatus } from './session-status.js'; @@ -55,6 +60,17 @@ export const SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES = 56 * 1024; // Leave transport headroom for the response envelope and request correlation. export const SUBSCRIPTION_OPEN_RESULT_MAX_BYTES = 92 * 1024; export const SESSION_LIVE_DELTA_MAX_BYTES = 16 * 1024; +/** + * Live tool events carry the tool's args and settled result content so a + * transcript can render the final card the moment it settles — terminal + * scrollback cannot be rewritten later, so a post-hoc backfill arrives too + * late (#3521). Each field is included only while its serialized form fits + * this budget, keeping the whole frame under SESSION_SUBSCRIPTION_FRAME_MAX_BYTES; + * oversized content degrades to its byte size (`contentBytes`), oversized args + * are simply omitted. + */ +export const SESSION_LIVE_TOOL_ARGS_MAX_BYTES = 16 * 1024; +export const SESSION_LIVE_TOOL_RESULT_MAX_BYTES = 48 * 1024; // Core emits at most 8,192 UTF-16 code units per tool output event. A code unit // needs at most three UTF-8 bytes (an astral pair needs four bytes total). export const SESSION_TOOL_OUTPUT_DELTA_MAX_BYTES = 3 * TOOL_OUTPUT_DELTA_MAX_CHARS; @@ -164,6 +180,8 @@ export type SessionToolEvent = activityKind?: ToolActivityKind; displayName?: string; stepId?: string; + /** Invocation arguments; omitted when their serialized form exceeds SESSION_LIVE_TOOL_ARGS_MAX_BYTES. */ + args?: unknown; }) | (SessionToolEventIdentity & { type: 'tool_output_delta'; @@ -183,6 +201,13 @@ export type SessionToolEvent = status: 'completed' | 'errored'; sandboxFailureReason?: SandboxBoundaryFailureSignal['reason']; durationMs?: number; + /** + * Settled result content; present whenever its serialized form fits + * SESSION_LIVE_TOOL_RESULT_MAX_BYTES. Mutually exclusive with contentBytes. + */ + content?: ToolResultContent; + /** Serialized byte size of an oversized result whose content was omitted. */ + contentBytes?: number; }) | (SessionToolEventIdentity & { type: 'tool_result_preview'; @@ -710,6 +735,17 @@ function decodeAssistantDelta(value: unknown): SessionAssistantDelta { }; } +/** + * Lenient live result-content validation: the durable transcript decodes the + * same union by envelope shape, and every kind renders through the client's + * own narrowing, so the wire only pins the discriminant. + */ +function decodeLiveToolResultContent(value: unknown): ToolResultContent { + const record = requireRecord(value, 'Session tool result content'); + requireUtf8String(record.kind, 'Session tool result content kind', SESSION_TOOL_NAME_MAX_BYTES); + return record as unknown as ToolResultContent; +} + function decodeSessionToolEvent(value: unknown): SessionToolEvent { const record = requireRecord(value, 'Session tool event'); const identity = { @@ -730,6 +766,7 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { 'activityKind', 'displayName', 'stepId', + 'args', ]; assertAllowedKeys(record, 'Session tool start event', allowed); assertRequiredKeys(record, 'Session tool start event', [ @@ -764,6 +801,9 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { ), }), ...(record.stepId === undefined ? {} : { stepId: requireEntityId(record.stepId, 'stepId') }), + // Args arrive as parsed JSON, so any value here is already a plain JSON + // value; the producer bounds the serialized size before framing. + ...(record.args === undefined ? {} : { args: record.args }), }; } if (record.type === 'tool_output_delta') { @@ -829,6 +869,8 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { 'status', 'sandboxFailureReason', 'durationMs', + 'content', + 'contentBytes', ]; assertAllowedKeys(record, 'Session tool result event', allowed); assertRequiredKeys(record, 'Session tool result event', [ @@ -845,6 +887,9 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { if (record.status === 'completed' && record.sandboxFailureReason !== undefined) { throw invalidProtocolFrame('Completed Session tool result cannot carry a sandbox failure'); } + if (record.content !== undefined && record.contentBytes !== undefined) { + throw invalidProtocolFrame('Session tool result cannot carry both content and contentBytes'); + } return { type: record.type, ...identity, @@ -860,6 +905,12 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { : { durationMs: requireCount(record.durationMs, 'Session tool result duration'), }), + ...(record.content === undefined + ? {} + : { content: decodeLiveToolResultContent(record.content) }), + ...(record.contentBytes === undefined + ? {} + : { contentBytes: requireCount(record.contentBytes, 'Session tool result content size') }), }; } if (record.type === 'tool_result_preview') { diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index 4f3e894c46..4945bf2371 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -19,11 +19,13 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; -import type { SessionEvent, ShellRunUpdate } from '@maka/core/events'; +import type { SessionEvent, ShellRunUpdate, ToolResultContent } from '@maka/core/events'; import { encodeProtocolMessage, RUNTIME_HOST_MAX_MESSAGE_BYTES, SESSION_LIVE_DELTA_MAX_BYTES, + SESSION_LIVE_TOOL_ARGS_MAX_BYTES, + SESSION_LIVE_TOOL_RESULT_MAX_BYTES, SESSION_RUNTIME_RESOURCE_PTY_DATA_MAX_BYTES, SESSION_RUNTIME_RESOURCE_CHANGES_MAX, SESSION_SUBSCRIPTION_FRAME_MAX_BYTES, @@ -1949,6 +1951,7 @@ function projectToolEvent( ? {} : { displayName: boundedUtf8(event.displayName, SESSION_TOOL_NAME_MAX_BYTES) }), ...(event.stepId === undefined ? {} : { stepId: event.stepId }), + ...boundedLiveToolArgs(event.args), }; case 'tool_output_delta': return { @@ -1979,6 +1982,7 @@ function projectToolEvent( ? { sandboxFailureReason: event.content.sandboxFailure.reason } : {}), ...(event.durationMs === undefined ? {} : { durationMs: event.durationMs }), + ...boundedLiveToolResult(event.content), }; case 'tool_result_preview': return { @@ -2003,6 +2007,44 @@ function boundedUtf8(value: string, maxBytes: number): string { return bounded; } +/** + * Live tool args ride the broadcast only while their serialized form fits the + * per-field budget; an oversized payload (a large WriteStdin paste) is omitted + * — the card renders without an input summary until the durable transcript + * fills it, matching the pre-#3521 placeholder behavior for that rare case. + */ +function boundedLiveToolArgs(args: unknown): { args?: unknown } { + if (args === undefined) return {}; + const bytes = serializedUtf8Bytes(args); + if (bytes === undefined || bytes > SESSION_LIVE_TOOL_ARGS_MAX_BYTES) return {}; + return { args: structuredClone(args) }; +} + +/** + * The settled content is the whole point of the live result frame (#3521): a + * transcript renders the final card at settle time, before it can scroll into + * immutable terminal scrollback. Oversized results degrade to their byte size + * (`contentBytes`) so the row stays truthful; the durable transcript remains + * the path to their full body. + */ +function boundedLiveToolResult(content: ToolResultContent): { + content?: ToolResultContent; + contentBytes?: number; +} { + const bytes = serializedUtf8Bytes(content); + if (bytes === undefined) return {}; + if (bytes > SESSION_LIVE_TOOL_RESULT_MAX_BYTES) return { contentBytes: bytes }; + return { content: structuredClone(content) }; +} + +function serializedUtf8Bytes(value: unknown): number | undefined { + try { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); + } catch { + return undefined; + } +} + function signal(): { readonly promise: Promise; resolve(): void } { let resolve!: () => void; const promise = new Promise((settle) => {