From b13c99c3d3987f25122415acc93ab32bf4b83463 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:12:55 +0800 Subject: [PATCH 01/13] refactor(cli): unify transcript reconciliation Generated-by: Codex --- .../runtime-host-run-command.test.ts | 6 +-- .../runtime-host-session-driver.test.ts | 26 +++++------- packages/cli/src/runtime-host-run-command.ts | 8 +++- .../cli/src/runtime-host-session-driver.ts | 42 ++++--------------- packages/cli/src/session-driver.ts | 4 +- 5 files changed, 31 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 708d6e6de4..2589f6c017 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -895,7 +895,7 @@ function runFixture(input: { if (input.graphMultiWakeRace) { queueMicrotask(() => { for (const listener of transcriptListeners) { - listener('session-created', 'turn-2', structuredClone(graphMessages()), 'terminal'); + listener('session-created', 'turn-2', structuredClone(graphMessages()), 'reconcile'); } }); } @@ -905,7 +905,7 @@ function runFixture(input: { queueMicrotask(() => { const terminal = multiWakeGraphMessages(true); for (const listener of transcriptListeners) { - listener('session-created', 'turn-3', structuredClone(terminal), 'terminal'); + listener('session-created', 'turn-3', structuredClone(terminal), 'reconcile'); } }); return multiWakeGraphMessages(false); @@ -918,7 +918,7 @@ function runFixture(input: { if (input.graphProjectionRace) { queueMicrotask(() => { for (const listener of transcriptListeners) { - listener('session-created', 'turn-2', structuredClone(messages), 'terminal'); + listener('session-created', 'turn-2', structuredClone(messages), 'reconcile'); } }); return graphMessages(false); diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 8814fd01d7..8267b39433 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1330,7 +1330,7 @@ describe('Runtime Host Maka Session driver', () => { model: 'gpt-5', }); await driver.switchSession('session-1'); - const replacement = deferred(); + const replacement = deferred(); driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages) => replacement.resolve(messages), ); @@ -1370,9 +1370,9 @@ describe('Runtime Host Maka Session driver', () => { model: 'gpt-5', }); await driver.switchSession('session-1'); - const replacements: StoredMessage[][] = []; + const replacements: Array = []; driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { - assert.equal(reason, 'tool_result'); + assert.equal(reason, 'reconcile'); replacements.push(messages); }); @@ -1383,16 +1383,15 @@ describe('Runtime Host Maka Session driver', () => { assert.deepEqual(replacements, [secondMessages]); }); - test('does not let an older live refresh overwrite the terminal transcript', async () => { + test('does not publish an older tool-result transcript after the terminal transcript', async () => { const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); const liveTranscript = deferred(); - const staleLiveMessages = [userMessage('turn-1', 'Run it')]; - const terminalMessages = [userMessage('turn-1', 'Run it'), assistantMessage('turn-1', 'Done')]; const liveRefresh = new FakeSubscription( continuitySnapshot(), liveTranscript.promise, 'subscription-2', ); + const terminalMessages = [userMessage('turn-1', 'Run it'), assistantMessage('turn-1', 'Done')]; const terminalRefresh = new FakeSubscription( continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }), Promise.resolve(terminalMessages), @@ -1406,10 +1405,7 @@ describe('Runtime Host Maka Session driver', () => { model: 'gpt-5', }); await driver.switchSession('session-1'); - const replacements: Array<{ - messages: StoredMessage[]; - reason: MakaTranscriptReplacementReason; - }> = []; + const replacements: Array<{ messages: readonly StoredMessage[]; reason: string }> = []; driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { replacements.push({ messages, reason }); }); @@ -1427,12 +1423,12 @@ describe('Runtime Host Maka Session driver', () => { }), }); await waitFor(() => replacements.length === 1); - assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'terminal' }]); + assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'reconcile' }]); - liveTranscript.resolve(staleLiveMessages); + liveTranscript.resolve([userMessage('turn-1', 'Run it')]); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'terminal' }]); + assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'reconcile' }]); }); test('does not let a retired-channel live refresh overwrite a reconnect snapshot', async () => { @@ -1503,7 +1499,7 @@ describe('Runtime Host Maka Session driver', () => { }); const switched = await driver.switchSession('session-1'); assert.ok(switched.activeTurn); - const transcript = deferred(); + const transcript = deferred(); driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { assert.equal(reason, 'reconnect'); transcript.resolve(messages); @@ -2383,7 +2379,7 @@ describe('turn consumer lag recovery (#3180)', () => { }); const switched = await driver.switchSession('session-1'); assert.ok(switched.activeTurn); - const transcript = deferred(); + const transcript = deferred(); driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { assert.equal(reason, 'reconnect'); transcript.resolve(messages); diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index a50ceca5e0..18d8f0865f 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -471,7 +471,7 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { } } - #acceptGraphTranscript(messages: StoredMessage[]): void { + #acceptGraphTranscript(messages: readonly StoredMessage[]): void { const replacement = messages.map((message) => structuredClone(message)); this.#latestTranscriptReplacement = replacement; for (const [turnId, waiters] of this.#graphTerminalWaiters) { @@ -484,7 +484,11 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { } } - #acceptRootTranscript(sessionId: string, turnId: string, messages: StoredMessage[]): void { + #acceptRootTranscript( + sessionId: string, + turnId: string, + messages: readonly StoredMessage[], + ): void { const active = this.#activeTurn; if (!active || active.sessionId !== sessionId || active.turnId !== turnId) return; active.outcome = classifierFromStoredTurn(messages, turnId, active.runId); diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index bb324b62c4..49e3d26a03 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -41,10 +41,7 @@ import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; -import { - isRuntimeHostTerminalTurn as isTerminalTurn, - type RuntimeHostTerminalTurn as TerminalTurnSnapshot, -} from '@maka/runtime-host/adapter'; +import { isRuntimeHostTerminalTurn as isTerminalTurn } from '@maka/runtime-host/adapter'; import type { DirectRequestOperationKey, RuntimeHostConnection } from '@maka/runtime-host/client'; import { readRuntimeHostResources, @@ -133,7 +130,7 @@ export interface RuntimeHostMakaSessionDriver extends MakaSessionDriver { listener: ( sessionId: string, turnId: string, - messages: StoredMessage[], + messages: readonly StoredMessage[], reason: MakaTranscriptReplacementReason, ) => void, ): () => void; @@ -189,7 +186,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { ( sessionId: string, turnId: string, - messages: StoredMessage[], + messages: readonly StoredMessage[], reason: MakaTranscriptReplacementReason, ) => void >(); @@ -634,7 +631,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { listener: ( sessionId: string, turnId: string, - messages: StoredMessage[], + messages: readonly StoredMessage[], reason: MakaTranscriptReplacementReason, ) => void, ): () => void { @@ -1028,8 +1025,8 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { for (const listener of this.#pendingInteractionListeners) listener(pending); }, onInteractionResolved: (pending) => this.#resolveExternalInteraction(pending), - onTurnTerminal: (turn) => this.#refreshTerminalTranscript(turn, sessionGeneration), - onToolResult: (turnId) => this.#refreshLiveTranscript(sessionId, sessionGeneration, turnId), + onTurnTerminal: (turn) => this.#refreshTranscript(sessionId, sessionGeneration, turn.turnId), + onToolResult: (turnId) => this.#refreshTranscript(sessionId, sessionGeneration, turnId), onTranscriptReplaced: (turnId, messages) => { if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) { return; @@ -1080,23 +1077,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { .catch(() => undefined); } - #refreshTerminalTranscript(turn: TerminalTurnSnapshot, sessionGeneration: number): void { - const refreshSequence = ++this.#transcriptRefreshSequence; - void loadCurrentMessages(this.#connection, turn.sessionId) - .then((messages) => { - if ( - this.#sessionId !== turn.sessionId || - this.#sessionGeneration !== sessionGeneration || - refreshSequence !== this.#transcriptRefreshSequence - ) { - return; - } - this.#publishTranscriptReplacement(turn.sessionId, turn.turnId, messages, 'terminal'); - }) - .catch(() => undefined); - } - - #refreshLiveTranscript(sessionId: string, sessionGeneration: number, turnId: string): void { + #refreshTranscript(sessionId: string, sessionGeneration: number, turnId: string): void { const refreshSequence = ++this.#transcriptRefreshSequence; void loadCurrentMessages(this.#connection, sessionId) .then((messages) => { @@ -1107,7 +1088,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { ) { return; } - this.#publishTranscriptReplacement(sessionId, turnId, messages, 'tool_result'); + this.#publishTranscriptReplacement(sessionId, turnId, messages, 'reconcile'); }) .catch(() => undefined); } @@ -1120,12 +1101,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { ): void { if (this.#sessionId !== sessionId) return; for (const listener of this.#transcriptListeners) { - listener( - sessionId, - turnId, - messages.map((message) => structuredClone(message)), - reason, - ); + listener(sessionId, turnId, messages, reason); } } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 701fca53d9..42e179efab 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -123,7 +123,7 @@ export interface MakaSessionDriver { listener: ( sessionId: string, turnId: string, - messages: StoredMessage[], + messages: readonly StoredMessage[], reason: MakaTranscriptReplacementReason, ) => void, ): () => void; @@ -176,7 +176,7 @@ export type CreateSessionRequest = Omit & permissionMode?: PermissionMode; }; -export type MakaTranscriptReplacementReason = 'terminal' | 'reconnect' | 'tool_result'; +export type MakaTranscriptReplacementReason = 'reconcile' | 'reconnect'; export type SessionResumeAvailability = { available: true } | { available: false; reason: string }; From ead026ae2fcaf7f5eae366903af568dc0bf4eef0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:13:00 +0800 Subject: [PATCH 02/13] refactor(cli): remove derived transcript mirrors Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 28 +++++++++++++++++++ packages/cli/src/pi-transcript-tools.ts | 12 +++++--- packages/cli/src/pi-transcript.ts | 20 ++----------- packages/cli/src/pi-tui-runner.ts | 13 +++++---- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 7ba93397de..dd8bf88bd8 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -588,6 +588,34 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('explains a stored tool call whose turn ended without a result', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { + type: 'tool_call', + id: 'tool-1', + turnId: 'turn-1', + ts: 1, + toolName: 'Read', + args: { path: '/tmp/example.txt' }, + }, + { + type: 'turn_state', + id: 'turn-state-1', + turnId: 'turn-1', + ts: 2, + status: 'completed', + partialOutputRetained: false, + }, + ] satisfies StoredMessage[]); + + assert.equal(toggleAllToolExpansion(state), true); + assert.match( + renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'), + /Interrupted before the tool returned a result\./, + ); + }); + test('keeps a stored errored Read poll as a card without folding it into the parent Bash card', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/bg-1'; diff --git a/packages/cli/src/pi-transcript-tools.ts b/packages/cli/src/pi-transcript-tools.ts index 40ac11ed18..7195862713 100644 --- a/packages/cli/src/pi-transcript-tools.ts +++ b/packages/cli/src/pi-transcript-tools.ts @@ -33,6 +33,7 @@ import { colorDiff } from './tui-diff.js'; import { collapseToSingleLine, fitLine, + formatToolResultContent, formatUnknownInline, limitText, renderIndented, @@ -193,7 +194,7 @@ function renderExpandedToolBlock(entry: MakaPiToolEntry, width: number): string[ } lines.push(...renderToolStreams(entry.outputDeltas.values(), width)); } - if (entry.result || entry.output) { + if (entry.result || entry.status === 'aborted') { lines.push(...renderToolResult(entry, width)); } if ( @@ -549,8 +550,8 @@ function renderToolResult(entry: MakaPiToolEntry, width: number): string[] { } // A generic `text` dump — a Bash body or raw tool text — is what the head/tail // cap targets: the model already holds the full body, so the transcript only - // needs enough to orient. An undefined result with a formatted `output` string - // is treated the same way. `json` is deliberately excluded: a Read json is + // needs enough to orient. An interrupted call with no result uses the same + // capped path for its explanation. `json` is deliberately excluded: a Read json is // summarized above, a Grep/Glob json is a structured list the user expands to // scan in full, and any other json collapses to a single inline line where the // cap would be a no-op anyway. @@ -566,6 +567,9 @@ function renderToolResult(entry: MakaPiToolEntry, width: number): string[] { /** Best-effort extraction of the human-readable body from a tool result. */ function plainResultText(entry: MakaPiToolEntry): string { const result = entry.result; + if (!result) { + return entry.status === 'aborted' ? 'Interrupted before the tool returned a result.' : ''; + } if (result?.kind === 'text') return typeof result.text === 'string' ? result.text : ''; if (result?.kind === 'json') { const value = result.value; @@ -585,7 +589,7 @@ function plainResultText(entry: MakaPiToolEntry): string { const preview = formatQuietJsonValue(value, 'en'); return preview.headline ? `${preview.headline}\n${preview.body}` : preview.body; } - return entry.output ?? ''; + return formatToolResultContent(result); } function renderTerminalResult( diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index bc00aee7eb..f39e3a7489 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -50,7 +50,6 @@ import { ansi } from './tui-ansi.js'; import { fitLine, formatTokenCount, - formatToolResultContent, formatUnknown, limitText, markdownTheme, @@ -176,10 +175,8 @@ export type MakaPiTranscriptEntry = toolName: string; title?: string; input: unknown; - /** Structured result; preferred over `output` when present. */ + /** Structured result returned by the tool. */ result?: ToolResultContent; - /** Flattened result text, kept as a fallback for text/json/unknown kinds. */ - output?: string; /** In-memory revision for render-cache invalidation when a result is replaced. */ resultVersion: number; progress: BoundedChunkBuffer; @@ -405,7 +402,6 @@ export function reconcileToolsWithStoredMessages( entry.title = durable.title; entry.input = structuredClone(durable.input); entry.result = durable.result ? structuredClone(durable.result) : undefined; - entry.output = durable.output; entry.durationMs = durable.durationMs; entry.status = durable.status; entry.hidden = durable.hidden; @@ -635,7 +631,6 @@ export function applyMakaSessionEventToTranscript( progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), result: event.content, - output: formatToolResultContent(event.content), resultVersion: 1, durationMs: event.durationMs, status: event.isError ? 'error' : 'done', @@ -683,7 +678,6 @@ export function applyMakaSessionEventToTranscript( } else { tool.status = toolResultTranscriptStatus(event.content, event.isError); tool.result = event.content; - tool.output = formatToolResultContent(event.content); tool.durationMs = event.durationMs; tool.resultVersion += 1; } @@ -697,7 +691,6 @@ export function applyMakaSessionEventToTranscript( progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), result: event.content, - output: formatToolResultContent(event.content), resultVersion: 1, durationMs: event.durationMs, status: toolResultTranscriptStatus(event.content, event.isError), @@ -877,11 +870,6 @@ function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { } function toolActivityToTranscriptEntry(item: ToolActivityItem): MakaPiToolEntry { - const output = item.result - ? formatToolResultContent(item.result) - : item.status === 'interrupted' - ? 'Interrupted before the tool returned a result.' - : undefined; const entry: MakaPiToolEntry = { kind: 'tool', toolUseId: item.toolUseId, @@ -891,7 +879,6 @@ function toolActivityToTranscriptEntry(item: ToolActivityItem): MakaPiToolEntry progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), ...(item.result ? { result: item.result } : {}), - ...(output ? { output } : {}), resultVersion: item.result ? 1 : 0, ...(item.durationMs !== undefined ? { durationMs: item.durationMs } : {}), status: transcriptToolStatus(item.status), @@ -942,8 +929,9 @@ function transcriptToolStatus(status: ToolActivityItem['status']): MakaPiToolEnt case 'completed': return 'done'; case 'errored': - case 'interrupted': return 'error'; + case 'interrupted': + return 'aborted'; case 'pending': case 'running': return 'running'; @@ -1004,7 +992,6 @@ function applyShellRunResult( if (!merged.changed) return false; entry.status = shellRunTranscriptStatus(merged.result.status); entry.result = merged.result; - entry.output = formatToolResultContent(merged.result); entry.durationMs = Math.max( 0, (merged.result.completedAt ?? merged.result.updatedAt) - merged.result.startedAt, @@ -1025,7 +1012,6 @@ function applyOwnShellRunResult( : 'done' : shellRunTranscriptStatus(result.status); entry.result = result; - entry.output = formatToolResultContent(result); if (entry.toolName === 'WriteStdin') { entry.durationMs = operationDurationMs; } else { diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 478c66411d..f1a0dceb41 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -280,9 +280,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const tui = new TUI(terminal); const state = createMakaPiTranscriptState(); - let transcriptMessages: readonly StoredMessage[] = []; + let transcriptLastUsedModel: string | undefined; + const rememberTranscriptModel = (messages: readonly StoredMessage[]): void => { + transcriptLastUsedModel = latestAssistantModelId(messages); + }; const replaceTranscript = (messages: readonly StoredMessage[]): void => { - transcriptMessages = messages; + rememberTranscriptModel(messages); replaceTranscriptWithStoredMessages(state, messages); }; let cwd = input.cwd; @@ -537,7 +540,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { requestRender(); return; } - transcriptMessages = messages; + rememberTranscriptModel(messages); if (reconcileToolsWithStoredMessages(state, turnId, messages)) { shellRunElapsedTicker.sync(); requestRender(); @@ -1462,7 +1465,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const setModel = async (nextModel: string) => { if (nextModel === model) return; - const previousModel = latestAssistantModelId(transcriptMessages) ?? model; + const previousModel = transcriptLastUsedModel ?? model; await input.driver.setModel(nextModel); model = nextModel; // Same-connection switch: scope the choice lookup to the live connection @@ -1488,7 +1491,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // Updates the provider (and thus the thinking variants) and the status line. const setModelChoice = async (choice: ModelChoice) => { if (choice.model === model && choice.connectionSlug === connectionSlug) return; - const previousModel = latestAssistantModelId(transcriptMessages) ?? model; + const previousModel = transcriptLastUsedModel ?? model; const previousConnectionSlug = connectionSlug; const previousChoice = modelChoices?.find( (candidate) => From f4d072384de2d9cdf3c3851e445cde1a8367cfda Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:37:53 +0800 Subject: [PATCH 03/13] fix(cli): fence transcript replacements across recovery Generated-by: Codex --- .../runtime-host-session-driver.test.ts | 73 ++++--------------- .../cli/src/runtime-host-session-driver.ts | 33 +++++---- 2 files changed, 34 insertions(+), 72 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 8267b39433..34f470796c 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -41,38 +41,12 @@ import { } from '@maka/runtime-host/protocol'; import { createRuntimeHostMakaSessionDriver, - runtimeHostSessionSummary, type RuntimeHostMakaSessionDriverInput, } from '../runtime-host-session-driver.js'; -import { - SkillInvocationBlockedError, - type MakaAttachedSessionTurn, - type MakaTranscriptReplacementReason, -} from '../session-driver.js'; +import { SkillInvocationBlockedError, type MakaAttachedSessionTurn } from '../session-driver.js'; import { WAIT_BUDGET_MS } from './tui-terminal-mock.js'; describe('Runtime Host Maka Session driver', () => { - test('maps authoritative live Turn ids into Session summaries', () => { - assert.deepEqual( - runtimeHostSessionSummary( - sessionProjection({ - status: 'running', - liveRunState: { schemaVersion: 1, runningTurnIds: ['turn-1', 'turn-2'] }, - }), - ).runningTurnIds, - ['turn-1', 'turn-2'], - ); - const knownEmpty = runtimeHostSessionSummary( - sessionProjection({ liveRunState: { schemaVersion: 1, runningTurnIds: [] } }), - ); - assert.equal(Object.hasOwn(knownEmpty, 'runningTurnIds'), true); - assert.deepEqual(knownEmpty.runningTurnIds, []); - assert.equal( - Object.hasOwn(runtimeHostSessionSummary(sessionProjection()), 'runningTurnIds'), - false, - ); - }); - test('keeps remote Session paths out of Client filesystem policy', async () => { const driver = createRuntimeHostMakaSessionDriver({ connection: new FakeConnection([]).value, @@ -1412,44 +1386,33 @@ describe('Runtime Host Maka Session driver', () => { attached.push(toolResultFrame(1)); await waitFor(() => connection.openedSubscriptions === 2); - attached.push({ - kind: 'subscription.session_projection', - hostEpoch: 'host-1', - subscriptionId: 'subscription-1', - sequence: 2, - snapshot: continuitySnapshot({ - projectionRevision: 2, - rootTurn: completedTurn('turn-1', 'run-1'), - }), - }); + attached.push(projectionFrame(2, completedTurn('turn-1', 'run-1'), 2)); await waitFor(() => replacements.length === 1); assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'reconcile' }]); liveTranscript.resolve([userMessage('turn-1', 'Run it')]); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); + await delay(0); assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'reconcile' }]); }); - test('does not let a retired-channel live refresh overwrite a reconnect snapshot', async () => { + test('does not publish an older tool-result transcript after reconnect recovery', async () => { const initial = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); const liveTranscript = deferred(); - const staleLiveMessages = [userMessage('turn-1', 'Run it')]; - const reconnectMessages = [ - userMessage('turn-1', 'Run it'), - assistantMessage('turn-1', 'Still running'), - ]; const liveRefresh = new FakeSubscription( continuitySnapshot(), liveTranscript.promise, 'subscription-2', ); - const replacement = new FakeSubscription( + const recoveredMessages = [ + userMessage('turn-1', 'Run it'), + assistantMessage('turn-1', 'Recovered'), + ]; + const recovered = new FakeSubscription( continuitySnapshot({ projectionRevision: 2 }), - Promise.resolve(reconnectMessages), + Promise.resolve(recoveredMessages), 'subscription-3', ); - const connection = new FakeConnection([initial, liveRefresh, replacement], true); + const connection = new FakeConnection([initial, liveRefresh, recovered], true); const driver = createRuntimeHostMakaSessionDriver({ connection: connection.value, cwd: '/tmp', @@ -1457,10 +1420,7 @@ describe('Runtime Host Maka Session driver', () => { model: 'gpt-5', }); await driver.switchSession('session-1'); - const replacements: Array<{ - messages: StoredMessage[]; - reason: MakaTranscriptReplacementReason; - }> = []; + const replacements: Array<{ messages: readonly StoredMessage[]; reason: string }> = []; driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => { replacements.push({ messages, reason }); }); @@ -1471,12 +1431,11 @@ describe('Runtime Host Maka Session driver', () => { new RuntimeHostSubscriptionError('connection_closed', 'connection lost during active Turn'), ); await waitFor(() => replacements.length === 1); - assert.deepEqual(replacements, [{ messages: reconnectMessages, reason: 'reconnect' }]); + assert.deepEqual(replacements, [{ messages: recoveredMessages, reason: 'reconnect' }]); - liveTranscript.resolve(staleLiveMessages); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(replacements, [{ messages: reconnectMessages, reason: 'reconnect' }]); + liveTranscript.resolve([userMessage('turn-1', 'Run it')]); + await delay(0); + assert.deepEqual(replacements, [{ messages: recoveredMessages, reason: 'reconnect' }]); }); test('resnapshots an active Session after reconnect and continues its live stream', async () => { diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 49e3d26a03..186ef02f81 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -1027,16 +1027,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { onInteractionResolved: (pending) => this.#resolveExternalInteraction(pending), onTurnTerminal: (turn) => this.#refreshTranscript(sessionId, sessionGeneration, turn.turnId), onToolResult: (turnId) => this.#refreshTranscript(sessionId, sessionGeneration, turnId), - onTranscriptReplaced: (turnId, messages) => { - if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) { - return; - } - // A reconnect snapshot is newer than every transcript read started by - // the retired channel. Invalidate those reads before publishing so a - // late tool-result snapshot cannot roll the transcript back. - this.#transcriptRefreshSequence += 1; - this.#publishTranscriptReplacement(sessionId, turnId, messages, 'reconnect'); - }, + onTranscriptReplaced: (turnId, messages) => + this.#publishTranscriptReplacement( + sessionId, + sessionGeneration, + turnId, + messages, + 'reconnect', + ), onGoalChanged: (goal) => { // A closing channel from a previous session can still be draining a // frame when the swap happens; only the live session may publish. @@ -1088,18 +1086,26 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { ) { return; } - this.#publishTranscriptReplacement(sessionId, turnId, messages, 'reconcile'); + this.#publishTranscriptReplacement( + sessionId, + sessionGeneration, + turnId, + messages, + 'reconcile', + ); }) .catch(() => undefined); } #publishTranscriptReplacement( sessionId: string, + sessionGeneration: number, turnId: string, messages: readonly StoredMessage[], reason: MakaTranscriptReplacementReason, ): void { - if (this.#sessionId !== sessionId) return; + if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) return; + this.#transcriptRefreshSequence += 1; for (const listener of this.#transcriptListeners) { listener(sessionId, turnId, messages, reason); } @@ -1244,9 +1250,6 @@ export function runtimeHostSessionSummary(session: SessionCatalogProjection): Se status: session.status, ...(session.blockedReason === undefined ? {} : { blockedReason: session.blockedReason }), ...(session.statusUpdatedAt === undefined ? {} : { statusUpdatedAt: session.statusUpdatedAt }), - ...(session.liveRunState === undefined - ? {} - : { runningTurnIds: [...session.liveRunState.runningTurnIds] }), ...(session.parentSessionId === undefined ? {} : { parentSessionId: session.parentSessionId }), ...(session.branchOfTurnId === undefined ? {} : { branchOfTurnId: session.branchOfTurnId }), ...(session.subagent === undefined ? {} : { subagent: session.subagent }), From 367fc06d47a485dea0f84cd67507cdb5aa7a275f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:42:17 +0800 Subject: [PATCH 04/13] refactor(cli): remove shell poll side state Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 82 +++++++++++++++--- packages/cli/src/pi-transcript.ts | 86 ++++--------------- 2 files changed, 88 insertions(+), 80 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index dd8bf88bd8..168d490c73 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -1559,12 +1559,7 @@ describe('Maka Pi TUI transcript', () => { }), ); - // The poll is in flight, but no Read row ever appears — the parent card is - // the only tool entry throughout. - assert.deepEqual( - state.entries.filter((entry) => entry.kind === 'tool').map((tool) => tool.toolUseId), - ['bash-bg'], - ); + // The poll is in flight, but no Read row ever appears. const inFlight = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); assert.doesNotMatch(inFlight, /● Read/); @@ -1777,6 +1772,69 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(rendered, /background task no longer exists/); }); + test('surfaces a failed poll at the tail without rewriting scrollback', () => { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/bg-1'; + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'npm test' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref, status: 'running' }), + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'read-bg', + toolName: 'Read', + args: { ref }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'text_delta', + messageId: 'assistant-late', + text: 'Still working\nwith more output', + }), + ); + + const before = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi); + const assistant = state.entries.find( + (entry) => entry.kind === 'assistant' && entry.messageId === 'assistant-late', + ); + assert.ok(assistant); + const viewportTop = state.renderGeometry.entryFirstLine?.get(assistant); + assert.ok(viewportTop !== undefined && viewportTop > 0); + state.renderGeometry.viewportTop = viewportTop; + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'read-bg', + isError: true, + content: { kind: 'text', text: 'background task no longer exists' }, + }), + ); + + const after = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi); + assert.deepEqual(after.slice(0, viewportTop), before.slice(0, viewportTop)); + assert.match(after.slice(viewportTop).join('\n'), /● Read/); + }); + test('never renders a StopBackgroundTask card while the stop is in flight', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/bg-1'; @@ -1809,10 +1867,8 @@ describe('Maka Pi TUI transcript', () => { ); // No transient stop row while the stop call is in flight. - assert.deepEqual( - state.entries.filter((entry) => entry.kind === 'tool').map((tool) => tool.toolUseId), - ['bash-bg'], - ); + const inFlight = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); + assert.doesNotMatch(inFlight, /● StopBackgroundTask/); applyMakaSessionEventToTranscript( state, @@ -2346,11 +2402,13 @@ describe('Maka Pi TUI transcript', () => { args: { ref }, }), ); - assert.equal(state.pendingShellRunPolls.size, 1); + const beforeAbort = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); + assert.doesNotMatch(beforeAbort, /● Read/); applyMakaSessionEventToTranscript(state, event({ type: 'abort', reason: 'user_stop' })); - assert.equal(state.pendingShellRunPolls.size, 0); + const afterAbort = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); + assert.doesNotMatch(afterAbort, /● Read/); }); test('folds a background-task Read result into its parent Bash card', () => { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index f39e3a7489..f4d1985094 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -94,14 +94,6 @@ export interface MakaPiTranscriptState { * entryInLiveViewport. */ renderGeometry: MakaPiRenderGeometry; - /** - * Ref polls folded at `tool_start`, childToolUseId → card facts. A Read / - * StopBackgroundTask aimed at a ref a visible Bash card owns is internal - * polling: it never renders a row, and its result folds straight into the - * parent. The facts survive only so an errored poll can surface as a normal - * card instead of being swallowed. - */ - pendingShellRunPolls: Map; /** Aggregated token usage for statusline display; reset on session switch. */ usage: MakaPiUsageSummary; /** @@ -143,13 +135,6 @@ export interface MakaPiRenderGeometry { viewportTop: number; } -/** Facts kept from a folded poll's `tool_start` so an errored result can still materialize a proper card. */ -export interface MakaPiPendingShellRunPoll { - toolName: string; - title?: string; - input: unknown; -} - /** A single live output chunk from a `tool_output_delta` event. */ export interface MakaPiToolOutputDelta { seq: number; @@ -186,10 +171,9 @@ export type MakaPiTranscriptEntry = /** Expanded card view; stamped from expandAllTools, retargeted by Ctrl+O. */ expanded: boolean; /** - * Set when a successful shell-run poll is folded into its parent while - * off-screen: the entry cannot be spliced (that would shift line numbers - * and clear scrollback), but it must not render as an independent card - * on a future full redraw. A hidden entry contributes zero lines. + * Set while an internal shell-run poll is in flight, or after one is + * folded into an off-screen parent. A hidden entry contributes zero + * lines, preserving terminal scrollback while retaining one tool state. */ hidden?: boolean; } @@ -229,7 +213,6 @@ export function createMakaPiTranscriptState(): MakaPiTranscriptState { expandAllTools: false, expandAllThinking: false, renderGeometry: { entryFirstLine: undefined, viewportTop: 0 }, - pendingShellRunPolls: new Map(), usage: { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 }, steering: [], followup: [], @@ -342,7 +325,6 @@ export function replaceTranscriptWithStoredMessages( const view = materializeSession(messages); state.entries = foldStoredShellRunChildren(view.items.flatMap(chatItemToTranscriptEntries)); clearPendingInteractions(state); - state.pendingShellRunPolls.clear(); state.expandAllTools = false; state.expandAllThinking = false; // The old entries are gone; no position is known until the next render, and @@ -578,17 +560,11 @@ export function applyMakaSessionEventToTranscript( // folds into the parent at tool_result. A poll is folded only when its // parent card already carries the run's shell_run result — otherwise it // renders normally and the tool_result fold below still applies. - if (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') { - const ref = readArgsRef(event.args); - if (ref && findShellRunParent(state, ref, event.toolUseId)) { - state.pendingShellRunPolls.set(event.toolUseId, { - toolName: event.toolName, - ...(event.displayName ? { title: event.displayName } : {}), - input: projectToolActivityArgs(event.toolName, event.args), - }); - break; - } - } + const ref = readArgsRef(event.args); + const hidden = + (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') && + !!ref && + !!findShellRunParent(state, ref, event.toolUseId); state.entries.push({ kind: 'tool', turnId: event.turnId, @@ -601,45 +577,12 @@ export function applyMakaSessionEventToTranscript( outputDeltas: createOutputBuffer(), status: 'running', expanded: state.expandAllTools, + ...(hidden ? { hidden: true } : {}), }); break; } case 'tool_result': { - const foldedPoll = state.pendingShellRunPolls.get(event.toolUseId); - if (foldedPoll) { - state.pendingShellRunPolls.delete(event.toolUseId); - const shellRun = event.content.kind === 'shell_run' ? event.content : undefined; - const parent = shellRun - ? findShellRunParent(state, shellRun.ref, event.toolUseId) - : undefined; - // isError is the call-level authoritative status: a failed call never - // folds, even when it carries a well-formed shell_run payload. - if (parent && shellRun && !event.isError) { - applyLiveShellRunResultToParent(state, parent, shellRun); - break; - } - // The poll failed (or lost its parent): surface a normal card so the - // failure is never swallowed by the fold. - const entry: MakaPiToolEntry = { - kind: 'tool', - turnId: event.turnId, - toolUseId: event.toolUseId, - toolName: foldedPoll.toolName, - ...(foldedPoll.title ? { title: foldedPoll.title } : {}), - input: foldedPoll.input, - progress: createProgressBuffer(), - outputDeltas: createOutputBuffer(), - result: event.content, - resultVersion: 1, - durationMs: event.durationMs, - status: event.isError ? 'error' : 'done', - expanded: state.expandAllTools, - }; - if (shellRun && !event.isError) applyOwnShellRunResult(entry, shellRun, event.durationMs); - state.entries.push(entry); - break; - } const tool = findToolEntry(state, event.toolUseId); const shellRun = event.content.kind === 'shell_run' ? event.content : undefined; const parent = shellRun @@ -666,6 +609,7 @@ export function applyMakaSessionEventToTranscript( break; } if (tool) { + if (tool.hidden) revealToolAtTail(state, tool); if (shellRun) { if (tool.toolName === 'Bash') { applyShellRunResult(tool, shellRun); @@ -794,7 +738,6 @@ export function applyMakaSessionEventToTranscript( case 'error': clearPendingInteractions(state); - state.pendingShellRunPolls.clear(); state.entries.push({ kind: 'notice', level: 'error', @@ -804,7 +747,6 @@ export function applyMakaSessionEventToTranscript( case 'abort': clearPendingInteractions(state); - state.pendingShellRunPolls.clear(); state.entries.push({ kind: 'notice', level: 'info', @@ -1605,6 +1547,14 @@ function findToolEntry( ); } +function revealToolAtTail(state: MakaPiTranscriptState, tool: MakaPiToolEntry): void { + tool.hidden = undefined; + const index = state.entries.indexOf(tool); + if (index < 0 || index === state.entries.length - 1) return; + state.entries.splice(index, 1); + state.entries.push(tool); +} + function createProgressBuffer(): BoundedChunkBuffer { return new BoundedChunkBuffer({ maxChars: LIVE_TOOL_BUFFER_MAX_CHARS, From b2e89b18daf0fd2210ff4c4460d347d04f3cb8ec Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:44:15 +0800 Subject: [PATCH 05/13] refactor(cli): delete runtime transcript materializer Generated-by: Codex --- packages/cli/src/pi-transcript.ts | 141 +++++--- packages/runtime/package.json | 3 +- .../src/__tests__/ai-sdk-backend.test.ts | 17 +- .../src/__tests__/materializer.test.ts | 330 ------------------ .../runtime-event-read-model.test.ts | 1 - packages/runtime/src/materializer.ts | 298 ---------------- 6 files changed, 90 insertions(+), 700 deletions(-) delete mode 100644 packages/runtime/src/__tests__/materializer.test.ts delete mode 100644 packages/runtime/src/materializer.ts diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index f4d1985094..b59107d881 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -27,6 +27,7 @@ import type { ToolResultContent, } from '@maka/core/events'; import { + deriveTurnRecords, STEP_LIMIT_NOTICE_TEXT, type StoredMessage, type SystemNoteMessage, @@ -37,13 +38,13 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { isActiveShellRunStatus } from '@maka/core/shell-run'; import { mergeShellRunStateWithDiagnostics } from '@maka/core/shell-run-result'; import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; +import { + type ToolActivityStatus, + toolResultActivityStatus, + unfinishedToolActivityStatus, +} from '@maka/core/tool-result-status'; import { type ShellRunUpdate } from '@maka/core/events'; import { homedir } from 'node:os'; -import { - materializeSession, - type ChatItem, - type ToolActivityItem, -} from '@maka/runtime/materializer'; import type { MakaSessionDriver } from './session-driver.js'; import { BoundedChunkBuffer } from './bounded-chunk-buffer.js'; import { ansi } from './tui-ansi.js'; @@ -322,8 +323,7 @@ export function replaceTranscriptWithStoredMessages( state: MakaPiTranscriptState, messages: readonly StoredMessage[], ): void { - const view = materializeSession(messages); - state.entries = foldStoredShellRunChildren(view.items.flatMap(chatItemToTranscriptEntries)); + state.entries = foldStoredShellRunChildren(storedMessagesToTranscriptEntries(messages)); clearPendingInteractions(state); state.expandAllTools = false; state.expandAllThinking = false; @@ -356,9 +356,7 @@ export function reconcileToolsWithStoredMessages( ): boolean { const turnMessages = messages.filter((message) => message.turnId === turnId); const durableTools = new Map( - foldStoredShellRunChildren( - materializeSession(turnMessages).items.flatMap(chatItemToTranscriptEntries), - ) + foldStoredShellRunChildren(storedMessagesToTranscriptEntries(turnMessages)) .filter( (entry): entry is Extract => entry.kind === 'tool', ) @@ -771,71 +769,106 @@ export function applyMakaSessionEventToTranscript( } } -function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { - switch (item.kind) { - case 'user': - return [ - { +function storedMessagesToTranscriptEntries( + messages: readonly StoredMessage[], +): MakaPiTranscriptEntry[] { + const entries: MakaPiTranscriptEntry[] = []; + const resultsByToolUseId = new Map( + messages + .filter( + (message): message is Extract => + message.type === 'tool_result', + ) + .map((message) => [message.toolUseId, message]), + ); + const turnStatusById = new Map( + deriveTurnRecords(messages).map((turn) => [turn.turnId, turn.status]), + ); + + for (const message of messages) { + switch (message.type) { + case 'user': + entries.push({ kind: - item.message.origin?.kind === 'legacy_automation' + message.origin?.kind === 'legacy_automation' ? 'legacy_automation' - : item.message.origin?.kind === 'goal' + : message.origin?.kind === 'goal' ? 'goal_continuation' : 'user', - text: item.message.displayText ?? item.message.text, - }, - ]; - case 'assistant': { - const entries: MakaPiTranscriptEntry[] = []; - // Stored thinking happened before the reply text, so it resumes above it. - const thinking = item.message.thinking?.text; - if (thinking?.trim()) { - // Replay resets the expansion defaults to collapsed, so replayed - // entries start collapsed too. - entries.push({ - kind: 'thinking', - messageId: item.message.id, - text: thinking, - expanded: false, + text: message.displayText ?? message.text, }); + break; + case 'assistant': { + // Stored thinking happened before the reply text, so it resumes above it. + const thinking = message.thinking?.text; + if (thinking?.trim()) { + entries.push({ + kind: 'thinking', + messageId: message.id, + text: thinking, + expanded: false, + }); + } + entries.push({ kind: 'assistant', messageId: message.id, text: message.text }); + break; } - entries.push({ kind: 'assistant', messageId: item.message.id, text: item.message.text }); - return entries; - } - case 'tool': - return [toolActivityToTranscriptEntry(item.item)]; - case 'system_note': { - const entry = systemNoteToTranscriptEntry(item.message); - return entry ? [entry] : []; + case 'tool_call': + entries.push( + storedToolToTranscriptEntry( + message, + resultsByToolUseId.get(message.id), + turnStatusById.get(message.turnId), + ), + ); + break; + case 'system_note': { + const entry = systemNoteToTranscriptEntry(message); + if (entry) entries.push(entry); + break; + } + case 'tool_result': + case 'permission_decision': + case 'token_usage': + case 'turn_state': + break; } } + return entries; } -function toolActivityToTranscriptEntry(item: ToolActivityItem): MakaPiToolEntry { +function storedToolToTranscriptEntry( + call: Extract, + result: Extract | undefined, + turnStatus: ReturnType[number]['status'] | undefined, +): MakaPiToolEntry { const entry: MakaPiToolEntry = { kind: 'tool', - toolUseId: item.toolUseId, - toolName: item.toolName, - ...(item.displayName ? { title: item.displayName } : {}), - input: item.args, + toolUseId: call.id, + toolName: call.toolName, + ...(call.displayName ? { title: call.displayName } : {}), + input: projectToolActivityArgs(call.toolName, call.args), progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - ...(item.result ? { result: item.result } : {}), - resultVersion: item.result ? 1 : 0, - ...(item.durationMs !== undefined ? { durationMs: item.durationMs } : {}), - status: transcriptToolStatus(item.status), + ...(result ? { result: result.content } : {}), + resultVersion: result ? 1 : 0, + ...(result?.durationMs !== undefined ? { durationMs: result.durationMs } : {}), + status: transcriptToolStatus( + result + ? toolResultActivityStatus(result.isError, result.content) + : unfinishedToolActivityStatus(turnStatus), + ), expanded: false, }; - if (item.result?.kind === 'subagent') { - entry.status = subagentTranscriptStatus(item.result.status); + if (result?.content.kind === 'subagent') { + entry.status = subagentTranscriptStatus(result.content.status); } // A failed call keeps its error status and raw payload: applying the shell_run // as the card's own result would let a still-running or settled payload // overwrite the error and swallow the failure on replay. This mirrors the live // tool_result path, which forces `error` for any errored shell_run result, and // is what lets the stored fold below recognize an errored poll by its status. - if (item.result?.kind === 'shell_run' && !item.isError) - applyOwnShellRunResult(entry, item.result); + if (result?.content.kind === 'shell_run' && !result.isError) + applyOwnShellRunResult(entry, result.content); return entry; } @@ -866,7 +899,7 @@ function foldStoredShellRunChildren(entries: MakaPiTranscriptEntry[]): MakaPiTra return folded; } -function transcriptToolStatus(status: ToolActivityItem['status']): MakaPiToolEntry['status'] { +function transcriptToolStatus(status: ToolActivityStatus): MakaPiToolEntry['status'] { switch (status) { case 'completed': return 'done'; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 79915e1a11..d46eb5d51e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -2,7 +2,7 @@ "name": "@maka/runtime", "version": "0.1.0", "license": "Apache-2.0", - "description": "SessionManager, session sandbox boundaries, AiSdkBackend, materializer.", + "description": "SessionManager, session sandbox boundaries, and AiSdkBackend.", "type": "module", "private": true, "exports": { @@ -19,7 +19,6 @@ "./context-budget": "./dist/context-budget.js", "./test-connection": "./dist/test-connection.js", "./model-fetcher": "./dist/model-fetcher.js", - "./materializer": "./dist/materializer.js", "./session-manager": "./dist/session-manager.js", "./test-only/fake-backend": "./dist/test-only/fake-backend.js", "./test-only/observation-text-reader": "./dist/__tests__/observation-text-reader.js", diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 98844d2eef..799e73ba62 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -35,7 +35,6 @@ import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent } from '../ai-sdk-flow.js'; import { projectRuntimeEventsToStoredMessages } from '../runtime-event-read-model.js'; -import { materializeSession } from '../materializer.js'; import type { InvocationContext } from '../invocation-context.js'; import type { AssistantMessage, StoredMessage, ToolResultMessage } from '@maka/core/session'; import { z } from 'zod'; @@ -10674,13 +10673,6 @@ describe('AiSdkBackend thinking persistence', () => { assert.equal(assistant.text, 'Final answer.'); assert.equal(assistant.thinking?.text, 'Let me reason.'); assert.equal(assistant.thinking?.signature, 'sig-123'); - - // materializeSession (session reload) surfaces the reconstructed thinking. - const viewModel = materializeSession(projection.messages); - const assistantItem = viewModel.items.find((item) => item.kind === 'assistant'); - assert.ok(assistantItem && assistantItem.kind === 'assistant'); - assert.equal(assistantItem.message.thinking?.text, 'Let me reason.'); - assert.equal(assistantItem.message.thinking?.signature, 'sig-123'); }); test('persists reasoning for a thinking-only turn that produces no final text', async () => { @@ -10742,8 +10734,8 @@ describe('AiSdkBackend thinking persistence', () => { assert.equal(assistantMessage.text, ''); assert.equal(assistantMessage.thinking?.text, 'silent thought'); - // Full chain: RuntimeEvent projection + materialize keep the reasoning on an - // empty-text assistant row without crashing. + // Full chain: RuntimeEvent projection keeps the reasoning on an empty-text + // assistant row without crashing. const ctx = { sessionId: 'session-1', invocationId: 'inv-1', @@ -10774,11 +10766,6 @@ describe('AiSdkBackend thinking persistence', () => { assert.ok(assistant && assistant.type === 'assistant'); assert.equal(assistant.text, ''); assert.equal(assistant.thinking?.text, 'silent thought'); - - const viewModel = materializeSession(projection.messages); - const assistantItem = viewModel.items.find((item) => item.kind === 'assistant'); - assert.ok(assistantItem && assistantItem.kind === 'assistant'); - assert.equal(assistantItem.message.thinking?.text, 'silent thought'); }); test('text-only terminal replay fixture preserves signed thinking and usage exactly', async () => { diff --git a/packages/runtime/src/__tests__/materializer.test.ts b/packages/runtime/src/__tests__/materializer.test.ts deleted file mode 100644 index 2d94918ece..0000000000 --- a/packages/runtime/src/__tests__/materializer.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * Tests for materializer. - * - * Run: `bun test packages/runtime/src/__tests__/materializer.test.ts` - */ - -import { describe, test } from 'node:test'; -import { expect } from '../test-helpers.js'; -import type { - UserMessage, - AssistantMessage, - ToolCallMessage, - ToolResultMessage, - PermissionDecisionMessage, - TokenUsageMessage, - SystemNoteMessage, -} from '@maka/core/session'; -import { - materializeSession, - applyAppendedMessage, - setToolStatus, - type ChatItem, -} from '../materializer.js'; - -// ---------- Fixtures ---------- - -const ts = 1_700_000_000_000; -const turnId = 't1'; - -const user = (id: string, text: string): UserMessage => ({ - type: 'user', - id, - turnId, - ts: ts + 1, - text, -}); - -const assistant = (id: string, text: string): AssistantMessage => ({ - type: 'assistant', - id, - turnId, - ts: ts + 2, - text, - modelId: 'claude-sonnet-4-5', -}); - -const toolCall = (id: string, name: string, args: unknown = {}): ToolCallMessage => ({ - type: 'tool_call', - id, - turnId, - ts: ts + 3, - toolName: name, - args, -}); - -const toolResult = (toolUseId: string, isError: boolean, text: string): ToolResultMessage => ({ - type: 'tool_result', - id: `r-${toolUseId}`, - turnId, - ts: ts + 4, - toolUseId, - isError, - content: { kind: 'text', text }, -}); - -const permission = ( - requestId: string, - toolUseId: string, - decision: 'allow' | 'deny', -): PermissionDecisionMessage => ({ - type: 'permission_decision', - id: requestId, - turnId, - ts: ts + 3, - toolUseId, - toolName: 'Write', - decision, -}); - -const tokens = (input: number, output: number, costUsd?: number): TokenUsageMessage => ({ - type: 'token_usage', - id: 'tu', - turnId, - ts: ts + 5, - input, - output, - ...(costUsd !== undefined ? { costUsd } : {}), -}); - -const note = (kind: SystemNoteMessage['kind']): SystemNoteMessage => ({ - type: 'system_note', - id: 'n', - ts: ts + 6, - kind, -}); - -// ---------- materializeSession ---------- - -describe('materializeSession', () => { - test('errored tool: result with isError=true → status errored', () => { - const vm = materializeSession([ - toolCall('t-2', 'Write'), - toolResult('t-2', true, 'Permission denied'), - ]); - expect(vm.items).toHaveLength(1); - const item = vm.items[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.item.status).toBe('errored'); - expect(item.item.isError).toBe(true); - }); - - test('cancelled terminal with isError=true → status interrupted', () => { - const cancelled: ToolResultMessage = { - type: 'tool_result', - id: 'r-cancel', - turnId, - ts: ts + 4, - toolUseId: 't-cancel', - isError: true, - content: { - kind: 'terminal', - cwd: '/repo', - cmd: 'sleep 99', - status: 'cancelled', - exitCode: 130, - output: { - mode: 'pipes', - stdout: '', - stderr: '', - stdoutTruncated: false, - stderrTruncated: false, - redacted: false, - }, - }, - }; - const vm = materializeSession([toolCall('t-cancel', 'Bash'), cancelled]); - const item = vm.items[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.item.status).toBe('interrupted'); - - const live = applyAppendedMessage( - applyAppendedMessage([], toolCall('t-cancel', 'Bash')).items, - cancelled, - ); - const liveItem = live.items[0]; - if (liveItem?.kind !== 'tool') throw new Error('wrong kind'); - expect(liveItem.item.status).toBe('interrupted'); - }); - - test('successful shell_run cancelled observation stays completed', () => { - // StopBackgroundTask returns isError:false + shell_run.status cancelled — - // the stop call succeeded; do not map to interrupted/error. - const observed: ToolResultMessage = { - type: 'tool_result', - id: 'r-stop', - turnId, - ts: ts + 4, - toolUseId: 't-stop', - isError: false, - content: { - kind: 'shell_run', - ref: 'maka://runtime/background-tasks/bg', - mode: 'pipes', - status: 'cancelled', - cwd: '/repo', - cmd: 'sleep 99', - startedAt: 1, - updatedAt: 2, - exitCode: 130, - revision: 2, - output: { - mode: 'pipes', - stdout: '', - stderr: '', - stdoutTruncated: false, - stderrTruncated: false, - redacted: false, - }, - operation: { kind: 'stop', applied: true }, - }, - }; - const vm = materializeSession([toolCall('t-stop', 'StopBackgroundTask'), observed]); - const item = vm.items[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.item.status).toBe('completed'); - }); - - test('orphan tool_call (no matching result, no turn record) → interrupted', () => { - const vm = materializeSession([toolCall('t-orphan', 'Bash')]); - expect(vm.items).toHaveLength(1); - const item = vm.items[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.item.status).toBe('interrupted'); - expect(item.item.result).toBeUndefined(); - }); - - // A missing result is the absence of evidence, not evidence of a terminal - // state. Only a turn that has itself ended makes it mean "never finished" — - // while the turn runs, so does the call. - for (const [turnStatus, expected] of [ - ['running', 'running'], - ['completed', 'interrupted'], - ] as const) { - test(`resultless tool_call in a ${turnStatus} turn → ${expected}`, () => { - const vm = materializeSession([ - { - type: 'turn_state', - id: `state-${turnStatus}`, - turnId, - ts, - status: turnStatus, - partialOutputRetained: false, - }, - toolCall('t-inflight', 'Bash'), - ]); - const item = vm.items[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.item.status).toBe(expected); - }); - } - - test('permission decision folded into tool ChatItem', () => { - const vm = materializeSession([ - toolCall('t-3', 'Write'), - permission('req-1', 't-3', 'allow'), - toolResult('t-3', false, 'ok'), - ]); - expect(vm.items).toHaveLength(1); - const item = vm.items[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.decision?.decision).toBe('allow'); - expect(item.decision?.id).toBe('req-1'); - }); - - test('mixed full conversation', () => { - const vm = materializeSession([ - note('session_start'), - user('u1', 'do X'), - toolCall('t-a', 'Read'), - toolResult('t-a', false, 'data'), - assistant('a1', 'Done.'), - tokens(50, 20), - ]); - expect(vm.items.map((i) => i.kind)).toEqual(['system_note', 'user', 'tool', 'assistant']); - expect(vm.totalTokens.input).toBe(50); - expect(vm.totalTokens.output).toBe(20); - }); -}); - -// ---------- applyAppendedMessage ---------- - -describe('applyAppendedMessage', () => { - test('preserves a semantic activity kind during reload and live append', () => { - const call = { ...toolCall('t', 'custom_shell'), activityKind: 'command' as const }; - const reloaded = materializeSession([call]); - const appended = applyAppendedMessage([], call); - - const reloadedItem = reloaded.items[0]; - const appendedItem = appended.items[0]; - if (reloadedItem?.kind !== 'tool' || appendedItem?.kind !== 'tool') - throw new Error('wrong kind'); - expect(reloadedItem.item.activityKind).toBe('command'); - expect(appendedItem.item.activityKind).toBe('command'); - }); - - test('append tool_result with isError=true → status errored', () => { - const items = applyAppendedMessage([], toolCall('t', 'Write')).items; - const next = applyAppendedMessage(items, toolResult('t', true, 'denied')); - const item = next.items[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.item.status).toBe('errored'); - expect(item.item.isError).toBe(true); - }); - - test('append tool_result for unknown toolUseId → no-op (list unchanged)', () => { - const items = applyAppendedMessage([], user('u', 'hello')).items; - const next = applyAppendedMessage(items, toolResult('nonexistent', false, 'x')); - expect(next.items).toEqual(items); - }); - - test('append permission_decision → patches tool item.decision', () => { - const items = applyAppendedMessage([], toolCall('t', 'Write')).items; - const next = applyAppendedMessage(items, permission('req', 't', 'deny')); - const item = next.items[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.decision?.decision).toBe('deny'); - }); -}); - -// ---------- setToolStatus (renderer idempotent merge per §10) ---------- - -describe('setToolStatus', () => { - test('updates by toolUseId without duplicating', () => { - const items = applyAppendedMessage([], toolCall('t', 'Read')).items; - // Two distinct hops off `pending`, so this stays a transition test rather - // than a second copy of the idempotence test below. - const stage1 = setToolStatus(items, 't', { status: 'running' }); - const stage2 = setToolStatus(stage1, 't', { status: 'completed' }); - expect(stage2).toHaveLength(1); - const item = stage2[0]; - if (item?.kind !== 'tool') throw new Error('wrong kind'); - expect(item.item.status).toBe('completed'); - }); - - test('idempotent on duplicate updates', () => { - const items = applyAppendedMessage([], toolCall('t', 'Read')).items; - const once = setToolStatus(items, 't', { status: 'running' }); - const twice = setToolStatus(once, 't', { status: 'running' }); - expect(twice).toEqual(once); - }); -}); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index ca7f35a795..6eec132f84 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -33,7 +33,6 @@ import { projectRuntimeEventsToStoredMessagesWithArchiveStatuses, } from '../runtime-event-read-model.js'; import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; -import { materializeSession } from '../materializer.js'; import { BackendRegistry, SessionManager, type SessionStore } from '../session-manager.js'; const ts = 1_800_000_000_000; diff --git a/packages/runtime/src/materializer.ts b/packages/runtime/src/materializer.ts deleted file mode 100644 index 196b4af7d9..0000000000 --- a/packages/runtime/src/materializer.ts +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * Materializer — converts durable messages into the view-model shape the UI - * renders. Lives in `runtime` (not `storage`) because correlation is a semantic - * operation, not a disk concern. - * - * Runtime/UI materializer for rebuilding chat and tool activity state from - * append-only stored messages. - */ - -import type { - StoredMessage, - UserMessage, - AssistantMessage, - ToolCallMessage, - ToolResultMessage, - PermissionDecisionMessage, - TokenUsageMessage, - SystemNoteMessage, - TurnStatus, -} from '@maka/core/session'; - -import type { ToolActivityKind, ToolResultContent } from '@maka/core/events'; - -import type { ToolActivityStatus } from '@maka/core/tool-result-status'; -import { deriveTurnRecords } from '@maka/core/session'; -import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; -import { - toolResultActivityStatus, - unfinishedToolActivityStatus, -} from '@maka/core/tool-result-status'; - -// ============================================================================ -// View-model types (mirror packages/ui/src exports, lifted here for reuse) -// ============================================================================ - -export interface ToolActivityItem { - toolUseId: string; - toolName: string; - activityKind?: ToolActivityKind; - displayName?: string; - intent?: string; - status: ToolActivityStatus; - args: unknown; - result?: ToolResultContent; - isError?: boolean; - durationMs?: number; - ts: number; -} - -export type ChatItem = - | { kind: 'user'; message: UserMessage } - | { kind: 'assistant'; message: AssistantMessage } - | { kind: 'tool'; item: ToolActivityItem; decision?: PermissionDecisionMessage } - | { kind: 'system_note'; message: SystemNoteMessage }; - -export interface SessionViewModel { - items: ChatItem[]; - totalTokens: { input: number; output: number; costUsd?: number }; -} - -// ============================================================================ -// Materialize one shot (used on session reload) -// ============================================================================ - -/** - * Convert StoredMessage[] into a ChatItem[] for rendering. - * - * A ToolCallMessage with no matching ToolResultMessage is read against its - * turn: still `running` means the call is in flight, anything else makes it - * `interrupted`. Storage never synthesizes a fake ToolResultMessage — that's - * our job here. - */ -export function materializeSession(messages: readonly StoredMessage[]): SessionViewModel { - const items: ChatItem[] = []; - let totalInput = 0; - let totalOutput = 0; - let totalCostUsd = 0; - let hasCost = false; - - // Index for tool correlation. ToolCallMessage.id === toolUseId, by §4.2. - const resultsByToolUseId = new Map(); - const decisionsByToolUseId = new Map(); - const turnStatusById = new Map( - deriveTurnRecords(messages).map((turn) => [turn.turnId, turn.status]), - ); - - // First pass: index results + decisions. - for (const m of messages) { - if (m.type === 'tool_result') { - resultsByToolUseId.set(m.toolUseId, m); - } else if (m.type === 'permission_decision') { - decisionsByToolUseId.set(m.toolUseId, m); - } - } - - // Second pass: emit ChatItems in document order, skipping tool_result + decision - // (they're folded into the tool ChatItem at the call site). - for (const m of messages) { - switch (m.type) { - case 'user': - items.push({ kind: 'user', message: m }); - break; - case 'assistant': - items.push({ kind: 'assistant', message: m }); - break; - case 'tool_call': { - const result = resultsByToolUseId.get(m.id); - const decision = decisionsByToolUseId.get(m.id); - items.push({ - kind: 'tool', - item: toolActivityFromPair(m, result, turnStatusById.get(m.turnId)), - decision, - }); - break; - } - case 'tool_result': - case 'permission_decision': - // folded into tool ChatItem above; skip here - break; - case 'token_usage': - totalInput += m.input; - totalOutput += m.output; - if (typeof m.costUsd === 'number') { - totalCostUsd += m.costUsd; - hasCost = true; - } - break; - case 'system_note': - items.push({ kind: 'system_note', message: m }); - break; - } - } - - return { - items, - totalTokens: { - input: totalInput, - output: totalOutput, - ...(hasCost ? { costUsd: totalCostUsd } : {}), - }, - }; -} - -/** - * Build a ToolActivityItem from a (ToolCallMessage, ToolResultMessage?) pair. - * - * - Missing result → `unfinishedToolActivityStatus` reads it against the turn - * - Cancelled shell / aborted explore → 'interrupted' (not failure) - * - Result with isError === true → 'errored' (includes permission deny/block) - * - Result with isError === false → 'completed' - */ -function toolActivityFromPair( - call: ToolCallMessage, - result: ToolResultMessage | undefined, - turnStatus: TurnStatus | undefined, -): ToolActivityItem { - if (result === undefined) { - return { - toolUseId: call.id, - toolName: call.toolName, - ...(call.activityKind !== undefined ? { activityKind: call.activityKind } : {}), - ...(call.displayName !== undefined ? { displayName: call.displayName } : {}), - ...(call.intent !== undefined ? { intent: call.intent } : {}), - status: unfinishedToolActivityStatus(turnStatus), - args: projectToolActivityArgs(call.toolName, call.args), - ts: call.ts, - }; - } - return { - toolUseId: call.id, - toolName: call.toolName, - ...(call.activityKind !== undefined ? { activityKind: call.activityKind } : {}), - ...(call.displayName !== undefined ? { displayName: call.displayName } : {}), - ...(call.intent !== undefined ? { intent: call.intent } : {}), - status: toolResultActivityStatus(result.isError, result.content), - args: projectToolActivityArgs(call.toolName, call.args), - result: result.content, - isError: result.isError, - ...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}), - ts: call.ts, - }; -} - -// ============================================================================ -// Streaming patch for live updates (used during an active turn) -// ============================================================================ - -/** - * Apply a single newly-appended StoredMessage to an existing ChatItem[]. - * Used by the renderer to incrementally update the view as events / writes - * land, without re-materializing the whole session. - * - * Returns the new items array (immutable update) and an optional patch hint - * indicating which existing item id was modified. - */ -export function applyAppendedMessage( - items: readonly ChatItem[], - message: StoredMessage, -): { items: ChatItem[]; modifiedToolUseId?: string } { - switch (message.type) { - case 'user': - return { items: [...items, { kind: 'user', message }] }; - - case 'assistant': - return { items: [...items, { kind: 'assistant', message }] }; - - case 'tool_call': { - const item: ToolActivityItem = { - toolUseId: message.id, - toolName: message.toolName, - ...(message.activityKind !== undefined ? { activityKind: message.activityKind } : {}), - ...(message.displayName !== undefined ? { displayName: message.displayName } : {}), - ...(message.intent !== undefined ? { intent: message.intent } : {}), - status: 'pending', - args: projectToolActivityArgs(message.toolName, message.args), - ts: message.ts, - }; - return { items: [...items, { kind: 'tool', item }] }; - } - - case 'tool_result': { - // Patch the matching tool ChatItem in place by toolUseId. - const next = items.map((it) => { - if (it.kind !== 'tool' || it.item.toolUseId !== message.toolUseId) return it; - return { - ...it, - item: { - ...it.item, - status: toolResultActivityStatus(message.isError, message.content), - result: message.content, - isError: message.isError, - ...(message.durationMs !== undefined ? { durationMs: message.durationMs } : {}), - }, - }; - }); - return { items: next, modifiedToolUseId: message.toolUseId }; - } - - case 'permission_decision': { - const next = items.map((it) => { - if (it.kind !== 'tool' || it.item.toolUseId !== message.toolUseId) return it; - return { ...it, decision: message }; - }); - return { items: next, modifiedToolUseId: message.toolUseId }; - } - - case 'system_note': - return { items: [...items, { kind: 'system_note', message }] }; - - case 'token_usage': - // No item to render; UI aggregates separately. - return { items: [...items] }; - - case 'turn_state': - // Turn metadata feeds the higher-level TurnViewModel projection; the - // incremental ChatItem stream has no standalone row for it. - return { items: [...items] }; - } -} - -// ============================================================================ -// Event-driven UI status transitions (used during streaming) -// ============================================================================ - -/** - * Renderer helpers for transitioning ToolActivityItem.status based on - * SessionEvent stream (NOT JSONL replay). Idempotent by toolUseId per §10 - * implementation notes — multiple events for the same id are merged. - */ -export function setToolStatus( - items: readonly ChatItem[], - toolUseId: string, - patch: Partial>, -): ChatItem[] { - return items.map((it) => { - if (it.kind !== 'tool' || it.item.toolUseId !== toolUseId) return it; - return { ...it, item: { ...it.item, ...patch } }; - }); -} From 848f1b4a6c8931e6361116918e2b9d5741ae2bd7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:45:33 +0800 Subject: [PATCH 06/13] refactor(cli): use projector snapshot authority Generated-by: Codex --- .../cli/src/runtime-host-session-channel.ts | 44 ++++++------------- packages/runtime-host/src/adapter/index.ts | 1 + 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 801fbee163..bb46fdda0f 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -23,6 +23,7 @@ import { createRuntimeHostSessionProjectionSeed, RuntimeHostSessionProjector, isRuntimeHostTerminalTurn as isTerminalTurn, + sameRuntimeHostTerminalTurn, type RuntimeHostTerminalTurn as TerminalTurnSnapshot, } from '@maka/runtime-host/adapter'; import { @@ -80,7 +81,6 @@ export interface RuntimeHostSessionChannelOptions { export class RuntimeHostSessionChannel { readonly sessionId: string; readonly messages: StoredMessage[]; - snapshot: SessionContinuitySnapshot; readonly #connection: Pick; #subscription: RuntimeHostSessionSubscription; readonly #now: () => number; @@ -121,7 +121,6 @@ export class RuntimeHostSessionChannel { this.#connection = connection; this.#subscription = subscription; this.sessionId = subscription.snapshot.session.sessionId; - this.snapshot = structuredClone(subscription.snapshot); this.messages = messages; this.#now = options.now; this.#onTurnStarted = options.onTurnStarted; @@ -178,14 +177,7 @@ export class RuntimeHostSessionChannel { } return true; } - this.messages.push(...(messages ?? []).map((message) => structuredClone(message))); - this.#projector = new RuntimeHostSessionProjector( - this.snapshot, - createRuntimeHostSessionProjectionSeed(this.messages, this.snapshot), - this.#now, - subscription.activeAssistantStreams, - ); - for (const event of this.#projector.seedActive(false)) this.#emit(event); + this.#acceptCanonicalReplacement(messages ?? []); this.#ready = true; try { for (const frame of this.#pendingFrames.splice(0)) this.#accept(frame); @@ -216,6 +208,10 @@ export class RuntimeHostSessionChannel { return this.#failure !== undefined; } + get snapshot(): SessionContinuitySnapshot { + return this.#projector?.snapshot ?? this.#subscription.snapshot; + } + get firstObservedTurnId(): string | undefined { return this.#pendingStartedTurns.keys().next().value; } @@ -430,7 +426,6 @@ export class RuntimeHostSessionChannel { this.messages.length, ...messages.map((message) => structuredClone(message)), ); - this.snapshot = nextSnapshot; if (!sameGoalProjection(previousSnapshot.goal, nextSnapshot.goal)) { this.#onGoalChanged(nextSnapshot.goal === null ? null : structuredClone(nextSnapshot.goal)); } @@ -506,7 +501,7 @@ export class RuntimeHostSessionChannel { if (this.#activated && !this.#startedTurnBarrier) this.#onTurnStarted(turn); else this.#pendingStartedTurns.set(turn.turnId, turn); } - } else if (root && isTerminalTurn(root) && !sameTerminalTurn(previousRoot, root)) { + } else if (root && isTerminalTurn(root) && !sameRuntimeHostTerminalTurn(previousRoot, root)) { for (const event of this.#projector.seedTerminal(root)) this.#emit(event); this.#queue(root.turnId).finish(); if (this.#activated) this.#onTurnTerminal(root); @@ -589,20 +584,21 @@ export class RuntimeHostSessionChannel { this.#fail(new Error(`Runtime Host Session subscription closed: ${frame.reason}`)); return; } + const previousSnapshot = this.snapshot; const previousPendingIds = new Set( - this.snapshot.interactions.pending.map((interaction) => interaction.interactionId), + previousSnapshot.interactions.pending.map((interaction) => interaction.interactionId), ); - const previousGoal = this.snapshot.goal; + const previousGoal = previousSnapshot.goal; const update = this.#projector?.accept(frame); if (!update || !this.#projector) return; - this.snapshot = this.#projector.snapshot; - if (!sameGoalProjection(previousGoal, this.snapshot.goal)) { + const snapshot = this.#projector.snapshot; + if (!sameGoalProjection(previousGoal, snapshot.goal)) { // Clone like the canonical-replacement path above: listeners receive // their own copy, so a mutating listener cannot corrupt the live // snapshot regardless of which path delivered the change. - this.#onGoalChanged(this.snapshot.goal === null ? null : structuredClone(this.snapshot.goal)); + this.#onGoalChanged(snapshot.goal === null ? null : structuredClone(snapshot.goal)); } - for (const interaction of this.snapshot.interactions.pending) { + for (const interaction of snapshot.interactions.pending) { if (previousPendingIds.has(interaction.interactionId)) continue; const pending = structuredClone(interaction); if (this.#activated) this.#onInteractionPending(pending); @@ -820,18 +816,6 @@ function isTurnTerminalOutcome(event: SessionEvent): boolean { return event.type === 'complete' || event.type === 'abort' || event.type === 'error'; } -function sameTerminalTurn( - previous: SessionContinuitySnapshot['rootTurn'], - next: TerminalTurnSnapshot, -): boolean { - return ( - previous !== null && - isTerminalTurn(previous) && - previous.runId === next.runId && - previous.terminalEventId === next.terminalEventId - ); -} - /** * Goal identity + revision: GoalManager.commit bumps the revision on every * accepted transition, so this pair detects every set/settle/pause/resume/ diff --git a/packages/runtime-host/src/adapter/index.ts b/packages/runtime-host/src/adapter/index.ts index a5abd63b5b..c7d7c0cfa5 100644 --- a/packages/runtime-host/src/adapter/index.ts +++ b/packages/runtime-host/src/adapter/index.ts @@ -21,6 +21,7 @@ export { createRuntimeHostSessionProjectionSeed, RuntimeHostSessionProjector, isRuntimeHostTerminalTurn, + sameRuntimeHostTerminalTurn, foldRuntimeHostAssistantDelta, projectRuntimeHostInteractionRequest, type RuntimeHostSessionProjectionSeed, From 1e6c2f461f56305c0327428ab8148514ede3a040 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:47:32 +0800 Subject: [PATCH 07/13] refactor(ui): remove unreachable tool states Generated-by: Codex --- .../src/main/__tests__/session-event-health.test.ts | 1 - apps/desktop/src/renderer/plan-mode-panel.tsx | 11 ----------- apps/desktop/src/shared/desktop-session-projection.ts | 2 -- packages/cli/src/pi-transcript.ts | 1 - packages/core/src/tool-result-status.ts | 10 +++------- .../runtime-host/src/adapter/session-projector.ts | 5 ++--- .../ui/src/__tests__/live-turn-projection.test.ts | 2 +- packages/ui/src/live-turn-projection.ts | 8 ++++---- packages/ui/src/tool-activity.tsx | 1 - .../ui/src/tool-activity/computer-action-label.ts | 2 +- packages/ui/stories/tool-activity.fixtures.ts | 8 -------- 11 files changed, 11 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-event-health.test.ts b/apps/desktop/src/main/__tests__/session-event-health.test.ts index 9fde04fca7..6f4f3c7973 100644 --- a/apps/desktop/src/main/__tests__/session-event-health.test.ts +++ b/apps/desktop/src/main/__tests__/session-event-health.test.ts @@ -97,7 +97,6 @@ describe('renderer session event health projection', () => { ]), false, ); - assert.equal(hasInFlightToolActivity([{ status: 'pending' }]), true); assert.equal(hasInFlightToolActivity([{ status: 'running' }]), true); }); diff --git a/apps/desktop/src/renderer/plan-mode-panel.tsx b/apps/desktop/src/renderer/plan-mode-panel.tsx index ece9b75d9c..37c5b3d850 100644 --- a/apps/desktop/src/renderer/plan-mode-panel.tsx +++ b/apps/desktop/src/renderer/plan-mode-panel.tsx @@ -71,7 +71,6 @@ export function usePlanModeState(session: SessionSummary | undefined): PlanModeS event.type === 'plan_submitted' || event.type === 'complete' || event.type === 'abort' - || isPlanToolResult(event) ) { refreshOrReport(); } @@ -337,16 +336,6 @@ export function PlanExecutionPanel(props: { ); } -function isPlanToolResult(event: SessionEvent): boolean { - if (event.type !== 'tool_result' || event.content.kind !== 'json') return false; - const value = event.content.value; - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const kind = (value as { kind?: unknown }).kind; - return kind === 'plan_progress_updated' - || kind === 'plan_execution_completed' - || kind === 'plan_execution_cancelled'; -} - function proposalStatusLabel( status: PlanProposal['status'], copy: PlanModeCopy['proposal'], diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index 654e1628e6..3bfa8ca51d 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -138,8 +138,6 @@ export function projectDesktopSessionEvent( childSessionId: projectSessionId(host, event.content.childSessionId), }, }; - case 'tool_result': - return { ...event, content: projectDesktopToolResultContent(host, event.content) }; case 'steering_message': return { ...event, content: projectMessageContent(host, event.content) }; case 'queue_update': diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index b59107d881..da79eba60f 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -907,7 +907,6 @@ function transcriptToolStatus(status: ToolActivityStatus): MakaPiToolEntry['stat return 'error'; case 'interrupted': return 'aborted'; - case 'pending': case 'running': return 'running'; } diff --git a/packages/core/src/tool-result-status.ts b/packages/core/src/tool-result-status.ts index c2210c0199..1f313bd729 100644 --- a/packages/core/src/tool-result-status.ts +++ b/packages/core/src/tool-result-status.ts @@ -26,12 +26,8 @@ import type { TurnStatus } from './session.js'; export type SettledToolActivityStatus = 'completed' | 'errored' | 'interrupted'; -/** - * A call that has not settled. `pending` is where `tool_start` opens one; it - * only reaches `running` once output arrives, so a tool that never streams - * stays `pending` for its whole life and both must read as in flight. - */ -export type InFlightToolActivityStatus = 'pending' | 'running'; +/** A call that has started and has not settled. */ +export type InFlightToolActivityStatus = 'running'; /** The whole tool-row status vocabulary, owned here so it is spelled once. */ export type ToolActivityStatus = InFlightToolActivityStatus | SettledToolActivityStatus; @@ -39,7 +35,7 @@ export type ToolActivityStatus = InFlightToolActivityStatus | SettledToolActivit export function isInFlightToolStatus( status: ToolActivityStatus, ): status is InFlightToolActivityStatus { - return status === 'pending' || status === 'running'; + return status === 'running'; } /** diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 79b9efe440..3973e93f3d 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -339,8 +339,7 @@ export class RuntimeHostSessionProjector { return emptyUpdate(events); } if (frame.kind === 'subscription.session_event') { - const event = projectToolEvent(frame); - if (event) events.push(event); + events.push(projectToolEvent(frame)); return emptyUpdate(events); } if (frame.kind !== 'subscription.session_projection') return emptyUpdate(events); @@ -479,7 +478,7 @@ export function projectRuntimeHostInteractionRequest( function projectToolEvent( frame: Extract, -): SessionEvent | undefined { +): SessionEvent { const event = frame.event; const base = { id: event.id, diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 55cdd8e5f4..7bb516425a 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -304,7 +304,7 @@ describe('applyLiveTurnEvent', () => { toolUseId: 'nested-1', toolName: 'Read', stepId: 'step-1', - status: 'pending', + status: 'running', args: { path: 'README.md' }, origin: 'code_mode', modelVisibility: 'hidden', diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index c8a6476619..727cb518bc 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -361,7 +361,7 @@ export function applyLiveTurnEvent( ...(event.intent !== undefined ? { intent: event.intent } : {}), ...projectToolActivityIdentity(event), ...(event.stepId !== undefined ? { stepId: event.stepId } : {}), - status: 'pending', + status: 'running', args: projectToolActivityArgs(event.toolName, event.args), }; const existingTool = existingToolStep?.tools.find((candidate) => candidate.toolUseId === event.toolUseId); @@ -390,7 +390,7 @@ export function applyLiveTurnEvent( const tool: ToolActivityItem = { ...base, ...projectToolActivityIdentity(event), - status: base.status === 'pending' ? 'running' : base.status, + status: base.status, outputChunks: applied.chunks, outputTruncated: base.outputTruncated || applied.truncated, }; @@ -424,7 +424,7 @@ export function applyLiveTurnEvent( const toolIndex = step.tools.findIndex((candidate) => candidate.toolUseId === event.toolUseId); const base: ToolActivityItem = toolIndex >= 0 ? step.tools[toolIndex]! - : { toolUseId: event.toolUseId, toolName: 'Tool', status: 'pending', args: undefined }; + : { toolUseId: event.toolUseId, toolName: 'Tool', status: 'running', args: undefined }; const tool: ToolActivityItem = { ...base, ...projectToolActivityIdentity(event), @@ -441,7 +441,7 @@ export function applyLiveTurnEvent( const toolIndex = step.tools.findIndex((candidate) => candidate.toolUseId === event.toolUseId); const base: ToolActivityItem = toolIndex >= 0 ? step.tools[toolIndex]! - : { toolUseId: event.toolUseId, toolName: 'Tool', status: 'pending', args: undefined }; + : { toolUseId: event.toolUseId, toolName: 'Tool', status: 'running', args: undefined }; // RH live tool_result deliberately omits content (empty text). Do not wipe // mid-flight open-facts until a meaningful result or persisted merge arrives. const retainOpenFacts = diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index 10b3dfd49f..b6e07fd543 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -551,7 +551,6 @@ function astryxToolStatus(item: ToolActivityItem): ChatToolCallItem['status'] { case 'errored': case 'interrupted': return 'error'; case 'running': return 'running'; - default: return 'pending'; } } diff --git a/packages/ui/src/tool-activity/computer-action-label.ts b/packages/ui/src/tool-activity/computer-action-label.ts index 55a3c17064..796571059e 100644 --- a/packages/ui/src/tool-activity/computer-action-label.ts +++ b/packages/ui/src/tool-activity/computer-action-label.ts @@ -136,7 +136,7 @@ export function computerRunningLabel( for (const item of items) { if (!isComputerTool(item)) continue; target = computerActionTarget(item, locale) ?? target; - if (item.status === 'pending' || item.status === 'running') active = item; + if (item.status === 'running') active = item; } if (!active) return undefined; const copy = getToolActivityCopy(locale).computer; diff --git a/packages/ui/stories/tool-activity.fixtures.ts b/packages/ui/stories/tool-activity.fixtures.ts index 3e862b160d..0aaa267760 100644 --- a/packages/ui/stories/tool-activity.fixtures.ts +++ b/packages/ui/stories/tool-activity.fixtures.ts @@ -131,14 +131,6 @@ function toolItem(item: ToolActivityItem): ToolActivityItem { } export const statusOverviewItems = [ - toolItem({ - toolUseId: 'status-pending', - toolName: 'read_file', - displayName: 'Read file', - intent: 'Open the target component before editing.', - status: 'pending', - args: { path: 'packages/ui/src/tool-activity.tsx' }, - }), toolItem({ toolUseId: 'status-long-running', toolName: 'bash', From 740b157fd9e807c8bbf522d1058fb42c6e43d4ca Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:48:47 +0800 Subject: [PATCH 08/13] refactor(cli): unify transcript settlement signals Generated-by: Codex --- packages/cli/src/runtime-host-run-command.ts | 17 ++++++------- .../cli/src/runtime-host-session-channel.ts | 25 +++++++++---------- .../cli/src/runtime-host-session-driver.ts | 4 +-- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index 18d8f0865f..9f52e0bdd0 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -256,11 +256,11 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { #closed = false; readonly #interactions: NonInteractiveInteractionController; #graphAdmissionTurnIds = new Set(); - #latestTranscriptReplacement: StoredMessage[] | undefined; + #latestTranscriptReplacement: readonly StoredMessage[] | undefined; readonly #graphTerminalWaiters = new Map< string, Set<{ - resolve(messages: StoredMessage[]): void; + resolve(messages: readonly StoredMessage[]): void; reject(error: Error): void; timer: ReturnType; }> @@ -398,7 +398,7 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { throw new Error(`Agent Graph ${terminalStatus}`); } await this.#interactions.settle(); - let messages = await this.#driver.readMessages(); + let messages: readonly StoredMessage[] = await this.#driver.readMessages(); let graphTurnId = lastNewGraphSupervisorTurnId(messages, this.#graphAdmissionTurnIds); let outcome = graphTurnId ? outcomeFromStoredTurn(messages, graphTurnId) : undefined; if (graphTurnId && !outcome) { @@ -472,14 +472,13 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { } #acceptGraphTranscript(messages: readonly StoredMessage[]): void { - const replacement = messages.map((message) => structuredClone(message)); - this.#latestTranscriptReplacement = replacement; + this.#latestTranscriptReplacement = messages; for (const [turnId, waiters] of this.#graphTerminalWaiters) { - if (!outcomeFromStoredTurn(replacement, turnId)) continue; + if (!outcomeFromStoredTurn(messages, turnId)) continue; this.#graphTerminalWaiters.delete(turnId); for (const waiter of waiters) { clearTimeout(waiter.timer); - waiter.resolve(replacement); + waiter.resolve(messages); } } } @@ -494,12 +493,12 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { active.outcome = classifierFromStoredTurn(messages, turnId, active.runId); } - #waitForGraphTurnTerminal(turnId: string): Promise { + #waitForGraphTurnTerminal(turnId: string): Promise { if (this.#closed) return Promise.reject(new Error('Runtime Host run context closed')); if (this.#stopRequested) return Promise.reject(new Error('Agent Graph wait was cancelled')); const latest = this.#latestTranscriptReplacement; if (latest && outcomeFromStoredTurn(latest, turnId)) return Promise.resolve(latest); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let waiters = this.#graphTerminalWaiters.get(turnId); if (!waiters) { waiters = new Set(); diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index bb46fdda0f..09acaaaea8 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -66,8 +66,7 @@ export interface RuntimeHostSessionChannelOptions { onRuntimeResourceChanged: (sourceSessionId: string, ref: string) => void; onInteractionPending: (pending: InteractionPendingSnapshot) => void; onInteractionResolved: (pending: InteractionPendingSnapshot) => void; - onTurnTerminal: (turn: TerminalTurnSnapshot) => void; - onToolResult?: (turnId: string) => void; + onTranscriptSettlement: (turnId: string) => void; onTranscriptReplaced: (turnId: string, messages: readonly StoredMessage[]) => void; /** * Fired when the folded session projection's goal changes (set / settle / @@ -88,8 +87,7 @@ export class RuntimeHostSessionChannel { readonly #onRuntimeResourceChanged: (sourceSessionId: string, ref: string) => void; readonly #onInteractionPending: (pending: InteractionPendingSnapshot) => void; readonly #onInteractionResolved: (pending: InteractionPendingSnapshot) => void; - readonly #onTurnTerminal: (turn: TerminalTurnSnapshot) => void; - readonly #onToolResult: ((turnId: string) => void) | undefined; + readonly #onTranscriptSettlement: (turnId: string) => void; readonly #onTranscriptReplaced: (turnId: string, messages: readonly StoredMessage[]) => void; readonly #onGoalChanged: (goal: GoalProjection | null) => void; readonly #onRecovered: () => void; @@ -98,7 +96,7 @@ export class RuntimeHostSessionChannel { readonly #pendingStartedTurns = new Map(); readonly #pendingOpenedInteractions: InteractionPendingSnapshot[] = []; readonly #pendingResolvedInteractions: InteractionPendingSnapshot[] = []; - readonly #pendingTerminalTurns: TerminalTurnSnapshot[] = []; + readonly #pendingTranscriptSettlements: string[] = []; readonly #failedSubscriptions = new WeakSet(); readonly #retiringSubscriptions = new WeakSet(); #projector: RuntimeHostSessionProjector | undefined; @@ -127,8 +125,7 @@ export class RuntimeHostSessionChannel { this.#onRuntimeResourceChanged = options.onRuntimeResourceChanged; this.#onInteractionPending = options.onInteractionPending; this.#onInteractionResolved = options.onInteractionResolved; - this.#onTurnTerminal = options.onTurnTerminal; - this.#onToolResult = options.onToolResult; + this.#onTranscriptSettlement = options.onTranscriptSettlement; this.#onTranscriptReplaced = options.onTranscriptReplaced; this.#onGoalChanged = options.onGoalChanged; this.#onRecovered = options.onRecovered; @@ -234,7 +231,9 @@ export class RuntimeHostSessionChannel { for (const interaction of this.#pendingResolvedInteractions.splice(0)) { this.#onInteractionResolved(interaction); } - for (const turn of this.#pendingTerminalTurns.splice(0)) this.#onTurnTerminal(turn); + for (const turnId of this.#pendingTranscriptSettlements.splice(0)) { + this.#onTranscriptSettlement(turnId); + } } #flushStartedTurns(): void { @@ -504,8 +503,8 @@ export class RuntimeHostSessionChannel { } else if (root && isTerminalTurn(root) && !sameRuntimeHostTerminalTurn(previousRoot, root)) { for (const event of this.#projector.seedTerminal(root)) this.#emit(event); this.#queue(root.turnId).finish(); - if (this.#activated) this.#onTurnTerminal(root); - else this.#pendingTerminalTurns.push(root); + if (this.#activated) this.#onTranscriptSettlement(root.turnId); + else this.#pendingTranscriptSettlements.push(root.turnId); } return true; } @@ -621,13 +620,13 @@ export class RuntimeHostSessionChannel { } if (update.terminalTurn) { this.#queue(update.terminalTurn.turnId).finish(); - if (this.#activated) this.#onTurnTerminal(update.terminalTurn); - else this.#pendingTerminalTurns.push(update.terminalTurn); + if (this.#activated) this.#onTranscriptSettlement(update.terminalTurn.turnId); + else this.#pendingTranscriptSettlements.push(update.terminalTurn.turnId); } } #emit(event: SessionEvent): void { - if (event.type === 'tool_result') this.#onToolResult?.(event.turnId); + if (event.type === 'tool_result') this.#onTranscriptSettlement(event.turnId); this.#queue(event.turnId).push(event); } diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 186ef02f81..ea4e233a80 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -1025,8 +1025,8 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { for (const listener of this.#pendingInteractionListeners) listener(pending); }, onInteractionResolved: (pending) => this.#resolveExternalInteraction(pending), - onTurnTerminal: (turn) => this.#refreshTranscript(sessionId, sessionGeneration, turn.turnId), - onToolResult: (turnId) => this.#refreshTranscript(sessionId, sessionGeneration, turnId), + onTranscriptSettlement: (turnId) => + this.#refreshTranscript(sessionId, sessionGeneration, turnId), onTranscriptReplaced: (turnId, messages) => this.#publishTranscriptReplacement( sessionId, From ce396ce873e5418bab59bb07e3db95849a286c78 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 16:59:47 +0800 Subject: [PATCH 09/13] refactor(cli): discard hidden polls on interruption Generated-by: Codex --- packages/cli/src/__tests__/pi-transcript.test.ts | 4 ++++ packages/cli/src/pi-transcript.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 168d490c73..caa5dfccb0 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -2409,6 +2409,10 @@ describe('Maka Pi TUI transcript', () => { const afterAbort = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); assert.doesNotMatch(afterAbort, /● Read/); + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.hidden), + false, + ); }); test('folds a background-task Read result into its parent Bash card', () => { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index da79eba60f..c786d3b85e 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -736,6 +736,7 @@ export function applyMakaSessionEventToTranscript( case 'error': clearPendingInteractions(state); + dropHiddenTools(state); state.entries.push({ kind: 'notice', level: 'error', @@ -745,6 +746,7 @@ export function applyMakaSessionEventToTranscript( case 'abort': clearPendingInteractions(state); + dropHiddenTools(state); state.entries.push({ kind: 'notice', level: 'info', @@ -1185,6 +1187,10 @@ function clearPendingInteractions(state: MakaPiTranscriptState): void { state.queuedInteractions = []; } +function dropHiddenTools(state: MakaPiTranscriptState): void { + state.entries = state.entries.filter((entry) => entry.kind !== 'tool' || !entry.hidden); +} + /** * Per-entry render cache. The transcript re-renders on every keystroke and * stream delta, but only the tail entry actually changes; caching the rendered From 70adfdfb3ea6f2116a514f1828e9e1fb38c9d3d2 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 18:55:54 +0800 Subject: [PATCH 10/13] fix(cli): preserve hidden polls during reconciliation Generated-by: Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 79 +++++++++++++++++++ packages/cli/src/pi-transcript.ts | 6 +- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index caa5dfccb0..7a4dac3d9e 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -383,6 +383,29 @@ describe('Maka Pi TUI transcript', () => { assert.equal(state.entries.at(-1)?.kind, 'notice'); }); + test('keeps an in-flight background poll hidden during settlement reconciliation', () => { + const { state, messages } = inFlightBackgroundPollFixture(); + reconcileToolsWithStoredMessages(state, 'turn-1', messages); + + const poll = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg', + ); + assert.equal(poll?.kind === 'tool' ? poll.hidden : undefined, true); + assert.equal(poll?.kind === 'tool' ? poll.status : undefined, 'running'); + }); + + test('drops a reconciled in-flight background poll on abort', () => { + const { state, messages } = inFlightBackgroundPollFixture(); + reconcileToolsWithStoredMessages(state, 'turn-1', messages); + + applyMakaSessionEventToTranscript(state, event({ type: 'abort', reason: 'user_stop' })); + + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg'), + false, + ); + }); + test('removes a live poll card that the durable transcript folds into its Bash parent', () => { const state = createMakaPiTranscriptState(); for (const tool of [ @@ -3354,6 +3377,62 @@ function event(input: { type: SessionEvent['type'] } & Record): } as SessionEvent; } +function inFlightBackgroundPollFixture(): { + state: ReturnType; + messages: StoredMessage[]; +} { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/bg-1'; + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'bash-bg', toolName: 'Bash', args: {} }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref }), + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-bg', toolName: 'Read', args: { ref } }), + ); + return { + state, + messages: [ + { + type: 'turn_state', + id: 'turn-state-1', + turnId: 'turn-1', + ts: 1, + status: 'running', + partialOutputRetained: true, + }, + { type: 'tool_call', id: 'bash-bg', turnId: 'turn-1', ts: 2, toolName: 'Bash', args: {} }, + { + type: 'tool_result', + id: 'bash-bg-result', + turnId: 'turn-1', + ts: 3, + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref }), + }, + { + type: 'tool_call', + id: 'read-bg', + turnId: 'turn-1', + ts: 4, + toolName: 'Read', + args: { ref }, + }, + ], + }; +} + function subagentResult( overrides: Partial> = {}, ): Extract { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index c786d3b85e..51c71ac000 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -384,7 +384,11 @@ export function reconcileToolsWithStoredMessages( entry.result = durable.result ? structuredClone(durable.result) : undefined; entry.durationMs = durable.durationMs; entry.status = durable.status; - entry.hidden = durable.hidden; + // An unfinished durable call has no presentation authority: hidden is + // live-only state for internal shell polls. A settled durable result may + // reveal the entry when it cannot fold into its Bash parent (for example, + // an error or a missing parent). + if (durable.result !== undefined) entry.hidden = durable.hidden; entry.resultVersion += 1; changed = true; reconciled.push(entry); From 47363af54f4f95be050d13f36a5d3a0345f3c2e6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 22:02:11 +0800 Subject: [PATCH 11/13] refactor(cli): make live tool events presentation authority --- .../cli/src/__tests__/pi-transcript.test.ts | 400 ++++++++++++++++-- packages/cli/src/pi-transcript.ts | 85 ++-- packages/cli/src/pi-tui-runner.ts | 4 +- packages/core/src/events.ts | 4 + .../src/__tests__/protocol.test.ts | 4 + .../session-continuity-coordinator.test.ts | 40 ++ .../src/__tests__/session-projector.test.ts | 68 +++ .../src/adapter/session-projector.ts | 2 + packages/runtime-host/src/protocol/index.ts | 2 +- .../src/protocol/session-continuity.ts | 5 + .../server/session-continuity-coordinator.ts | 22 +- .../__tests__/live-turn-projection.test.ts | 17 +- packages/ui/src/live-turn-projection.ts | 10 +- 13 files changed, 549 insertions(+), 114 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 7a4dac3d9e..03b7b6fe27 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -33,7 +33,7 @@ import { renderMakaPiActivityStrip, renderMakaPiStatusLine, renderMakaPiTranscript, - reconcileToolsWithStoredMessages, + hydrateToolsWithStoredMessages, replaceTranscriptWithStoredMessages, submitCompactToTranscript, toggleAllThinkingExpansion, @@ -339,7 +339,7 @@ describe('Maka Pi TUI transcript', () => { assert.equal(state.entries[0]?.kind === 'assistant' ? state.entries[0].text : undefined, ''); }); - test('reconciles durable tool details without resetting live turn state', () => { + test('hydrates durable tool details without resetting live turn state', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( state, @@ -350,7 +350,7 @@ describe('Maka Pi TUI transcript', () => { state.pendingFallback = [{ text: 'Try again', enqueue: 'steer' }]; assert.equal( - reconcileToolsWithStoredMessages(state, 'turn-1', [ + hydrateToolsWithStoredMessages(state, 'turn-1', [ { type: 'tool_call', id: 'tool-1', @@ -383,20 +383,20 @@ describe('Maka Pi TUI transcript', () => { assert.equal(state.entries.at(-1)?.kind, 'notice'); }); - test('keeps an in-flight background poll hidden during settlement reconciliation', () => { + test('keeps an in-flight background poll suppressed during durable hydration', () => { const { state, messages } = inFlightBackgroundPollFixture(); - reconcileToolsWithStoredMessages(state, 'turn-1', messages); + hydrateToolsWithStoredMessages(state, 'turn-1', messages); const poll = state.entries.find( (entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg', ); - assert.equal(poll?.kind === 'tool' ? poll.hidden : undefined, true); + assert.equal(poll?.kind === 'tool' ? poll.suppressed : undefined, true); assert.equal(poll?.kind === 'tool' ? poll.status : undefined, 'running'); }); - test('drops a reconciled in-flight background poll on abort', () => { + test('drops a hydrated in-flight background poll on abort', () => { const { state, messages } = inFlightBackgroundPollFixture(); - reconcileToolsWithStoredMessages(state, 'turn-1', messages); + hydrateToolsWithStoredMessages(state, 'turn-1', messages); applyMakaSessionEventToTranscript(state, event({ type: 'abort', reason: 'user_stop' })); @@ -406,30 +406,71 @@ describe('Maka Pi TUI transcript', () => { ); }); - test('removes a live poll card that the durable transcript folds into its Bash parent', () => { + test('drops a resultless background poll when the turn completes', () => { + const { state, messages } = inFlightBackgroundPollFixture(); + hydrateToolsWithStoredMessages(state, 'turn-1', [ + ...messages.filter((message) => message.type !== 'turn_state'), + { + type: 'turn_state', + id: 'turn-state-complete', + turnId: 'turn-1', + ts: 5, + status: 'completed', + partialOutputRetained: true, + }, + ]); + + applyMakaSessionEventToTranscript(state, event({ type: 'complete', stopReason: 'end_turn' })); + + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg'), + false, + ); + }); + + test('lets the live result own failed-poll tail placement after durable hydration', () => { const state = createMakaPiTranscriptState(); - for (const tool of [ - { toolUseId: 'bash-1', toolName: 'Bash' }, - { toolUseId: 'poll-1', toolName: 'Read' }, - ]) { - applyMakaSessionEventToTranscript(state, event({ type: 'tool_start', ...tool, args: {} })); - applyMakaSessionEventToTranscript( - state, - event({ - type: 'tool_result', - toolUseId: tool.toolUseId, - isError: false, - content: { kind: 'text', text: '' }, - }), - ); - } - const initialRun = shellRun({ ref: 'maka://runtime/session-1/run-1', revision: 1 }); - const polledRun = shellRun({ ref: 'maka://runtime/session-1/run-1', revision: 2 }); + const ref = 'maka://runtime/background-tasks/bg-1'; + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'npm test' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref, status: 'running' }), + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-bg', toolName: 'Read', args: { ref } }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'text_delta', messageId: 'assistant-late', text: 'Still working' }), + ); + + const before = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi); + const assistant = state.entries.find( + (entry) => entry.kind === 'assistant' && entry.messageId === 'assistant-late', + ); + assert.ok(assistant); + const viewportTop = state.renderGeometry.entryFirstLine?.get(assistant); + assert.ok(viewportTop !== undefined && viewportTop > 0); + state.renderGeometry.viewportTop = viewportTop; - reconcileToolsWithStoredMessages(state, 'turn-1', [ + hydrateToolsWithStoredMessages(state, 'turn-1', [ { type: 'tool_call', - id: 'bash-1', + id: 'bash-bg', turnId: 'turn-1', ts: 1, toolName: 'Bash', @@ -440,38 +481,149 @@ describe('Maka Pi TUI transcript', () => { id: 'bash-result', turnId: 'turn-1', ts: 2, - toolUseId: 'bash-1', + toolUseId: 'bash-bg', isError: false, - content: initialRun, + content: shellRun({ ref, status: 'running' }), }, { type: 'tool_call', - id: 'poll-1', + id: 'read-bg', turnId: 'turn-1', ts: 3, toolName: 'Read', - args: { ref: initialRun.ref }, + args: { ref }, }, { type: 'tool_result', - id: 'poll-result', + id: 'read-result', turnId: 'turn-1', ts: 4, - toolUseId: 'poll-1', - isError: false, - content: polledRun, + toolUseId: 'read-bg', + isError: true, + content: { kind: 'text', text: 'background task no longer exists' }, }, ]); - const tools = state.entries.filter( - (entry): entry is Extract<(typeof state.entries)[number], { kind: 'tool' }> => - entry.kind === 'tool', + const hydratedPoll = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg', ); + assert.equal(hydratedPoll?.kind === 'tool' ? hydratedPoll.suppressed : undefined, true); assert.deepEqual( - tools.map((tool) => tool.toolUseId), - ['bash-1'], + renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).slice(0, viewportTop), + before.slice(0, viewportTop), + ); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'read-bg', + isError: true, + content: { kind: 'text', text: '' }, + contentOmitted: true, + }), + ); + + const tail = state.entries.at(-1); + assert.equal(tail?.kind, 'tool'); + assert.equal(tail?.kind === 'tool' ? tail.toolUseId : undefined, 'read-bg'); + assert.deepEqual(tail?.kind === 'tool' ? tail.result : undefined, { + kind: 'text', + text: 'background task no longer exists', + }); + }); + + test('keeps a successful poll correlated until its live result folds it', () => { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/bg-1'; + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'npm test' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref, status: 'running', revision: 1 }), + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-bg', toolName: 'Read', args: { ref } }), + ); + + hydrateToolsWithStoredMessages(state, 'turn-1', [ + { + type: 'tool_call', + id: 'bash-bg', + turnId: 'turn-1', + ts: 1, + toolName: 'Bash', + args: { command: 'npm test' }, + }, + { + type: 'tool_result', + id: 'bash-result', + turnId: 'turn-1', + ts: 2, + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref, status: 'running', revision: 1 }), + }, + { + type: 'tool_call', + id: 'read-bg', + turnId: 'turn-1', + ts: 3, + toolName: 'Read', + args: { ref }, + }, + { + type: 'tool_result', + id: 'read-result', + turnId: 'turn-1', + ts: 4, + toolUseId: 'read-bg', + isError: false, + content: shellRun({ ref, status: 'running', revision: 2 }), + }, + ]); + + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg'), + true, + ); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'read-bg', + isError: false, + content: shellRun({ ref, status: 'running', revision: 2 }), + }), + ); + + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg'), + false, + ); + const parent = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'bash-bg', + ); + assert.equal( + parent?.kind === 'tool' && parent.result?.kind === 'shell_run' + ? parent.result.revision + : undefined, + 2, ); - assert.equal(tools[0]?.result?.kind === 'shell_run' ? tools[0].result.revision : undefined, 2); }); test('renders steering messages with human-facing text and falls back to model-facing text', () => { @@ -1616,6 +1768,60 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(settled, /● Read/); }); + test('folds an omitted Runtime Host poll result using its live correlation', () => { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/bg-1'; + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'npm test' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref, status: 'running' }), + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'read-bg', + toolName: 'Read', + args: undefined, + shellRunRef: ref, + }), + ); + + const poll = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg', + ); + assert.equal(poll?.kind === 'tool' ? poll.suppressed : undefined, true); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'read-bg', + isError: false, + content: { kind: 'text', text: '' }, + contentOmitted: true, + }), + ); + + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg'), + false, + ); + }); + test('surfaces an errored poll carrying shell_run content instead of folding it', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/bg-1'; @@ -1858,6 +2064,114 @@ describe('Maka Pi TUI transcript', () => { assert.match(after.slice(viewportTop).join('\n'), /● Read/); }); + test('removes a successful off-screen poll without changing scrollback', () => { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/bg-1'; + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'npm test' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref, status: 'running' }), + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'text_delta', messageId: 'assistant-middle', text: 'Still working' }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-bg', toolName: 'Read', args: { ref } }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'text_delta', messageId: 'assistant-late', text: 'More output' }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-file', toolName: 'Read', args: {} }), + ); + + const before = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi); + const visibleTail = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'read-file', + ); + assert.ok(visibleTail); + const viewportTop = state.renderGeometry.entryFirstLine?.get(visibleTail); + assert.ok(viewportTop !== undefined && viewportTop > 0); + state.renderGeometry.viewportTop = viewportTop; + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'read-bg', + isError: false, + content: shellRun({ ref, status: 'running', revision: 2 }), + }), + ); + + const after = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi); + assert.equal( + state.entries.some((entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg'), + false, + ); + assert.deepEqual(after.slice(0, viewportTop), before.slice(0, viewportTop)); + }); + + test('gives a suppressed poll zero transcript footprint', () => { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/bg-1'; + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'npm test' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ ref, status: 'running' }), + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'text_delta', messageId: 'assistant-middle', text: 'Still working' }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-bg', toolName: 'Read', args: { ref } }), + ); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-file', toolName: 'Read', args: {} }), + ); + + const before = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi); + state.entries = state.entries.filter( + (entry) => entry.kind !== 'tool' || entry.toolUseId !== 'read-bg', + ); + const after = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi); + + assert.deepEqual(after, before); + }); + test('never renders a StopBackgroundTask card while the stop is in flight', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/bg-1'; @@ -2433,7 +2747,7 @@ describe('Maka Pi TUI transcript', () => { const afterAbort = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); assert.doesNotMatch(afterAbort, /● Read/); assert.equal( - state.entries.some((entry) => entry.kind === 'tool' && entry.hidden), + state.entries.some((entry) => entry.kind === 'tool' && entry.suppressed), false, ); }); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 51c71ac000..cdc28ccc7e 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -155,7 +155,7 @@ export type MakaPiTranscriptEntry = | { kind: 'thinking'; messageId: string; text: string; expanded: boolean } | { kind: 'tool'; - /** Present for live events so terminal reconciliation is turn-scoped. */ + /** Present for live events so durable hydration is turn-scoped. */ turnId?: string; toolUseId: string; toolName: string; @@ -171,12 +171,8 @@ export type MakaPiTranscriptEntry = status: 'running' | 'done' | 'error' | 'failed' | 'aborted' | 'detached' | 'unavailable'; /** Expanded card view; stamped from expandAllTools, retargeted by Ctrl+O. */ expanded: boolean; - /** - * Set while an internal shell-run poll is in flight, or after one is - * folded into an off-screen parent. A hidden entry contributes zero - * lines, preserving terminal scrollback while retaining one tool state. - */ - hidden?: boolean; + /** An internal shell-run poll retained for correlation but not displayed. */ + suppressed?: boolean; } | { kind: 'notice'; level: 'info' | 'error'; text: string }; @@ -349,7 +345,7 @@ export function replaceTranscriptWithStoredMessages( * Fill durable tool details that are intentionally absent from Runtime Host * live events without applying session-switch reset semantics. */ -export function reconcileToolsWithStoredMessages( +export function hydrateToolsWithStoredMessages( state: MakaPiTranscriptState, turnId: string, messages: readonly StoredMessage[], @@ -363,37 +359,19 @@ export function reconcileToolsWithStoredMessages( .map((entry) => [entry.toolUseId, entry]), ); let changed = false; - const reconciled: MakaPiTranscriptEntry[] = []; for (const entry of state.entries) { - if (entry.kind !== 'tool' || entry.turnId !== turnId) { - reconciled.push(entry); - continue; - } + if (entry.kind !== 'tool' || entry.turnId !== turnId) continue; const durable = durableTools.get(entry.toolUseId); - if (!durable) { - if (!entryInLiveViewport(state, entry)) { - entry.hidden = true; - reconciled.push(entry); - } - changed = true; - continue; - } + if (!durable) continue; entry.toolName = durable.toolName; entry.title = durable.title; entry.input = structuredClone(durable.input); entry.result = durable.result ? structuredClone(durable.result) : undefined; entry.durationMs = durable.durationMs; entry.status = durable.status; - // An unfinished durable call has no presentation authority: hidden is - // live-only state for internal shell polls. A settled durable result may - // reveal the entry when it cannot fold into its Bash parent (for example, - // an error or a missing parent). - if (durable.result !== undefined) entry.hidden = durable.hidden; entry.resultVersion += 1; changed = true; - reconciled.push(entry); } - state.entries = reconciled; return changed; } @@ -562,8 +540,8 @@ export function applyMakaSessionEventToTranscript( // folds into the parent at tool_result. A poll is folded only when its // parent card already carries the run's shell_run result — otherwise it // renders normally and the tool_result fold below still applies. - const ref = readArgsRef(event.args); - const hidden = + const ref = event.shellRunRef ?? readArgsRef(event.args); + const suppressed = (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') && !!ref && !!findShellRunParent(state, ref, event.toolUseId); @@ -579,13 +557,17 @@ export function applyMakaSessionEventToTranscript( outputDeltas: createOutputBuffer(), status: 'running', expanded: state.expandAllTools, - ...(hidden ? { hidden: true } : {}), + ...(suppressed ? { suppressed: true } : {}), }); break; } case 'tool_result': { const tool = findToolEntry(state, event.toolUseId); + if (tool?.suppressed && event.contentOmitted && !event.isError) { + state.entries.splice(state.entries.indexOf(tool), 1); + break; + } const shellRun = event.content.kind === 'shell_run' ? event.content : undefined; const parent = shellRun ? findShellRunParent(state, shellRun.ref, event.toolUseId) @@ -593,25 +575,14 @@ export function applyMakaSessionEventToTranscript( if (tool && parent && shellRun && !event.isError) { applyLiveShellRunResultToParent(state, parent, shellRun); if (tool.toolName === 'Read' || tool.toolName === 'StopBackgroundTask') { - // Splicing an off-screen entry shifts subsequent entries' line - // numbers, which changes the composed buffer above the viewport and - // forces a scrollback-clearing full redraw (#1135). Leave it in - // place but mark it hidden so it contributes zero lines: a future - // full redraw (width change, session switch) will not render it as - // a duplicate card. The stale entry is fully cleaned on the next - // session switch / replaceTranscriptWithStoredMessages. - if (entryInLiveViewport(state, tool)) { - state.entries.splice(state.entries.indexOf(tool), 1); - } else { - tool.hidden = true; - } + state.entries.splice(state.entries.indexOf(tool), 1); } else { applyOwnShellRunResult(tool, shellRun, event.durationMs); } break; } if (tool) { - if (tool.hidden) revealToolAtTail(state, tool); + if (tool.suppressed) unsuppressToolAtTail(state, tool); if (shellRun) { if (tool.toolName === 'Bash') { applyShellRunResult(tool, shellRun); @@ -623,9 +594,11 @@ export function applyMakaSessionEventToTranscript( if (event.isError) tool.status = 'error'; } else { tool.status = toolResultTranscriptStatus(event.content, event.isError); - tool.result = event.content; tool.durationMs = event.durationMs; - tool.resultVersion += 1; + if (!event.contentOmitted) { + tool.result = event.content; + tool.resultVersion += 1; + } } } else { state.entries.push({ @@ -740,7 +713,7 @@ export function applyMakaSessionEventToTranscript( case 'error': clearPendingInteractions(state); - dropHiddenTools(state); + dropSuppressedTools(state); state.entries.push({ kind: 'notice', level: 'error', @@ -750,7 +723,7 @@ export function applyMakaSessionEventToTranscript( case 'abort': clearPendingInteractions(state); - dropHiddenTools(state); + dropSuppressedTools(state); state.entries.push({ kind: 'notice', level: 'info', @@ -761,6 +734,7 @@ export function applyMakaSessionEventToTranscript( case 'complete': // The turn is over; any unresolved interaction is no longer actionable. clearPendingInteractions(state); + dropSuppressedTools(state); if (event.stopReason === 'max_tokens') { state.entries.push({ kind: 'notice', @@ -1099,19 +1073,19 @@ export function renderMakaPiTranscript( const entryFirstLine = new Map(); const viewportTop = state.renderGeometry.viewportTop; + let previousVisibleEntry: MakaPiTranscriptEntry | undefined; for (let i = 0; i < state.entries.length; i += 1) { const entry = state.entries[i]!; - if (entry.kind === 'tool' && entry.hidden) { + if (entry.kind === 'tool' && entry.suppressed) { entryFirstLine.set(entry, lines.length); continue; } - const prev = state.entries[i - 1]; // A blank gap separates human-facing boundaries (user/assistant/thinking/ // notice) and the edges of a tool stack; only consecutive tool entries (the // agent-work stack) have no blank line between them. Thinking reads as // model output, so it gets the same blank-line breathing room as assistant // text rather than packing against the tool rows. - const continuesStack = entry.kind === 'tool' && prev?.kind === 'tool'; + const continuesStack = entry.kind === 'tool' && previousVisibleEntry?.kind === 'tool'; if (!continuesStack) lines.push(''); entryFirstLine.set(entry, lines.length); // An entry that sits entirely above the live viewport is in terminal @@ -1128,6 +1102,7 @@ export function renderMakaPiTranscript( lines.length < viewportTop && (entryHeight === 0 || lines.length + entryHeight <= viewportTop); lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen)); + previousVisibleEntry = entry; } state.renderGeometry.entryFirstLine = entryFirstLine; @@ -1191,8 +1166,8 @@ function clearPendingInteractions(state: MakaPiTranscriptState): void { state.queuedInteractions = []; } -function dropHiddenTools(state: MakaPiTranscriptState): void { - state.entries = state.entries.filter((entry) => entry.kind !== 'tool' || !entry.hidden); +function dropSuppressedTools(state: MakaPiTranscriptState): void { + state.entries = state.entries.filter((entry) => entry.kind !== 'tool' || !entry.suppressed); } /** @@ -1589,8 +1564,8 @@ function findToolEntry( ); } -function revealToolAtTail(state: MakaPiTranscriptState, tool: MakaPiToolEntry): void { - tool.hidden = undefined; +function unsuppressToolAtTail(state: MakaPiTranscriptState, tool: MakaPiToolEntry): void { + tool.suppressed = undefined; const index = state.entries.indexOf(tool); if (index < 0 || index === state.entries.length - 1) return; state.entries.splice(index, 1); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index f1a0dceb41..44f5cb07ae 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -96,7 +96,7 @@ import { applyShellRunViewUpdateToTranscript, permissionModeLabel, replaceTranscriptWithStoredMessages, - reconcileToolsWithStoredMessages, + hydrateToolsWithStoredMessages, submitCompactToTranscript, toggleAllThinkingExpansion, toggleAllToolExpansion, @@ -541,7 +541,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } rememberTranscriptModel(messages); - if (reconcileToolsWithStoredMessages(state, turnId, messages)) { + if (hydrateToolsWithStoredMessages(state, turnId, messages)) { shellRunElapsedTicker.sync(); requestRender(); } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 6ae6ec1aa3..4a58287e49 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -530,6 +530,8 @@ export interface ToolStartEvent extends BaseEvent, ToolActivityIdentity { type: 'tool_start'; toolUseId: string; toolName: string; + /** Bounded correlation for a shell-run observation without transporting full tool args. */ + shellRunRef?: string; /** Runtime-owned durable tool-operation identity (Phase 2). */ operationId?: string; /** Stable semantic category for presentation; absent on legacy events. */ @@ -646,6 +648,8 @@ export interface ToolResultEvent extends BaseEvent, ToolActivityIdentity { providerExecuted?: boolean; /** Raw provider result retained for provider-native replay; never rendered directly. */ providerOutput?: unknown; + /** The transport omitted durable result content; consumers must not treat the placeholder as authoritative. */ + contentOmitted?: true; isError: boolean; content: ToolResultContent; durationMs?: number; diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 4064810411..8d2abdce72 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -209,6 +209,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 41); }); + test('publishes a new compatibility epoch for shell-run poll correlation', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 42); + }); + test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); 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..c7d1c3d427 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -1872,6 +1872,46 @@ test('publishes only the minimal sandbox failure reason from a tool result', asy coordinator.close(); }); +test('publishes only the bounded shell-run correlation from poll args', 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 ref = 'maka://runtime/background-tasks/bg-1'; + + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { + type: 'tool_start', + id: 'start-1', + turnId: 'turn-1', + ts: 2, + toolUseId: 'tool-1', + toolName: 'Read', + args: { ref, unrelated: 'not published' }, + }); + 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: 2, + toolUseId: 'tool-1', + toolName: 'Read', + shellRunRef: ref, + }); + + connection.abort(opened.subscriptionId); + coordinator.close(); +}); + class RecordingSink implements SessionContinuityFrameSink { readonly frames: SubscriptionFrame[] = []; diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 4ffad9e9cd..6ff4f76eb7 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -262,6 +262,74 @@ test('does not replay settled transcript steps when the active step reaches term ); }); +test('marks Runtime Host tool results whose durable content is omitted', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const projected = projector.accept({ + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'tool_result', + id: 'result-1', + turnId: 'turn-1', + ts: 10, + toolUseId: 'tool-1', + status: 'completed', + }, + }).events[0]; + + assert.equal(projected?.type, 'tool_result'); + assert.equal( + projected?.type === 'tool_result' && 'contentOmitted' in projected + ? projected.contentOmitted + : undefined, + true, + ); +}); + +test('preserves the bounded shell-run correlation on a tool start', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + const ref = 'maka://runtime/background-tasks/bg-1'; + + const projected = projector.accept({ + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'tool_start', + id: 'start-1', + turnId: 'turn-1', + ts: 10, + toolUseId: 'tool-1', + toolName: 'Read', + shellRunRef: ref, + }, + } as SubscriptionFrame).events[0]; + + assert.equal(projected?.type, 'tool_start'); + assert.equal( + projected?.type === 'tool_start' && 'shellRunRef' in projected + ? projected.shellRunRef + : undefined, + ref, + ); +}); + 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 3973e93f3d..09760af34a 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -496,6 +496,7 @@ function projectToolEvent( ...(event.activityKind ? { activityKind: event.activityKind } : {}), ...(event.displayName ? { displayName: event.displayName } : {}), ...(event.stepId ? { stepId: event.stepId } : {}), + ...(event.shellRunRef ? { shellRunRef: event.shellRunRef } : {}), }; } if (event.type === 'tool_output_delta') { @@ -523,6 +524,7 @@ function projectToolEvent( return { type: 'tool_result', ...base, + contentOmitted: true, isError: event.status === 'errored', content: { kind: 'text', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index d1418d69c2..3b21e7d132 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,7 @@ 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 = 42 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 43 as const; // 42: Turn provider retry progress adds `provider_capacity`. Older peers reject // that strict retry-reason enum value, so mixed versions must fail handshake. // 41: Context compaction returns a typed terminal outcome on both Turn diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 0060c0bdfd..8e0374ad74 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -164,6 +164,7 @@ export type SessionToolEvent = activityKind?: ToolActivityKind; displayName?: string; stepId?: string; + shellRunRef?: string; }) | (SessionToolEventIdentity & { type: 'tool_output_delta'; @@ -730,6 +731,7 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { 'activityKind', 'displayName', 'stepId', + 'shellRunRef', ]; assertAllowedKeys(record, 'Session tool start event', allowed); assertRequiredKeys(record, 'Session tool start event', [ @@ -764,6 +766,9 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { ), }), ...(record.stepId === undefined ? {} : { stepId: requireEntityId(record.stepId, 'stepId') }), + ...(record.shellRunRef === undefined + ? {} + : { shellRunRef: decodeRuntimeResourceRef(record.shellRunRef) }), }; } if (record.type === 'tool_output_delta') { diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index 4f3e894c46..d5cc3104a5 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -21,6 +21,7 @@ import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { SessionEvent, ShellRunUpdate } from '@maka/core/events'; import { + decodeRuntimeResourceRef, encodeProtocolMessage, RUNTIME_HOST_MAX_MESSAGE_BYTES, SESSION_LIVE_DELTA_MAX_BYTES, @@ -1938,7 +1939,8 @@ function projectToolEvent( toolUseId: event.toolUseId, }; switch (event.type) { - case 'tool_start': + case 'tool_start': { + const shellRunRef = toolStartShellRunRef(event); return { type: event.type, ...identity, @@ -1949,7 +1951,9 @@ function projectToolEvent( ? {} : { displayName: boundedUtf8(event.displayName, SESSION_TOOL_NAME_MAX_BYTES) }), ...(event.stepId === undefined ? {} : { stepId: event.stepId }), + ...(shellRunRef ? { shellRunRef } : {}), }; + } case 'tool_output_delta': return { type: event.type, @@ -2003,6 +2007,22 @@ function boundedUtf8(value: string, maxBytes: number): string { return bounded; } +function toolStartShellRunRef( + event: Extract, +): string | undefined { + if (event.toolName !== 'Read' && event.toolName !== 'StopBackgroundTask') return undefined; + const ref = + event.args !== null && typeof event.args === 'object' + ? (event.args as { ref?: unknown }).ref + : undefined; + if (typeof ref !== 'string') return undefined; + try { + return decodeRuntimeResourceRef(ref); + } catch { + return undefined; + } +} + function signal(): { readonly promise: Promise; resolve(): void } { let resolve!: () => void; const promise = new Promise((settle) => { diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 7bb516425a..438454391b 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -934,20 +934,31 @@ describe('tool_result_preview live projection', () => { ); }); - it('keeps open-facts when RH-style empty tool_result settles the row', () => { + it('keeps hydrated result content when Runtime Host omits it from the live event', () => { const previewed = previewedSubagentTurn(); - const settled = applyLiveTurnEvent(previewed, { + const hydrated: LiveTurnProjection = { + ...previewed, + steps: [{ + ...previewed.steps[0]!, + tools: [{ + ...previewed.steps[0]!.tools[0]!, + result: { kind: 'text', text: 'full durable output' }, + }], + }], + }; + const settled = applyLiveTurnEvent(hydrated, { type: 'tool_result', id: 'event-3', turnId: 'turn-1', toolUseId: 'tool-1', + contentOmitted: true, isError: false, content: { kind: 'text', text: '' }, ts: 102, }); assert.equal(settled.steps[0]?.tools[0]?.status, 'completed'); - assert.deepEqual(settled.steps[0]?.tools[0]?.result, previewed.steps[0]?.tools[0]?.result); + assert.deepEqual(settled.steps[0]?.tools[0]?.result, { kind: 'text', text: 'full durable output' }); }); }); diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 727cb518bc..5de4682f30 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -442,19 +442,11 @@ export function applyLiveTurnEvent( const base: ToolActivityItem = toolIndex >= 0 ? step.tools[toolIndex]! : { toolUseId: event.toolUseId, toolName: 'Tool', status: 'running', args: undefined }; - // RH live tool_result deliberately omits content (empty text). Do not wipe - // mid-flight open-facts until a meaningful result or persisted merge arrives. - const retainOpenFacts = - event.content.kind === 'text' && - event.content.text.length === 0 && - base.result?.kind === 'subagent' && - typeof base.result.childSessionId === 'string' && - base.result.childSessionId.length > 0; const tool: ToolActivityItem = { ...base, ...projectToolActivityIdentity(event), status: toolResultActivityStatus(event.isError, event.content), - result: retainOpenFacts ? base.result : event.content, + result: event.contentOmitted ? base.result : event.content, ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}), }; nextStep = { From 412e1ffc69d00b7b51c5dfc8ae77ef11dd6c7481 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 23:39:27 +0800 Subject: [PATCH 12/13] refactor(cli): derive tool presentation from authoritative facts --- .../cli/src/__tests__/pi-transcript.test.ts | 287 +++++++++++++++++- packages/cli/src/pi-transcript-tools.ts | 35 ++- packages/cli/src/pi-transcript.ts | 215 +++++++------ .../__tests__/live-turn-projection.test.ts | 33 +- packages/ui/src/__tests__/materialize.test.ts | 26 +- .../__tests__/shell-run-projection.test.ts | 29 +- packages/ui/src/materialize.ts | 51 +++- packages/ui/src/tool-activity.tsx | 14 +- 8 files changed, 524 insertions(+), 166 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 03b7b6fe27..e6acead48b 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -33,13 +33,20 @@ import { renderMakaPiActivityStrip, renderMakaPiStatusLine, renderMakaPiTranscript, + refreshRunningShellRunElapsed, hydrateToolsWithStoredMessages, + makaPiToolPresentationStatus, replaceTranscriptWithStoredMessages, submitCompactToTranscript, toggleAllThinkingExpansion, toggleAllToolExpansion, + type MakaPiToolEntry, } from '../pi-transcript.js'; +function toolStatus(entry: MakaPiToolEntry | undefined): string | undefined { + return entry ? makaPiToolPresentationStatus(entry) : undefined; +} + describe('Maka Pi TUI transcript', () => { test('renders manual compaction from the typed terminal outcome', async () => { for (const [outcome, expected] of [ @@ -383,6 +390,193 @@ describe('Maka Pi TUI transcript', () => { assert.equal(state.entries.at(-1)?.kind, 'notice'); }); + test('keeps a hydrated background Bash live when its omitted settlement arrives', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'npm test' }, + }), + ); + hydrateToolsWithStoredMessages( + state, + 'turn-1', + storedBash('bash-bg', shellRun({ status: 'running', revision: 1 })), + ); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + contentOmitted: true, + content: { kind: 'text', text: '' }, + durationMs: 777, + }), + ); + + const bash = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'bash-bg', + ); + assert.equal(bash?.kind === 'tool' ? makaPiToolPresentationStatus(bash) : undefined, 'running'); + assert.equal(bash?.kind === 'tool' ? bash.durationMs : undefined, 0); + assert.equal(refreshRunningShellRunElapsed(state, 2_000), true); + }); + + test('does not replace a newer live ShellRun revision with an older durable snapshot', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'npm test' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'bash-bg', + isError: false, + content: shellRun({ + status: 'completed', + revision: 5, + updatedAt: 5_000, + completedAt: 5_000, + stdout: 'complete\n', + }), + }), + ); + + hydrateToolsWithStoredMessages( + state, + 'turn-1', + storedBash('bash-bg', shellRun({ status: 'running', revision: 1 })), + ); + + const bash = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'bash-bg', + ); + assert.equal( + bash?.kind === 'tool' && bash.result?.kind === 'shell_run' ? bash.result.revision : undefined, + 5, + ); + assert.equal( + bash?.kind === 'tool' && bash.result?.kind === 'shell_run' ? bash.result.status : undefined, + 'completed', + ); + }); + + test('does not materialize an omitted placeholder as a tool result', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'late-result', + isError: false, + contentOmitted: true, + content: { kind: 'text', text: '' }, + }), + ); + + const tool = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'late-result', + ); + assert.equal(tool?.kind === 'tool' ? tool.result : undefined, undefined); + assert.equal(tool?.kind === 'tool' ? makaPiToolPresentationStatus(tool) : undefined, 'done'); + }); + + test('does not reopen a settled call from a resultless durable snapshot', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-1', toolName: 'Read', args: {} }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'read-1', + isError: false, + content: { kind: 'text', text: 'complete' }, + }), + ); + + hydrateToolsWithStoredMessages(state, 'turn-1', [ + { + type: 'tool_call', + id: 'read-1', + turnId: 'turn-1', + ts: 1, + toolName: 'Read', + args: { path: 'README.md' }, + }, + { + type: 'turn_state', + id: 'running-state', + turnId: 'turn-1', + ts: 2, + status: 'running', + partialOutputRetained: true, + }, + ]); + + const tool = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'read-1', + ); + assert.equal(tool?.kind === 'tool' ? makaPiToolPresentationStatus(tool) : undefined, 'done'); + }); + + test('does not replace a newer live result or terminal outcome with durable history', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'read-1', toolName: 'Read', args: {} }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'read-1', + isError: true, + content: { kind: 'text', text: '' }, + }), + ); + + hydrateToolsWithStoredMessages(state, 'turn-1', [ + { + type: 'tool_call', + id: 'read-1', + turnId: 'turn-1', + ts: 1, + toolName: 'Read', + args: { path: 'README.md' }, + }, + { + type: 'tool_result', + id: 'old-result', + turnId: 'turn-1', + ts: 2, + toolUseId: 'read-1', + isError: false, + content: { kind: 'text', text: 'older durable output' }, + }, + ]); + + const tool = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'read-1', + ); + assert.deepEqual(tool?.kind === 'tool' ? tool.result : undefined, { kind: 'text', text: '' }); + assert.equal(tool?.kind === 'tool' ? tool.callStatus : undefined, 'errored'); + }); + test('keeps an in-flight background poll suppressed during durable hydration', () => { const { state, messages } = inFlightBackgroundPollFixture(); hydrateToolsWithStoredMessages(state, 'turn-1', messages); @@ -391,7 +585,7 @@ describe('Maka Pi TUI transcript', () => { (entry) => entry.kind === 'tool' && entry.toolUseId === 'read-bg', ); assert.equal(poll?.kind === 'tool' ? poll.suppressed : undefined, true); - assert.equal(poll?.kind === 'tool' ? poll.status : undefined, 'running'); + assert.equal(poll?.kind === 'tool' ? makaPiToolPresentationStatus(poll) : undefined, 'running'); }); test('drops a hydrated in-flight background poll on abort', () => { @@ -754,7 +948,7 @@ describe('Maka Pi TUI transcript', () => { const tools = state.entries.filter((entry) => entry.kind === 'tool'); assert.equal(tools.length, 1); assert.equal(tools[0]?.toolUseId, 'bash-bg'); - assert.equal(tools[0]?.status, 'done'); + assert.equal(toolStatus(tools[0]), 'done'); assert.equal( tools[0]?.result?.kind === 'shell_run' && tools[0].result.output?.mode === 'pipes' ? tools[0].result.output.stdout @@ -852,10 +1046,10 @@ describe('Maka Pi TUI transcript', () => { tools.map((tool) => tool.toolUseId), ['bash-bg', 'read-bg'], ); - assert.equal(tools[1]?.status, 'error'); + assert.equal(toolStatus(tools[1]), 'error'); // The parent keeps its own revision, output, and status — the failed poll // changes nothing. - assert.equal(tools[0]?.status, 'running'); + assert.equal(toolStatus(tools[0]), 'running'); assert.equal(tools[0]?.result?.kind === 'shell_run' ? tools[0].result.revision : undefined, 1); assert.equal( tools[0]?.result?.kind === 'shell_run' && tools[0].result.output?.mode === 'pipes' @@ -1171,6 +1365,9 @@ describe('Maka Pi TUI transcript', () => { }, size: { cols: 100, rows: 30 }, }); + assert.equal(tools[1]?.durationMs, undefined); + refreshRunningShellRunElapsed(state, 3_000); + assert.equal(tools[1]?.durationMs, undefined); assert.equal(toggleAllToolExpansion(state), true); const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); @@ -1617,7 +1814,7 @@ describe('Maka Pi TUI transcript', () => { const tools = state.entries.filter((entry) => entry.kind === 'tool'); assert.deepEqual( - tools.map((tool) => [tool.toolUseId, tool.status]), + tools.map((tool) => [tool.toolUseId, makaPiToolPresentationStatus(tool)]), [ ['agent-a', 'done'], ['agent-b', 'failed'], @@ -1663,7 +1860,7 @@ describe('Maka Pi TUI transcript', () => { const tools = state.entries.filter((entry) => entry.kind === 'tool'); assert.equal(tools.length, 1); assert.equal(tools[0]?.toolUseId, 'agent-a'); - assert.equal(tools[0]?.status, 'aborted'); + assert.equal(toolStatus(tools[0]), 'aborted'); }); test('keeps a background Bash card running until the process settles', () => { @@ -1696,7 +1893,7 @@ describe('Maka Pi TUI transcript', () => { ); const tool = state.entries.find((entry) => entry.kind === 'tool'); - assert.equal(tool?.kind === 'tool' ? tool.status : undefined, 'running'); + assert.equal(tool?.kind === 'tool' ? makaPiToolPresentationStatus(tool) : undefined, 'running'); const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); assert.match(rendered, /● Bash \$ sleep 30 \(running 10s\)/); assert.doesNotMatch(rendered, /done/); @@ -1874,7 +2071,7 @@ describe('Maka Pi TUI transcript', () => { tools.map((tool) => tool.toolUseId), ['bash-bg', 'read-bg'], ); - assert.equal(tools[1]?.status, 'error'); + assert.equal(toolStatus(tools[1]), 'error'); // The parent keeps its pre-error revision — the failed call changes nothing. assert.equal( tools[0]?.result?.kind === 'shell_run' && tools[0].result.output?.mode === 'pipes' @@ -1938,7 +2135,7 @@ describe('Maka Pi TUI transcript', () => { tools.map((tool) => tool.toolUseId), ['bash-bg', 'read-bg'], ); - assert.equal(tools[1]?.status, 'error'); + assert.equal(toolStatus(tools[1]), 'error'); assert.equal( tools[0]?.result?.kind === 'shell_run' && tools[0].result.output?.mode === 'pipes' ? tools[0].result.output.stdout @@ -1992,7 +2189,7 @@ describe('Maka Pi TUI transcript', () => { const poll = tools[1]; assert.equal(poll?.toolUseId, 'read-bg'); assert.equal(poll?.toolName, 'Read'); - assert.equal(poll?.status, 'error'); + assert.equal(toolStatus(poll), 'error'); const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); assert.match(rendered, /● Read/); // The error disc carries the failure state; free-text error content stays @@ -2222,7 +2419,7 @@ describe('Maka Pi TUI transcript', () => { tools.map((tool) => tool.toolUseId), ['bash-bg'], ); - assert.equal(tools[0]?.status, 'aborted'); + assert.equal(toolStatus(tools[0]), 'aborted'); const rendered = renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'); assert.doesNotMatch(rendered, /● StopBackgroundTask/); }); @@ -2265,7 +2462,7 @@ describe('Maka Pi TUI transcript', () => { tools.map((tool) => tool.toolUseId), ['bash-bg', 'stdin-bg'], ); - assert.equal(tools[1]?.status, 'running'); + assert.equal(toolStatus(tools[1]), 'running'); }); test('stays silent for a hydration catch-up update that settles a resumed card', () => { @@ -2315,7 +2512,7 @@ describe('Maka Pi TUI transcript', () => { assert.equal(applied, true); const tools = state.entries.filter((entry) => entry.kind === 'tool'); - assert.equal(tools[0]?.status, 'done'); + assert.equal(toolStatus(tools[0]), 'done'); assert.equal( state.entries.some((entry) => entry.kind === 'notice'), false, @@ -2444,7 +2641,10 @@ describe('Maka Pi TUI transcript', () => { }), }); const detached = state.entries.find((entry) => entry.kind === 'tool'); - assert.equal(detached?.kind === 'tool' ? detached.status : '', 'detached'); + assert.equal( + detached?.kind === 'tool' ? makaPiToolPresentationStatus(detached) : '', + 'detached', + ); assert.equal( state.entries.some((entry) => entry.kind === 'notice'), false, @@ -2476,6 +2676,41 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('does not pair stale ownership with a newer ShellRun revision', () => { + const state = createMakaPiTranscriptState(); + const ref = 'maka://runtime/background-tasks/bg-1'; + applyMakaSessionEventToTranscript( + state, + event({ type: 'tool_start', toolUseId: 'bash-bg', toolName: 'Bash', args: { command: 'build' } }), + ); + applyShellRunViewUpdateToTranscript(state, { + sessionId: 'session-1', + ownership: { kind: 'local' }, + sourceTurnId: 'turn-1', + sourceToolCallId: 'bash-bg', + result: shellRun({ ref, status: 'running', revision: 5, updatedAt: 5 }), + }); + applyShellRunViewUpdateToTranscript(state, { + sessionId: 'session-branch', + ownership: { + kind: 'source_owned', + sourceSessionId: 'session-1', + ownerSessionId: 'session-1', + }, + sourceTurnId: 'turn-1', + sourceToolCallId: 'bash-bg', + result: shellRun({ ref, status: 'running', revision: 4, updatedAt: 4 }), + }); + + const bash = state.entries.find((entry) => entry.kind === 'tool' && entry.toolUseId === 'bash-bg'); + assert.equal( + bash?.kind === 'tool' && bash.result?.kind === 'shell_run' ? bash.result.revision : undefined, + 5, + ); + assert.equal(bash?.kind === 'tool' ? bash.shellRunSource : undefined, undefined); + assert.equal(bash?.kind === 'tool' ? makaPiToolPresentationStatus(bash) : undefined, 'running'); + }); + test('announces a detached background task orphaned settle as an error exactly once', () => { const state = createMakaPiTranscriptState(); const ref = 'maka://runtime/background-tasks/bg-1'; @@ -2997,7 +3232,7 @@ describe('Maka Pi TUI transcript', () => { const tools = state.entries.filter((entry) => entry.kind === 'tool'); assert.equal(tools.length, 1); - assert.equal(tools[0]?.status, 'aborted'); + assert.equal(toolStatus(tools[0]), 'aborted'); const lines = renderMakaPiTranscript(state, meta(), 100); const rendered = lines.map(stripAnsi).join('\n'); assert.match(rendered, /● Bash \$ sleep 30 \(7s · cancelled · exit 130\)/); @@ -3691,6 +3926,28 @@ function event(input: { type: SessionEvent['type'] } & Record): } as SessionEvent; } +function storedBash(toolUseId: string, content: ShellRunToolResult): StoredMessage[] { + return [ + { + type: 'tool_call', + id: toolUseId, + turnId: 'turn-1', + ts: 1, + toolName: 'Bash', + args: { command: 'npm test' }, + }, + { + type: 'tool_result', + id: `${toolUseId}-result`, + turnId: 'turn-1', + ts: 2, + toolUseId, + isError: false, + content, + }, + ]; +} + function inFlightBackgroundPollFixture(): { state: ReturnType; messages: StoredMessage[]; diff --git a/packages/cli/src/pi-transcript-tools.ts b/packages/cli/src/pi-transcript-tools.ts index 7195862713..450ebba5c7 100644 --- a/packages/cli/src/pi-transcript-tools.ts +++ b/packages/cli/src/pi-transcript-tools.ts @@ -38,7 +38,11 @@ import { limitText, renderIndented, } from './pi-transcript-format.js'; -import type { MakaPiToolEntry, MakaPiToolOutputDelta } from './pi-transcript.js'; +import { + makaPiToolPresentationStatus, + type MakaPiToolEntry, + type MakaPiToolOutputDelta, +} from './pi-transcript.js'; export function renderToolBlock( entry: MakaPiToolEntry, @@ -50,10 +54,10 @@ export function renderToolBlock( /** Status disc for a tool row: green = done, accent = running, danger = error/aborted/failed, muted = detached/unavailable. */ function toolDisc(entry: MakaPiToolEntry): string { - if (entry.status === 'running') return disc('accent'); - if (entry.status === 'error' || entry.status === 'aborted' || entry.status === 'failed') - return disc('danger'); - if (entry.status === 'detached' || entry.status === 'unavailable') return disc('muted'); + const status = makaPiToolPresentationStatus(entry); + if (status === 'running') return disc('accent'); + if (status === 'error' || status === 'aborted' || status === 'failed') return disc('danger'); + if (status === 'detached' || status === 'unavailable') return disc('muted'); return disc('ok'); } @@ -65,14 +69,15 @@ function toolDisc(entry: MakaPiToolEntry): string { * `source unavailable`) are dimmed like the other placeholders. */ function toolDurationText(entry: MakaPiToolEntry): string { + const status = makaPiToolPresentationStatus(entry); const subSecond = entry.durationMs !== undefined && entry.durationMs < 1000; const secs = entry.durationMs === undefined ? undefined : Math.max(0, Math.round(entry.durationMs / 1000)); - if (entry.status === 'running') { + if (status === 'running') { return secs === undefined || subSecond ? 'running' : `running ${secs}s`; } - if (entry.status === 'detached') return ansi.dim('detached'); - if (entry.status === 'unavailable') return ansi.dim('source unavailable'); + if (status === 'detached') return ansi.dim('detached'); + if (status === 'unavailable') return ansi.dim('source unavailable'); return secs === undefined || subSecond ? '' : `${secs}s`; } @@ -114,7 +119,7 @@ function compactAnnotation(entry: MakaPiToolEntry): { text: string; protect: boo const duration = toolDurationText(entry); if (duration) parts.push(duration); let protect = true; - if (entry.status !== 'running') { + if (makaPiToolPresentationStatus(entry) !== 'running') { const summary = compactToolSummary(entry); if (summary && !(summary.placeholder && parts.length > 0)) { parts.push(collapseToSingleLine(summary.text)); @@ -194,12 +199,12 @@ function renderExpandedToolBlock(entry: MakaPiToolEntry, width: number): string[ } lines.push(...renderToolStreams(entry.outputDeltas.values(), width)); } - if (entry.result || entry.status === 'aborted') { + if (entry.result || makaPiToolPresentationStatus(entry) === 'aborted') { lines.push(...renderToolResult(entry, width)); } if ( entry.toolName === 'Bash' && - entry.status === 'running' && + makaPiToolPresentationStatus(entry) === 'running' && entry.result?.kind === 'shell_run' ) { lines.push(...renderIndented(ansi.dim('Ask Maka to stop this task'), width, 2)); @@ -312,7 +317,7 @@ function compactToolSummary(entry: MakaPiToolEntry): CompactToolSummary | undefi // fabricated file count. if ( entry.toolName === 'Read' && - entry.status !== 'error' && + makaPiToolPresentationStatus(entry) !== 'error' && isFilesystemReadPath(entry) && isReadBodyResult(result) ) { @@ -525,7 +530,7 @@ function renderToolResult(entry: MakaPiToolEntry, width: number): string[] { // visible instead of being mistaken for a one-line file. if ( entry.toolName === 'Read' && - entry.status !== 'error' && + makaPiToolPresentationStatus(entry) !== 'error' && isFilesystemReadPath(entry) && isReadBodyResult(result) ) { @@ -568,7 +573,9 @@ function renderToolResult(entry: MakaPiToolEntry, width: number): string[] { function plainResultText(entry: MakaPiToolEntry): string { const result = entry.result; if (!result) { - return entry.status === 'aborted' ? 'Interrupted before the tool returned a result.' : ''; + return makaPiToolPresentationStatus(entry) === 'aborted' + ? 'Interrupted before the tool returned a result.' + : ''; } if (result?.kind === 'text') return typeof result.text === 'string' ? result.text : ''; if (result?.kind === 'json') { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index cdc28ccc7e..87433d8e0e 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -168,7 +168,10 @@ export type MakaPiTranscriptEntry = progress: BoundedChunkBuffer; outputDeltas: BoundedChunkBuffer; durationMs?: number; - status: 'running' | 'done' | 'error' | 'failed' | 'aborted' | 'detached' | 'unavailable'; + /** Invocation lifecycle. Resource liveness remains authoritative in `result`. */ + callStatus: ToolActivityStatus; + /** Ownership of an active ShellRun; absent means locally owned. */ + shellRunSource?: 'source_owned' | 'unavailable'; /** Expanded card view; stamped from expandAllTools, retargeted by Ctrl+O. */ expanded: boolean; /** An internal shell-run poll retained for correlation but not displayed. */ @@ -257,7 +260,11 @@ export function refreshRunningShellRunElapsed( ): boolean { let found = false; for (const entry of state.entries) { - if (entry.kind !== 'tool' || entry.status !== 'running' || entry.result?.kind !== 'shell_run') + if ( + entry.kind !== 'tool' || + entry.result?.kind !== 'shell_run' || + makaPiToolPresentationStatus(entry) !== 'running' + ) continue; entry.durationMs = Math.max(0, now - entry.result.startedAt); found = true; @@ -290,17 +297,18 @@ export function applyShellRunViewUpdateToTranscript( tool.toolName !== 'Bash' || tool.result?.kind !== 'shell_run' || tool.result.ref !== update.result.ref || + tool.result.revision !== update.result.revision || !isActiveShellRunStatus(tool.result.status) ) return applied; - const status = + const shellRunSource = update.ownership.kind === 'local' - ? 'running' + ? undefined : update.ownership.kind === 'source_owned' - ? 'detached' + ? 'source_owned' : 'unavailable'; - if (tool.status === status) return applied; - tool.status = status; + if (tool.shellRunSource === shellRunSource) return applied; + tool.shellRunSource = shellRunSource; return true; } @@ -366,15 +374,32 @@ export function hydrateToolsWithStoredMessages( entry.toolName = durable.toolName; entry.title = durable.title; entry.input = structuredClone(durable.input); - entry.result = durable.result ? structuredClone(durable.result) : undefined; - entry.durationMs = durable.durationMs; - entry.status = durable.status; - entry.resultVersion += 1; + entry.callStatus = mergeToolCallStatus(entry.callStatus, durable.callStatus); + if ( + durable.result?.kind === 'shell_run' && + durable.callStatus !== 'errored' && + entry.toolName === 'Bash' + ) { + applyShellRunResult(entry, structuredClone(durable.result)); + } else if (durable.result !== undefined && entry.result === undefined) { + entry.result = structuredClone(durable.result); + entry.resultVersion += 1; + if (durable.durationMs !== undefined) entry.durationMs = durable.durationMs; + } else if (durable.durationMs !== undefined && entry.durationMs === undefined) { + entry.durationMs = durable.durationMs; + } changed = true; } return changed; } +function mergeToolCallStatus( + current: ToolActivityStatus, + durable: ToolActivityStatus, +): ToolActivityStatus { + return current === 'running' ? durable : current; +} + /** * True when the entry will render inside the live viewport, or has not been * rendered yet (a fresh entry first appears at the tail, inside the viewport). @@ -555,7 +580,7 @@ export function applyMakaSessionEventToTranscript( resultVersion: 0, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - status: 'running', + callStatus: 'running', expanded: state.expandAllTools, ...(suppressed ? { suppressed: true } : {}), }); @@ -583,18 +608,17 @@ export function applyMakaSessionEventToTranscript( } if (tool) { if (tool.suppressed) unsuppressToolAtTail(state, tool); + tool.callStatus = toolResultActivityStatus(event.isError, event.content); if (shellRun) { if (tool.toolName === 'Bash') { applyShellRunResult(tool, shellRun); } else { applyOwnShellRunResult(tool, shellRun, event.durationMs); } - // isError is the call-level authoritative status: a failed call shows - // error even when its payload is a well-formed (still running) run. - if (event.isError) tool.status = 'error'; } else { - tool.status = toolResultTranscriptStatus(event.content, event.isError); - tool.durationMs = event.durationMs; + if (!(event.contentOmitted && tool.result?.kind === 'shell_run')) { + tool.durationMs = event.durationMs; + } if (!event.contentOmitted) { tool.result = event.content; tool.resultVersion += 1; @@ -609,10 +633,10 @@ export function applyMakaSessionEventToTranscript( input: undefined, progress: createProgressBuffer(), outputDeltas: createOutputBuffer(), - result: event.content, - resultVersion: 1, + ...(!event.contentOmitted ? { result: event.content } : {}), + resultVersion: event.contentOmitted ? 0 : 1, durationMs: event.durationMs, - status: toolResultTranscriptStatus(event.content, event.isError), + callStatus: toolResultActivityStatus(event.isError, event.content), expanded: state.expandAllTools, }); } @@ -832,16 +856,11 @@ function storedToolToTranscriptEntry( ...(result ? { result: result.content } : {}), resultVersion: result ? 1 : 0, ...(result?.durationMs !== undefined ? { durationMs: result.durationMs } : {}), - status: transcriptToolStatus( - result - ? toolResultActivityStatus(result.isError, result.content) - : unfinishedToolActivityStatus(turnStatus), - ), + callStatus: result + ? toolResultActivityStatus(result.isError, result.content) + : unfinishedToolActivityStatus(turnStatus), expanded: false, }; - if (result?.content.kind === 'subagent') { - entry.status = subagentTranscriptStatus(result.content.status); - } // A failed call keeps its error status and raw payload: applying the shell_run // as the card's own result would let a still-running or settled payload // overwrite the error and swallow the failure on replay. This mirrors the live @@ -858,7 +877,11 @@ function foldStoredShellRunChildren(entries: MakaPiTranscriptEntry[]): MakaPiTra // An errored poll never folds: its failed payload must not mutate the parent // and its error card must survive replay, mirroring the live path's "failure // is never swallowed" invariant. - if (entry.kind === 'tool' && entry.result?.kind === 'shell_run' && entry.status !== 'error') { + if ( + entry.kind === 'tool' && + entry.result?.kind === 'shell_run' && + entry.callStatus !== 'errored' + ) { const shellRun = entry.result; const parent = [...folded] .reverse() @@ -879,63 +902,66 @@ function foldStoredShellRunChildren(entries: MakaPiTranscriptEntry[]): MakaPiTra return folded; } -function transcriptToolStatus(status: ToolActivityStatus): MakaPiToolEntry['status'] { - switch (status) { - case 'completed': - return 'done'; - case 'errored': - return 'error'; - case 'interrupted': - return 'aborted'; - case 'running': - return 'running'; - } -} - -function toolResultTranscriptStatus( - result: ToolResultContent, - isError: boolean, -): MakaPiToolEntry['status'] { - return result.kind === 'subagent' - ? subagentTranscriptStatus(result.status) - : isError - ? 'error' - : 'done'; -} - -function subagentTranscriptStatus( - status: Extract['status'], -): MakaPiToolEntry['status'] { - switch (status) { - case 'completed': - return 'done'; - case 'failed': - return 'failed'; - case 'cancelled': - return 'aborted'; - case 'running': - case 'waiting_for_user': - return 'running'; - } -} - -function shellRunTranscriptStatus( - status: Extract['status'], -): MakaPiToolEntry['status'] { - switch (status) { - case 'starting': - case 'running': - return 'running'; - case 'completed': - return 'done'; - case 'cancelled': - return 'aborted'; - case 'failed': - case 'timed_out': - case 'orphaned': - return 'failed'; +export type MakaPiToolPresentationStatus = + | 'running' + | 'done' + | 'error' + | 'failed' + | 'aborted' + | 'detached' + | 'unavailable'; + +export function makaPiToolPresentationStatus(entry: MakaPiToolEntry): MakaPiToolPresentationStatus { + if (entry.result?.kind === 'subagent') return SUBAGENT_PRESENTATION_STATUS[entry.result.status]; + if (entry.result?.kind === 'shell_run') { + if (entry.callStatus === 'errored') return 'error'; + if (entry.toolName === 'WriteStdin') { + return entry.result.operation?.kind === 'pty_control' && entry.result.operation.failed + ? 'error' + : 'done'; + } + if (isActiveShellRunStatus(entry.result.status)) { + return entry.shellRunSource === 'source_owned' + ? 'detached' + : entry.shellRunSource === 'unavailable' + ? 'unavailable' + : 'running'; + } + return SHELL_RUN_PRESENTATION_STATUS[entry.result.status]; } -} + return CALL_PRESENTATION_STATUS[entry.callStatus]; +} + +const CALL_PRESENTATION_STATUS = { + running: 'running', + completed: 'done', + errored: 'error', + interrupted: 'aborted', +} as const satisfies Record; + +const SUBAGENT_PRESENTATION_STATUS = { + completed: 'done', + failed: 'failed', + cancelled: 'aborted', + running: 'running', + waiting_for_user: 'running', +} as const satisfies Record< + Extract['status'], + MakaPiToolPresentationStatus +>; + +const SHELL_RUN_PRESENTATION_STATUS = { + starting: 'running', + running: 'running', + completed: 'done', + cancelled: 'aborted', + failed: 'failed', + timed_out: 'failed', + orphaned: 'failed', +} as const satisfies Record< + Extract['status'], + MakaPiToolPresentationStatus +>; function applyShellRunResult( entry: MakaPiToolEntry, @@ -944,7 +970,6 @@ function applyShellRunResult( const current = entry.result?.kind === 'shell_run' ? entry.result : undefined; const merged = mergeShellRunStateWithDiagnostics(current, result, 'cli.transcript'); if (!merged.changed) return false; - entry.status = shellRunTranscriptStatus(merged.result.status); entry.result = merged.result; entry.durationMs = Math.max( 0, @@ -959,12 +984,6 @@ function applyOwnShellRunResult( result: Extract, operationDurationMs = entry.durationMs, ): void { - entry.status = - entry.toolName === 'WriteStdin' - ? result.operation?.kind === 'pty_control' && result.operation.failed - ? 'error' - : 'done' - : shellRunTranscriptStatus(result.status); entry.result = result; if (entry.toolName === 'WriteStdin') { entry.durationMs = operationDurationMs; @@ -1275,17 +1294,15 @@ function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): case 'notice': return `notice|${width}|${entry.level}|${entry.text.length}`; case 'tool': - // A tool entry mutates in place as it runs: status/duration flip, - // progress/output deltas append, and resultVersion advances whenever a - // result is accepted. Count those revisions instead of duplicating the - // result's rendering contract in this cache key. `input` and - // `toolName` are omitted deliberately: both are set once at `tool_start`, - // before the first render, and never change, so they can't go stale. + // A tool entry mutates in place as it runs: its derived presentation and + // duration change, progress/output deltas append, and resultVersion + // advances whenever durable detail or a resource revision is accepted. + // Count those facts instead of duplicating the result rendering contract. return [ 'tool', width, entry.expanded ? 1 : 0, - entry.status, + makaPiToolPresentationStatus(entry), entry.durationMs ?? '', entry.title ?? entry.toolName, entry.progress.version, diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 438454391b..5b6656f08b 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -28,7 +28,7 @@ import { settleLiveTurnStep, type LiveTurnProjection, } from '../live-turn-projection.js'; -import { overlayLiveTurn, type ToolActivityItem } from '../materialize.js'; +import { materializeTurns, overlayLiveTurn, type ToolActivityItem } from '../materialize.js'; import { redactSecrets } from '../redact.js'; import { getConversationCopy } from '../conversation-copy.js'; @@ -960,6 +960,37 @@ describe('tool_result_preview live projection', () => { assert.equal(settled.steps[0]?.tools[0]?.status, 'completed'); assert.deepEqual(settled.steps[0]?.tools[0]?.result, { kind: 'text', text: 'full durable output' }); }); + + it('lets a meaningful live empty result replace older durable content', () => { + const turns = materializeTurns([ + { + type: 'tool_call', id: 'tool-1', turnId: 'turn-1', stepId: 'step-1', ts: 1, + toolName: 'Read', args: { path: 'README.md' }, + }, + { + type: 'tool_result', id: 'result-1', turnId: 'turn-1', ts: 2, + toolUseId: 'tool-1', isError: false, + content: { kind: 'text', text: 'older durable output' }, + }, + { + type: 'turn_state', id: 'state-1', turnId: 'turn-1', ts: 3, + status: 'running', partialOutputRetained: true, + }, + ]); + const started = applyLiveTurnEvent(undefined, { + type: 'tool_start', id: 'start-1', turnId: 'turn-1', stepId: 'step-1', + toolUseId: 'tool-1', toolName: 'Read', args: { path: 'README.md' }, ts: 4, + }); + const settled = applyLiveTurnEvent(started, { + type: 'tool_result', id: 'live-result-1', turnId: 'turn-1', toolUseId: 'tool-1', + isError: false, content: { kind: 'text', text: '' }, ts: 5, + }); + + assert.deepEqual(overlayLiveTurn(turns, settled)[0]?.tools[0]?.result, { + kind: 'text', + text: '', + }); + }); }); function previewedSubagentTurn(): LiveTurnProjection { diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index d295a35d95..197db51e71 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -618,26 +618,16 @@ describe("live tool status over persisted", () => { }, ]); - const turns = overlayLiveTurn(settled, { + const live = applyLiveTurnEvent(undefined, { + type: "tool_start", + id: "start-1", turnId: "t1", - phase: "streamed", - steps: [ - { - stepId: "tool:computer-1", - tools: [ - { - toolUseId: "computer-1", - toolName: "maka_computer", - status: "running", - args: undefined, - // Runtime Host live events carry lifecycle but not the durable - // result payload. - result: { kind: "text", text: "" }, - }, - ], - }, - ], + toolUseId: "computer-1", + toolName: "maka_computer", + args: undefined, + ts: 5, }); + const turns = overlayLiveTurn(settled, live); const toolGroup = turns .find((turn) => turn.turnId === "t1") diff --git a/packages/ui/src/__tests__/shell-run-projection.test.ts b/packages/ui/src/__tests__/shell-run-projection.test.ts index ca1be638e6..04e6768cfb 100644 --- a/packages/ui/src/__tests__/shell-run-projection.test.ts +++ b/packages/ui/src/__tests__/shell-run-projection.test.ts @@ -22,7 +22,12 @@ import { describe, test } from 'node:test'; import type { ShellRunSnapshotResult, ShellRunUpdate } from '@maka/core/events'; import type { ShellRunToolResult } from '@maka/core/shell-run-result'; import type { StoredMessage } from '@maka/core/session'; -import { materializeTurns } from '../materialize.js'; +import { + applyShellRunOverlayEntry, + materializeTurns, + toolActivityPresentationStatus, + type ToolActivityItem, +} from '../materialize.js'; import { createTranscriptProjection } from '../transcript-projection.js'; import type { LiveTurnProjection } from '../live-turn-projection.js'; @@ -71,6 +76,7 @@ describe('ShellRun UI projection', () => { resize: { cols: 100, rows: 30, applied: true, changed: true }, }, ); + assert.equal(write ? toolActivityPresentationStatus(write) : undefined, 'completed'); }); test('keeps a durable background update ahead of a stale live turn result', () => { @@ -112,6 +118,8 @@ describe('ShellRun UI projection', () => { const result = overlaid[0]?.tools[0]?.result; assert.equal(result?.kind === 'shell_run' ? result.revision : undefined, 3); assert.equal(result?.kind === 'shell_run' ? result.status : undefined, 'completed'); + const bash = overlaid[0]?.tools[0]; + assert.equal(bash ? toolActivityPresentationStatus(bash) : undefined, 'completed'); }); test('keeps a newer live PTY screen ahead of the persisted snapshot', () => { @@ -224,6 +232,25 @@ describe('ShellRun UI projection', () => { }] }); assert.equal(unavailable[0]?.tools[0]?.shellRunSource, 'unavailable'); }); + + test('does not pair stale ownership with a newer ShellRun revision', () => { + const tool: ToolActivityItem = { + toolUseId: 'bash-1', + toolName: 'Bash', + status: 'completed', + args: { command: 'job' }, + result: shellRun(5), + }; + + const overlaid = applyShellRunOverlayEntry(tool, { + result: shellRun(4), + source: 'owned', + }); + + assert.equal(overlaid.result?.kind === 'shell_run' ? overlaid.result.revision : undefined, 5); + assert.equal(overlaid.shellRunSource, undefined); + assert.equal(toolActivityPresentationStatus(overlaid), 'running'); + }); }); function toolCall( diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index fc328ecfbf..2f26a26174 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -97,6 +97,7 @@ export interface ToolActivityItem { * legacy call with no step association. */ stepId?: string; + /** Lifecycle of the tool invocation itself, independent of a returned resource. */ status: ToolActivityStatus; args: unknown; result?: ToolResultContent; @@ -266,15 +267,10 @@ function mergeLiveOverPersisted( if (live.args === undefined) { merged.args = persisted.args; } - const liveResultIsEmpty = - live.result === undefined || - (live.result.kind === "text" && live.result.text.length === 0); - if (persisted.result !== undefined && liveResultIsEmpty) { - // Runtime Host represents its deliberately omitted result payload as an - // empty text result at the SessionEvent compatibility seam. A transcript - // refresh can also win the race with the terminal live event, leaving no - // live result at all. In both cases the committed result supplies detail - // without taking a newer, meaningful live result away. + if (persisted.result !== undefined && live.result === undefined) { + // `applyLiveTurnEvent` removes deliberately omitted payloads before this + // merge. Only absence asks durable state to fill the result; an explicit + // empty result is still meaningful newer evidence. merged.result = persisted.result; } // A settled turn always yields a settled persisted status — materializeTools @@ -581,15 +577,19 @@ export function foldShellRunUpdates( update.result, "ui.overlay-shell-run-updates", ); + const acceptedOwnership = merged.result.revision === update.result.revision; byToolUseId.set(update.sourceToolCallId, { result: merged.result, - source: + source: acceptedOwnership + ? ( !isActiveShellRunStatus(merged.result.status) || update.ownership.kind === "local" ? undefined : update.ownership.kind === "source_owned" ? "owned" - : "unavailable", + : "unavailable" + ) + : current?.source, }); } return byToolUseId; @@ -617,11 +617,36 @@ export function applyShellRunOverlayEntry( entry.result, "ui.overlay-shell-run-update", ); - return merged.changed || tool.shellRunSource !== entry.source - ? { ...tool, result: merged.result, shellRunSource: entry.source } + const source = merged.result.revision === entry.result.revision + ? entry.source + : tool.shellRunSource; + return merged.changed || tool.shellRunSource !== source + ? { ...tool, result: merged.result, shellRunSource: source } : tool; } +/** Presentation is derived from invocation and resource facts, never persisted as another state. */ +export function toolActivityPresentationStatus(item: ToolActivityItem): ToolActivityStatus { + if (item.status === "errored") return "errored"; + if (item.toolName === "Bash" && item.result?.kind === "shell_run") { + return SHELL_RUN_PRESENTATION_STATUS[item.result.status]; + } + return item.status; +} + +const SHELL_RUN_PRESENTATION_STATUS = { + starting: "running", + running: "running", + completed: "completed", + cancelled: "interrupted", + failed: "errored", + timed_out: "errored", + orphaned: "errored", +} as const satisfies Record< + Extract["status"], + ToolActivityStatus +>; + /** * Group materialized chat + tool items by `turnId` into ordered turns. Items * without a turnId (e.g. fake-backend echo, or older sessions) fall into a diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index b6e07fd543..9b7d60c171 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -31,7 +31,11 @@ import { } from './icons.js'; import { useClipboardCopyFeedback } from './clipboard-feedback.js'; import { useUiLocale } from './locale-context.js'; -import { type ToolActivityItem, type ToolOutputChunk } from './materialize.js'; +import { + toolActivityPresentationStatus, + type ToolActivityItem, + type ToolOutputChunk, +} from './materialize.js'; import { isConnectorTool, resolveToolDisplayName } from './tool-activity/display-name.js'; import { computerActionLabel, @@ -128,7 +132,7 @@ export function ToolCallDetail({ // Cancel is not a failure; stale errored+cancelled must not paint as failed. const failedOutcome = item.status === 'errored' && !cancelled; const permissionDenied = isPermissionDeniedToolResult(item.result); - const running = isInFlightToolStatus(item.status); + const running = isInFlightToolStatus(toolActivityPresentationStatus(item)); const outputActionIdentity = [ computerActionLabel(item, locale) ?? resolveToolDisplayName(item, locale), item.intent ? formatToolIntent(item.intent) : undefined, @@ -306,7 +310,7 @@ export function ToolTrow({ export function toolTrowHasVisibleSpinner(items: readonly ToolActivityItem[]): boolean { return items.some((item, index) => !isLinkedAgentResult(item.result) - && isInFlightToolStatus(item.status) + && isInFlightToolStatus(toolActivityPresentationStatus(item)) && (index === items.length - 1 || isLinkedAgentResult(items[index + 1]?.result)), ); } @@ -432,7 +436,7 @@ function standardToolCall( target: item.intent ? formatToolIntent(item.intent) : inferredTarget, duration: formatDuration(item.durationMs) ?? undefined, errorMessage: toolCallErrorMessage(item, locale), - stats: item.progress && isInFlightToolStatus(item.status) + stats: item.progress && isInFlightToolStatus(toolActivityPresentationStatus(item)) ? `${item.progress.current}/${item.progress.total}` : outcomeWord(item, locale), ...diffStats(itemDiffs(item)), @@ -546,7 +550,7 @@ function itemDiffs(item: ToolActivityItem): string[] { } function astryxToolStatus(item: ToolActivityItem): ChatToolCallItem['status'] { - switch (item.status) { + switch (toolActivityPresentationStatus(item)) { case 'completed': return 'complete'; case 'errored': case 'interrupted': return 'error'; From fc30892351f9b5b96dea53b6d598a183ed98f96d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 23:42:20 +0800 Subject: [PATCH 13/13] style(cli): format transcript probes --- packages/cli/src/__tests__/pi-transcript.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index e6acead48b..72003776aa 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -2681,7 +2681,12 @@ describe('Maka Pi TUI transcript', () => { const ref = 'maka://runtime/background-tasks/bg-1'; applyMakaSessionEventToTranscript( state, - event({ type: 'tool_start', toolUseId: 'bash-bg', toolName: 'Bash', args: { command: 'build' } }), + event({ + type: 'tool_start', + toolUseId: 'bash-bg', + toolName: 'Bash', + args: { command: 'build' }, + }), ); applyShellRunViewUpdateToTranscript(state, { sessionId: 'session-1', @@ -2702,7 +2707,9 @@ describe('Maka Pi TUI transcript', () => { result: shellRun({ ref, status: 'running', revision: 4, updatedAt: 4 }), }); - const bash = state.entries.find((entry) => entry.kind === 'tool' && entry.toolUseId === 'bash-bg'); + const bash = state.entries.find( + (entry) => entry.kind === 'tool' && entry.toolUseId === 'bash-bg', + ); assert.equal( bash?.kind === 'tool' && bash.result?.kind === 'shell_run' ? bash.result.revision : undefined, 5,