From e63a9de63105afe1d35d22055bd7ce6a85d6ee0d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 01:21:21 +0800 Subject: [PATCH 1/2] feat(runtime-host): carry args and settled content in live tool events Live tool_start/tool_result broadcast frames carried no args or result content, so a transcript card could only settle into a '(no output)' placeholder and wait for the turn-terminal durable backfill. Terminal scrollback cannot be rewritten, so any card scrolled out of the viewport before the backfill kept the placeholder forever (#3521). The runtime already holds the full result when it emits the event (persist-then-broadcast). Project it into the live frame, bounded so the frame stays under the subscription frame cap: args up to 16 KB, result content up to 48 KB; an oversized result degrades to its serialized byte size (contentBytes) so the row stays truthful. The wire keeps rejecting raw provider error text and thinking signatures. Generated-by: Maka (Kimi K3) --- packages/core/src/events.ts | 6 + .../src/__tests__/protocol.test.ts | 28 +++- .../session-continuity-coordinator.test.ts | 139 ++++++++++++++++++ .../src/__tests__/session-projector.test.ts | 82 +++++++++++ .../src/adapter/session-projector.ts | 23 ++- packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/session-continuity.ts | 53 ++++++- .../server/session-continuity-coordinator.ts | 44 +++++- 8 files changed, 364 insertions(+), 15 deletions(-) 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) => { From 7aa64d2982fa2b5379f36163a966d517099d1733 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 01:27:11 +0800 Subject: [PATCH 2/2] fix(cli): render oversized live tool results by byte size With content now riding live tool_result frames, a result that exceeds the 48 KB live budget arrives as an empty placeholder plus contentBytes. Render the truthful byte count in the compact row (instead of the '(no output)' lie) and a dim note in the expanded card; the terminal reconcile still heals the full body from the durable transcript at turn end, clearing the placeholder. Generated-by: Maka (Kimi K3) --- .../cli/src/__tests__/pi-transcript.test.ts | 82 +++++++++++++++++++ packages/cli/src/pi-transcript-tools.ts | 15 +++- packages/cli/src/pi-transcript.ts | 11 +++ 3 files changed, 107 insertions(+), 1 deletion(-) 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,