From e39eeb3c228fa8112e6fab3c4dbd407207ec3e0c Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 30 Aug 2026 22:35:53 +0800 Subject: [PATCH 01/13] feat(runtime): add assistant commentary phases Normalize model-authored progress and final-answer text across provider, persistence, Runtime Host, CLI, TUI, and UI boundaries. Infer phases for providers without native support and preserve explicit OpenAI Responses phases. Generated-by: OpenAI Codex --- .../cli/src/__tests__/pi-transcript.test.ts | 25 ++ .../runtime-host-run-command.test.ts | 119 ++++++ packages/cli/src/pi-transcript.ts | 39 +- packages/cli/src/runtime-host-run-command.ts | 36 +- .../core/src/__tests__/runtime-event.test.ts | 36 ++ packages/core/src/events.ts | 19 + packages/core/src/runtime-event.ts | 7 + packages/core/src/session.ts | 7 +- .../src/__tests__/protocol.test.ts | 64 ++++ .../session-continuity-coordinator.test.ts | 16 +- .../src/__tests__/session-projector.test.ts | 53 ++- .../src/adapter/session-projector.ts | 146 +++++-- packages/runtime-host/src/protocol/index.ts | 3 +- .../src/protocol/session-continuity.ts | 43 ++- .../server/session-continuity-coordinator.ts | 17 +- .../src/__tests__/ai-sdk-backend.test.ts | 357 ++++++++++++++++++ .../src/__tests__/main-session-prompt.test.ts | 33 ++ .../src/__tests__/model-adapter.test.ts | 39 ++ .../session-event-runtime-mapper.test.ts | 42 +++ .../src/__tests__/session-manager.test.ts | 173 +++++++++ packages/runtime/src/ai-sdk-backend.ts | 105 +++++- packages/runtime/src/model-adapter.ts | 23 +- packages/runtime/src/model-protocol.ts | 9 +- .../runtime/src/runtime-event-backfill.ts | 9 +- .../runtime/src/runtime-event-read-model.ts | 1 + .../src/session-event-runtime-mapper.ts | 7 +- packages/runtime/src/session-manager.ts | 54 ++- .../src/system-prompt/main-session-prompt.ts | 18 +- .../__tests__/codex-session-adapter.test.ts | 5 + packages/storage/src/codex-session-adapter.ts | 10 + .../__tests__/live-turn-projection.test.ts | 24 ++ packages/ui/src/__tests__/materialize.test.ts | 88 +++++ packages/ui/src/live-turn-projection.ts | 8 + packages/ui/src/materialize.ts | 14 +- 34 files changed, 1545 insertions(+), 104 deletions(-) create mode 100644 packages/runtime/src/__tests__/main-session-prompt.test.ts diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b74ed7bdd2..4b8b6501b9 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -774,6 +774,31 @@ describe('Maka Pi TUI transcript', () => { assert.match(renderMakaPiTranscript(state, meta(), 80).map(stripAnsi).join('\n'), /final/); }); + test('keeps the assistant text phase when completion replaces streamed text', () => { + const state = createMakaPiTranscriptState(); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'text_delta', + messageId: 'message-1', + text: 'checking', + phase: 'commentary', + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'text_complete', + messageId: 'message-1', + text: 'checking the repository', + phase: 'commentary', + }), + ); + + const entry = state.entries[0]; + assert.equal(entry?.kind === 'assistant' ? entry.phase : undefined, 'commentary'); + }); + test('allows text_complete to replace streamed assistant text with empty text', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript( 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 7f5dc70c83..6c7bb0627a 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -237,6 +237,32 @@ describe('Runtime Host maka run adapter', () => { assert.equal(stderr.join(''), 'maka run: Turn failed\n'); }); + test('prints only final-answer text when a Turn also emits commentary', async () => { + const stdout: string[] = []; + const fixture = runFixture({ + turnEvents: commentaryThenFinalEvents('turn-1'), + }); + const exitCode = await runFixtureCommand(fixture, ['inspect and answer'], (text) => + stdout.push(text), + ); + + assert.equal(exitCode, 0); + assert.equal(stdout.join(''), 'The implementation is ready.\n'); + }); + + test('prefers an explicit final answer over a later unphased compatibility message', async () => { + const stdout: string[] = []; + const fixture = runFixture({ + turnEvents: explicitFinalThenLegacyEvents('turn-1'), + }); + const exitCode = await runFixtureCommand(fixture, ['inspect and answer'], (text) => + stdout.push(text), + ); + + assert.equal(exitCode, 0); + assert.equal(stdout.join(''), 'The implementation is ready.\n'); + }); + test('returns exit code 1 when a same-step sibling succeeds after a sandbox failure', async () => { const stdout: string[] = []; const stderr: string[] = []; @@ -437,6 +463,56 @@ describe('Runtime Host maka run adapter', () => { assert.equal(observed.at(-1)?.finalOutput, 'Final graph answer'); }); + test('ignores durable commentary when selecting a Graph final answer', async () => { + const stdout: string[] = []; + const finalMessages = graphMessages(); + finalMessages.splice(finalMessages.length - 1, 0, { + type: 'assistant', + id: 'assistant-commentary-after-final', + turnId: 'turn-2', + ts: 4.5, + text: 'I am still checking.', + phase: 'commentary', + modelId: 'gpt-5', + }); + const fixture = runFixture({ graph: true, finalMessages }); + const exitCode = await runFixtureCommand(fixture, ['delegate once', '--graph'], (text) => + stdout.push(text), + ); + + assert.equal(exitCode, 0); + assert.equal(stdout.join(''), 'Final graph answer\n'); + }); + + test('prefers a durable explicit final answer over later unphased compatibility text', async () => { + const stdout: string[] = []; + const finalMessages = graphMessages(); + finalMessages.splice(finalMessages.length - 1, 0, { + type: 'assistant', + id: 'assistant-explicit-final', + turnId: 'turn-2', + ts: 4.5, + text: 'Explicit graph answer', + phase: 'final_answer', + modelId: 'gpt-5', + }); + finalMessages.splice(finalMessages.length - 1, 0, { + type: 'assistant', + id: 'assistant-legacy-after-final', + turnId: 'turn-2', + ts: 4.6, + text: 'Legacy compatibility text', + modelId: 'gpt-5', + }); + const fixture = runFixture({ graph: true, finalMessages }); + const exitCode = await runFixtureCommand(fixture, ['delegate once', '--graph'], (text) => + stdout.push(text), + ); + + assert.equal(exitCode, 0); + assert.equal(stdout.join(''), 'Explicit graph answer\n'); + }); + test('reports a recovered sandbox boundary from live and durable Turns', async () => { const live = await observeFixtureOutcome({ turnEvents: sandboxBoundaryEvents('turn-1', 'step-1', 'step-2', 'Recovered answer'), @@ -1467,6 +1543,49 @@ async function* eventsFor(turnId: string, text: string, ts = 1): AsyncIterable { + yield { + type: 'text_complete', + id: `${turnId}-commentary`, + turnId, + messageId: `${turnId}-commentary-message`, + ts: 1, + text: 'I am checking the implementation.', + phase: 'commentary', + }; + yield { + type: 'text_complete', + id: `${turnId}-final`, + turnId, + messageId: `${turnId}-final-message`, + ts: 2, + text: 'The implementation is ready.', + phase: 'final_answer', + }; + yield { type: 'complete', id: `${turnId}-complete`, turnId, ts: 3, stopReason: 'end_turn' }; +} + +async function* explicitFinalThenLegacyEvents(turnId: string): AsyncIterable { + yield { + type: 'text_complete', + id: `${turnId}-final`, + turnId, + messageId: `${turnId}-final-message`, + ts: 1, + text: 'The implementation is ready.', + phase: 'final_answer', + }; + yield { + type: 'text_complete', + id: `${turnId}-legacy`, + turnId, + messageId: `${turnId}-legacy-message`, + ts: 2, + text: 'Legacy compatibility text', + }; + yield { type: 'complete', id: `${turnId}-complete`, turnId, ts: 3, stopReason: 'end_turn' }; +} + async function* eventsAfterTranscriptReplacement(publish: () => void): AsyncIterable { publish(); yield* eventsFor('turn-1', 'Incomplete answer', 3); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index bbbec37545..d102125340 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -19,6 +19,7 @@ import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; import type { + AssistantTextPhase, ProviderRetryEvent, ProviderRetryScheduledEvent, SandboxBoundaryRequestEvent, @@ -164,7 +165,7 @@ export type MakaPiTranscriptEntry = | { kind: 'user'; messageId: string; text: string; transient?: boolean } | { kind: 'legacy_automation'; text: string } | { kind: 'goal_continuation'; text: string } - | { kind: 'assistant'; messageId: string; text: string } + | { kind: 'assistant'; messageId: string; text: string; phase?: AssistantTextPhase } | { kind: 'thinking'; messageId: string; text: string; expanded: boolean } | { kind: 'tool'; @@ -788,12 +789,12 @@ export function applyMakaSessionEventToTranscript( } switch (event.type) { case 'text_delta': - appendAssistantText(state, event.messageId, event.text); + appendAssistantText(state, event.messageId, event.text, event.phase); break; case 'text_complete': - if (!setAssistantText(state, event.messageId, event.text) && event.text) { - appendAssistantText(state, event.messageId, event.text); + if (!setAssistantText(state, event.messageId, event.text, event.phase) && event.text) { + appendAssistantText(state, event.messageId, event.text, event.phase); } break; @@ -1087,7 +1088,12 @@ function storedMessagesToTranscriptEntries( expanded: false, }); } - entries.push({ kind: 'assistant', messageId: message.id, text: message.text }); + entries.push({ + kind: 'assistant', + messageId: message.id, + text: message.text, + ...(message.phase !== undefined ? { phase: message.phase } : {}), + }); break; } case 'tool_call': @@ -1934,20 +1940,37 @@ function formatCost(costUsd: number): string { return costUsd.toFixed(2); } -function appendAssistantText(state: MakaPiTranscriptState, messageId: string, text: string): void { +function appendAssistantText( + state: MakaPiTranscriptState, + messageId: string, + text: string, + phase?: AssistantTextPhase, +): void { const last = state.entries[state.entries.length - 1]; if (last?.kind === 'assistant' && last.messageId === messageId) { last.text += text; + if (phase !== undefined) last.phase = phase; return; } - state.entries.push({ kind: 'assistant', messageId, text }); + state.entries.push({ + kind: 'assistant', + messageId, + text, + ...(phase !== undefined ? { phase } : {}), + }); } -function setAssistantText(state: MakaPiTranscriptState, messageId: string, text: string): boolean { +function setAssistantText( + state: MakaPiTranscriptState, + messageId: string, + text: string, + phase?: AssistantTextPhase, +): boolean { for (let index = state.entries.length - 1; index >= 0; index -= 1) { const entry = state.entries[index]; if (entry?.kind === 'assistant' && entry.messageId === messageId) { entry.text = text; + if (phase !== undefined) entry.phase = phase; return true; } } diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index 62f8536e42..f4dcfd701b 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -17,7 +17,11 @@ * under the License. */ -import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events'; +import { + failureClassFromCompleteStopReason, + type AssistantTextPhase, + type SessionEvent, +} from '@maka/core/events'; import { findProjectByIdentity } from '@maka/core/project'; import { type StoredMessage } from '@maka/core/session'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; @@ -554,7 +558,7 @@ function runtimeHostSessionSummaries(items: readonly SessionCatalogItem[]): Sess } type TurnOutcomeObservation = - | { readonly kind: 'output'; readonly text: string } + | { readonly kind: 'output'; readonly text: string; readonly phase?: AssistantTextPhase } | { readonly kind: 'terminal'; readonly update: 'replace' | 'if_unset'; @@ -590,7 +594,8 @@ class TurnOutcomeClassifier { string, { readonly failedStepId: string | undefined } >(); - #finalOutput: string | undefined; + #explicitFinalOutput: string | undefined; + #legacyFinalOutput: string | undefined; #terminal: TerminalOutcomeObservation | undefined; #sandboxBoundaryRecovered = false; @@ -603,7 +608,11 @@ class TurnOutcomeClassifier { case undefined: return; case 'output': - this.#finalOutput = observation.text; + if (observation.phase === 'final_answer') { + this.#explicitFinalOutput = observation.text; + } else { + this.#legacyFinalOutput = observation.text; + } return; case 'terminal': if (observation.update === 'replace' || this.#terminal === undefined) { @@ -649,6 +658,7 @@ class TurnOutcomeClassifier { const terminal = this.#terminal; if (!terminal && incomplete === 'pending') return undefined; const completed = terminal?.status === 'completed'; + const finalOutput = this.#explicitFinalOutput ?? this.#legacyFinalOutput; const sandboxBoundary = this.#unresolvedSandboxFailures.size > 0 ? 'unresolved' @@ -665,7 +675,7 @@ class TurnOutcomeClassifier { return { outcomeId: this.#outcomeId, status: completed ? 'completed' : 'failed', - ...(completed && this.#finalOutput !== undefined ? { finalOutput: this.#finalOutput } : {}), + ...(completed && finalOutput !== undefined ? { finalOutput } : {}), ...(!completed ? { failure } : {}), sandboxBoundary, }; @@ -673,8 +683,12 @@ class TurnOutcomeClassifier { } function observationFromSessionEvent(event: SessionEvent): TurnOutcomeObservation | undefined { - if (event.type === 'text_complete' && event.text.trim().length > 0) { - return { kind: 'output', text: event.text }; + if ( + event.type === 'text_complete' && + event.phase !== 'commentary' && + event.text.trim().length > 0 + ) { + return { kind: 'output', text: event.text, phase: event.phase }; } if (event.type === 'error') { return { @@ -707,8 +721,12 @@ function observationFromSessionEvent(event: SessionEvent): TurnOutcomeObservatio } function observationFromStoredMessage(message: StoredMessage): TurnOutcomeObservation | undefined { - if (message.type === 'assistant' && message.text.trim().length > 0) { - return { kind: 'output', text: message.text }; + if ( + message.type === 'assistant' && + message.phase !== 'commentary' && + message.text.trim().length > 0 + ) { + return { kind: 'output', text: message.text, phase: message.phase }; } if (message.type === 'turn_state' && message.status === 'completed') { return { kind: 'terminal', update: 'replace', status: 'completed' }; diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index b0e13b3ba7..c62e11be3a 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -87,6 +87,42 @@ test('Stored assistant reasoning parts survive recovery decoding', () => { assert.deepEqual(stored.thinking?.parts, parts); }); +test('assistant text phase survives canonical decoding and rejects unknown values', () => { + const message = decodeCanonicalMessage({ + type: 'assistant', + id: 'message-1', + turnId: 'turn-1', + ts: 1, + text: 'I am checking the repository now.', + phase: 'commentary', + modelId: 'gpt-5.4', + }); + assert.equal(message.type === 'assistant' ? message.phase : undefined, 'commentary'); + + const event = decodeRuntimeEvent( + baseEvent({ + content: { + kind: 'text', + text: 'The change is complete.', + phase: 'final_answer', + }, + }), + ); + assert.equal(event.content?.kind === 'text' ? event.content.phase : undefined, 'final_answer'); + + assert.throws(() => + decodeCanonicalMessage({ + type: 'assistant', + id: 'message-2', + turnId: 'turn-1', + ts: 2, + text: 'hidden reasoning', + phase: 'analysis', + modelId: 'gpt-5.4', + }), + ); +}); + test('decodes released Automation origins as read-only legacy provenance', () => { const message = decodeCanonicalMessage({ type: 'user', diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 386b5c598b..40e061f2c2 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -68,6 +68,21 @@ export const TOOL_ACTIVITY_KINDS = [ 'tool', ] as const; export type ToolActivityKind = (typeof TOOL_ACTIVITY_KINDS)[number]; +export const ASSISTANT_TEXT_PHASES = ['commentary', 'final_answer'] as const; +/** + * Maka-owned assistant text semantics. + * + * OpenAI Responses can provide this explicitly. Phase-less protocols such as + * Chat Completions and Anthropic Messages are normalized by the Runtime from + * the response shape: text followed by a client tool call is commentary, while + * terminal text is a final answer. + */ +export type AssistantTextPhase = (typeof ASSISTANT_TEXT_PHASES)[number]; + +export function isAssistantTextPhase(value: unknown): value is AssistantTextPhase { + return typeof value === 'string' && (ASSISTANT_TEXT_PHASES as readonly string[]).includes(value); +} + type TerminalToolResultStatus = Exclude; // ============================================================================ @@ -539,6 +554,8 @@ export type SessionEvent = export interface TextDeltaEvent extends BaseEvent { type: 'text_delta'; messageId: string; + /** User-visible role when known before or during streaming. */ + phase?: AssistantTextPhase; /** Absolute UTF-16 offset for replay-safe streams; absent for append-only backends. */ startOffset?: number; text: string; @@ -548,6 +565,8 @@ export interface TextCompleteEvent extends BaseEvent { type: 'text_complete'; messageId: string; text: string; + /** User-visible role of this assistant text after runtime normalization. */ + phase?: AssistantTextPhase; /** Provider-owned text metadata such as Responses URL citations. */ providerOptions?: Record; } diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 644f3c89fe..c8a41a2aae 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -35,7 +35,9 @@ import { isMessageContent, + isAssistantTextPhase, normalizeMessageContent, + type AssistantTextPhase, type MessageContent, type PermissionClosureReason, } from './events.js'; @@ -144,6 +146,8 @@ export function isTerminalRuntimeEventStatus(value: unknown): boolean { export interface RuntimeEventTextContent extends MessageContent { kind: 'text'; + /** User-visible role of model-authored text; absent on user and legacy events. */ + phase?: AssistantTextPhase; /** Provider-owned text metadata such as Responses URL citations. */ providerOptions?: Record; /** Durable provenance for a host-authored user-role turn. */ @@ -535,6 +539,7 @@ const TEXT_CONTENT_SHAPE = defineObjectShape()( 'quotes', 'inlineReferences', 'steering', + 'phase', 'providerOptions', ], ); @@ -727,6 +732,7 @@ export function decodeRuntimeEvent(value: unknown): RuntimeEvent { ? { origin: decodeTurnOrigin(value.content.origin) } : {}), ...(value.content.steering === true ? { steering: true as const } : {}), + ...(value.content.phase !== undefined ? { phase: value.content.phase } : {}), }, } as unknown as RuntimeEvent; } @@ -741,6 +747,7 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { !hasExactShape(value, TEXT_CONTENT_SHAPE) || (value.origin !== undefined && !isTurnOrigin(value.origin)) || (value.steering !== undefined && value.steering !== true) || + (value.phase !== undefined && !isAssistantTextPhase(value.phase)) || (value.providerOptions !== undefined && !isRecord(value.providerOptions)) ) { return false; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index cbfc7efec3..1309570f4a 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -19,7 +19,9 @@ import { decodeMessageContent, + isAssistantTextPhase, TOOL_ACTIVITY_KINDS, + type AssistantTextPhase, type MessageContent, type ToolActivityKind, type ToolResultContent, @@ -797,6 +799,8 @@ export interface AssistantMessage { turnId: string; ts: number; text: string; + /** User-visible role of this model-authored text. */ + phase?: AssistantTextPhase; /** Provider-owned text metadata such as Responses URL citations. */ providerOptions?: Record; thinking?: AssistantThinking; @@ -1020,7 +1024,7 @@ const USER_MESSAGE_SHAPE = defineObjectShape()( ); const ASSISTANT_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'text', 'modelId'], - ['thinking', 'contentOrder', 'providerOptions'], + ['thinking', 'contentOrder', 'phase', 'providerOptions'], ); const TOOL_CALL_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'toolName', 'args'], @@ -1195,6 +1199,7 @@ function decodeMessage( hasMessageEnvelope(message, true) && typeof message.text === 'string' && typeof message.modelId === 'string' && + (message.phase === undefined || isAssistantTextPhase(message.phase)) && (message.providerOptions === undefined || isRecord(message.providerOptions)) && (message.thinking === undefined || isAssistantThinking(message.thinking)) && (message.contentOrder === undefined || diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 81bcfd7543..0e0cd1ecb8 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -381,6 +381,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56); }); + test('publishes a new compatibility epoch for assistant text phases', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 76); + }); + 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); @@ -404,6 +408,37 @@ describe('Runtime Host bootstrap protocol', () => { }, }; assert.deepEqual(decodeHostFrame(opened), opened); + const commentaryOpen = { + ...opened, + result: { + ...opened.result, + activeAssistantStreams: [ + { + kind: 'text' as const, + turnId: 'turn-1', + messageId: 'message-commentary', + phase: 'commentary' as const, + }, + ], + }, + }; + assert.deepEqual(decodeHostFrame(commentaryOpen), commentaryOpen); + assert.throws( + () => + decodeHostFrame({ + ...commentaryOpen, + result: { + ...commentaryOpen.result, + activeAssistantStreams: [ + { + ...commentaryOpen.result.activeAssistantStreams[0], + phase: 'analysis', + }, + ], + }, + }), + isInvalidFrame, + ); assert.throws( () => decodeHostFrame({ @@ -710,6 +745,35 @@ describe('Runtime Host bootstrap protocol', () => { }, }; assert.deepEqual(decodeHostFrame(completion), completion); + const commentary = { + ...completion, + delta: { + kind: 'text' as const, + turnId: 'turn-1', + runId: 'run-1', + messageId: 'message-commentary', + startOffset: 0, + text: 'checking', + phase: 'commentary' as const, + }, + }; + assert.deepEqual(decodeHostFrame(commentary), commentary); + assert.throws( + () => + decodeHostFrame({ + ...commentary, + delta: { ...commentary.delta, phase: 'analysis' }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...completion, + delta: { ...completion.delta, phase: 'commentary' }, + }), + isInvalidFrame, + ); const replacement = { ...completion, delta: { 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 04b4852b31..24ce0a8a25 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -282,7 +282,10 @@ test('open identifies every assistant stream that is still active and round-trip ); coordinator.attachConnection('connection-1', new RecordingSink()); await open(coordinator, 'connection-1'); - await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(1)); + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { + ...textEvent(1), + phase: 'commentary', + }); await coordinator.acceptRuntimeEvent( SESSION_ID, 'run-1', @@ -296,7 +299,7 @@ test('open identifies every assistant stream that is still active and round-trip coordinator.attachConnection('connection-2', new RecordingSink()); const active = await open(coordinator, 'connection-2'); assert.deepEqual(active.activeAssistantStreams, [ - { kind: 'text', turnId: 'turn-1', messageId: 'message-1' }, + { kind: 'text', turnId: 'turn-1', messageId: 'message-1', phase: 'commentary' }, { kind: 'thinking', turnId: 'turn-1', messageId: 'message-2' }, { kind: 'text', turnId: 'turn-1', messageId: 'message-3' }, ]); @@ -315,11 +318,10 @@ test('open identifies every assistant stream that is still active and round-trip if (!('ok' in decoded) || !decoded.ok || decoded.operation !== 'subscription.open') return; assert.deepEqual(decoded.result.activeAssistantStreams, active.activeAssistantStreams); - await coordinator.acceptRuntimeEvent( - SESSION_ID, - 'run-1', - textCompleteEvent('message-1', 'chunk-1'), - ); + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { + ...textCompleteEvent('message-1', 'chunk-1'), + phase: 'commentary', + }); coordinator.attachConnection('connection-3', new RecordingSink()); const remaining = await open(coordinator, 'connection-3'); assert.deepEqual(remaining.activeAssistantStreams, [ diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 5fbcce8395..122dbbf306 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -74,6 +74,57 @@ test('applies authoritative replacement once and does not complete it again at T ); }); +test('preserves commentary phase across active stream seeding and completion', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed( + [{ ...assistant('message-1', 'checking'), phase: 'commentary' }], + snapshot(), + ), + () => 10, + [ + { + kind: 'text', + turnId: 'turn-1', + messageId: 'message-1', + phase: 'commentary', + }, + ], + ); + + assert.deepEqual(projector.seedActive(true), [ + { + type: 'text_delta', + id: 'host-seed:run-1:text:message-1', + turnId: 'turn-1', + messageId: 'message-1', + ts: 10, + startOffset: 0, + text: 'checking', + phase: 'commentary', + }, + ]); + assert.deepEqual( + projector.accept( + deltaFrame(1, 'checking'.length, ' the repository', { + complete: true, + phase: 'commentary', + }), + ).events, + [ + { + type: 'text_complete', + id: 'host-frame:host-1:subscription-1:1', + turnId: 'turn-1', + messageId: 'message-1', + ts: 10, + text: 'checking the repository', + phase: 'commentary', + }, + ], + ); +}); + test('keeps a revocable in-flight lease pending', () => { const previous = snapshot({ queue: { @@ -732,7 +783,7 @@ function deltaFrame( sequence: number, startOffset: number, text: string, - flags: { reset?: true; complete?: true } = {}, + flags: { reset?: true; complete?: true; phase?: 'commentary' | 'final_answer' } = {}, ): SubscriptionFrame { return { kind: 'subscription.session_delta', diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 0288be35ef..82f52a1a30 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -18,7 +18,11 @@ */ import { isDeepStrictEqual } from 'node:util'; -import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events'; +import type { + ActiveInteractionRequestEvent, + AssistantTextPhase, + SessionEvent, +} from '@maka/core/events'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; import type { InteractionPendingSnapshot, @@ -38,6 +42,7 @@ interface AssistantAccumulator { turnId: string; messageId: string; text: string; + phase?: AssistantTextPhase; complete: boolean; replacing: boolean; } @@ -128,6 +133,7 @@ export class RuntimeHostSessionProjector { turnId: root.turnId, messageId: message.id, text: message.text, + ...(message.phase !== undefined ? { phase: message.phase } : {}), complete: true, replacing: false, }); @@ -137,11 +143,13 @@ export class RuntimeHostSessionProjector { if (stream.turnId !== root.turnId) continue; const key = accumulatorKey(stream.kind, stream.messageId); const current = this.#accumulators.get(key); + const phase = stream.kind === 'text' ? (stream.phase ?? current?.phase) : undefined; this.#accumulators.set(key, { kind: stream.kind, turnId: stream.turnId, messageId: stream.messageId, text: current?.text ?? '', + ...(phase !== undefined ? { phase } : {}), complete: false, replacing: false, }); @@ -177,15 +185,18 @@ export class RuntimeHostSessionProjector { for (const accumulator of this.#accumulators.values()) { if (accumulator.complete) continue; seededAssistantText = true; - events.push({ - type: accumulator.kind === 'text' ? 'text_delta' : 'thinking_delta', - id: `host-seed:${root.runId}:${accumulator.kind}:${accumulator.messageId}`, - turnId: accumulator.turnId, - messageId: accumulator.messageId, - ts: this.#now(), - startOffset: 0, - text: accumulator.text, - }); + events.push( + assistantDeltaEvent({ + kind: accumulator.kind, + phase: accumulator.phase, + id: `host-seed:${root.runId}:${accumulator.kind}:${accumulator.messageId}`, + turnId: accumulator.turnId, + messageId: accumulator.messageId, + ts: this.#now(), + startOffset: 0, + text: accumulator.text, + }), + ); } } if (root.providerRetry && !seededAssistantText) { @@ -352,33 +363,41 @@ export class RuntimeHostSessionProjector { const current = this.#accumulators.get(key); const folded = foldRuntimeHostAssistantDelta(delta.reset ? '' : (current?.text ?? ''), delta); const replacing = delta.reset === true || (current?.replacing ?? false); + const phase = delta.kind === 'text' ? (delta.phase ?? current?.phase) : undefined; this.#accumulators.set(key, { kind: delta.kind, turnId: delta.turnId, messageId: delta.messageId, text: folded.text, + ...(phase !== undefined ? { phase } : {}), complete: delta.complete === true, replacing: delta.complete === true ? false : replacing, }); if (delta.complete === true) { - events.push({ - type: delta.kind === 'text' ? 'text_complete' : 'thinking_complete', - id: frameIdentity(frame), - turnId: delta.turnId, - messageId: delta.messageId, - ts: this.#now(), - text: folded.text, - }); + events.push( + assistantCompleteEvent({ + kind: delta.kind, + phase, + id: frameIdentity(frame), + turnId: delta.turnId, + messageId: delta.messageId, + ts: this.#now(), + text: folded.text, + }), + ); } else if (folded.tail && !replacing) { - events.push({ - type: delta.kind === 'text' ? 'text_delta' : 'thinking_delta', - id: frameIdentity(frame), - turnId: delta.turnId, - messageId: delta.messageId, - ts: this.#now(), - startOffset: folded.text.length - folded.tail.length, - text: folded.tail, - }); + events.push( + assistantDeltaEvent({ + kind: delta.kind, + phase, + id: frameIdentity(frame), + turnId: delta.turnId, + messageId: delta.messageId, + ts: this.#now(), + startOffset: folded.text.length - folded.tail.length, + text: folded.tail, + }), + ); } return emptyUpdate(events); } @@ -457,14 +476,17 @@ export class RuntimeHostSessionProjector { const events: SessionEvent[] = []; for (const accumulator of this.#accumulators.values()) { if (accumulator.turnId !== root.turnId || (!includeSettled && accumulator.complete)) continue; - events.push({ - type: accumulator.kind === 'text' ? 'text_complete' : 'thinking_complete', - id: `${root.terminalEventId}:${accumulator.kind}:${accumulator.messageId}`, - turnId: root.turnId, - messageId: accumulator.messageId, - ts: this.#now(), - text: accumulator.text, - }); + events.push( + assistantCompleteEvent({ + kind: accumulator.kind, + phase: accumulator.phase, + id: `${root.terminalEventId}:${accumulator.kind}:${accumulator.messageId}`, + turnId: root.turnId, + messageId: accumulator.messageId, + ts: this.#now(), + text: accumulator.text, + }), + ); } if (root.status === 'completed') { events.push({ @@ -657,6 +679,60 @@ function projectSessionEvent( }; } +function assistantDeltaEvent(input: { + kind: 'text' | 'thinking'; + phase?: AssistantTextPhase; + id: string; + turnId: string; + messageId: string; + ts: number; + startOffset: number; + text: string; +}): SessionEvent { + const base = { + id: input.id, + turnId: input.turnId, + messageId: input.messageId, + ts: input.ts, + startOffset: input.startOffset, + text: input.text, + }; + if (input.kind === 'text') { + return { + type: 'text_delta', + ...base, + ...(input.phase !== undefined ? { phase: input.phase } : {}), + }; + } + return { type: 'thinking_delta', ...base }; +} + +function assistantCompleteEvent(input: { + kind: 'text' | 'thinking'; + phase?: AssistantTextPhase; + id: string; + turnId: string; + messageId: string; + ts: number; + text: string; +}): SessionEvent { + const base = { + id: input.id, + turnId: input.turnId, + messageId: input.messageId, + ts: input.ts, + text: input.text, + }; + if (input.kind === 'text') { + return { + type: 'text_complete', + ...base, + ...(input.phase !== undefined ? { phase: input.phase } : {}), + }; + } + return { type: 'thinking_complete', ...base }; +} + export function foldRuntimeHostAssistantDelta( current: string, delta: Pick, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 0092796aee..21539e5830 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,8 @@ 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 = 76 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 77 as const; +// 77: live assistant text streams carry the optional commentary/final-answer phase // 76: Peer Mesh endpoint and Mesh display names are signed, persisted facts // managed through Host operations rather than local-only Client labels. // 75: Peer Mesh routes identify whether a peer is a Client or Runtime Host so diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 5cd42d7927..ce8306258c 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -17,8 +17,16 @@ * under the License. */ -import { TOOL_ACTIVITY_KINDS, TOOL_OUTPUT_DELTA_MAX_CHARS } from '@maka/core/events'; -import type { SandboxBoundaryFailureSignal, ToolResultPreviewContent } from '@maka/core/events'; +import { + isAssistantTextPhase, + TOOL_ACTIVITY_KINDS, + TOOL_OUTPUT_DELTA_MAX_CHARS, +} from '@maka/core/events'; +import type { + AssistantTextPhase, + SandboxBoundaryFailureSignal, + ToolResultPreviewContent, +} from '@maka/core/events'; import { decodeToolResultPreviewContent } from '@maka/core/tool-result-preview'; import type { ToolActivityKind } from '@maka/core/events'; import type { SessionStatus } from '@maka/core/session'; @@ -29,6 +37,7 @@ import { requireExactRecord, requireId, requireRecord, + requireShapedRecord, } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; import { decodeSessionStatus } from './session-status.js'; @@ -120,6 +129,7 @@ export interface SessionAssistantStreamIdentity { kind: 'text' | 'thinking'; turnId: string; messageId: string; + phase?: AssistantTextPhase; } export interface SubscriptionCloseInput { @@ -148,6 +158,7 @@ export interface SessionAssistantDelta { messageId: string; startOffset: number; text: string; + phase?: AssistantTextPhase; reset?: true; complete?: true; } @@ -650,11 +661,12 @@ function decodeActiveAssistantStreams( } const root = snapshot.rootTurn; const identities = value.map((candidate): SessionAssistantStreamIdentity => { - const record = requireExactRecord(candidate, 'active Session assistant stream', [ - 'kind', - 'turnId', - 'messageId', - ]); + const record = requireShapedRecord( + candidate, + 'active Session assistant stream', + ['kind', 'turnId', 'messageId'], + ['phase'], + ); if (record.kind !== 'text' && record.kind !== 'thinking') { throw invalidProtocolFrame('Invalid active Session assistant stream kind'); } @@ -663,7 +675,16 @@ function decodeActiveAssistantStreams( kind, turnId: requireEntityId(record.turnId, 'turnId'), messageId: requireEntityId(record.messageId, 'messageId'), + ...(record.phase !== undefined && isAssistantTextPhase(record.phase) + ? { phase: record.phase } + : {}), }; + if ( + (record.phase !== undefined && !isAssistantTextPhase(record.phase)) || + (kind === 'thinking' && record.phase !== undefined) + ) { + throw invalidProtocolFrame('Invalid active Session assistant stream phase'); + } if ( !root || root.status === 'completed' || @@ -709,6 +730,7 @@ function decodeAssistantDelta(value: unknown): SessionAssistantDelta { 'messageId', 'startOffset', 'text', + 'phase', 'reset', 'complete', ]); @@ -723,6 +745,12 @@ function decodeAssistantDelta(value: unknown): SessionAssistantDelta { if (record.kind !== 'text' && record.kind !== 'thinking') { throw invalidProtocolFrame('Invalid Session assistant delta kind'); } + if ( + (record.phase !== undefined && !isAssistantTextPhase(record.phase)) || + (record.kind === 'thinking' && record.phase !== undefined) + ) { + throw invalidProtocolFrame('Invalid Session assistant delta phase'); + } if (record.complete !== undefined && record.complete !== true) { throw invalidProtocolFrame('Invalid Session assistant delta completion'); } @@ -749,6 +777,7 @@ function decodeAssistantDelta(value: unknown): SessionAssistantDelta { 'Session assistant delta text', SESSION_LIVE_DELTA_MAX_BYTES, ), + ...(record.phase !== undefined ? { phase: record.phase } : {}), ...(record.reset === true ? { reset: true as const } : {}), ...(record.complete === true ? { complete: true as const } : {}), }; diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index 2596e1a8af..a03e0ae802 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -134,6 +134,7 @@ interface ActiveAssistantStream { messageId: string; kind: SessionAssistantDelta['kind']; text: string; + phase?: SessionAssistantDelta['phase']; completedParts?: string[]; } @@ -709,11 +710,13 @@ export class SessionContinuityCoordinator implements SessionContinuityService { const prefixKey = assistantStreamKey(kind, event.messageId); const current = state.assistantStreams.get(prefixKey); const startOffset = current?.text.length ?? 0; + const phase = event.type === 'text_delta' ? (event.phase ?? current?.phase) : undefined; state.assistantStreams.set(prefixKey, { turnId: event.turnId, messageId: event.messageId, kind, text: (current?.text ?? '') + event.text, + ...(phase !== undefined ? { phase } : {}), }); for (const subscriber of state.subscribers.values()) { this.#enqueueAssistantDelta(subscriber, sessionId, runId, event, kind, startOffset); @@ -762,6 +765,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { messageId: event.messageId, text: '', } satisfies ActiveAssistantStream); + if (event.phase !== undefined) current.phase = event.phase; for (const subscriber of state.subscribers.values()) { this.#enqueueAssistantCompletion( subscriber, @@ -897,7 +901,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService { const subscriptionId = randomUUID(); const activeAssistantStreams = [...committed.state.assistantStreams.values()].map( - ({ kind, turnId, messageId }) => ({ kind, turnId, messageId }), + ({ kind, turnId, messageId, phase }) => ({ + kind, + turnId, + messageId, + ...(phase !== undefined ? { phase } : {}), + }), ); let transcript: SubscriberTranscriptState | undefined; let retainedTranscriptOverlay: RetainedTranscriptOverlay | undefined; @@ -1563,7 +1572,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { subscriber: Subscriber, sessionId: string, runId: string, - current: Pick, + current: Pick, kind: SessionAssistantDelta['kind'], finalText: string, ): void { @@ -1595,6 +1604,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { messageId: current.messageId, startOffset: finalText.length, text: '', + ...(kind === 'text' && current.phase !== undefined ? { phase: current.phase } : {}), ...(!extendsPrefix && finalText.length === 0 ? { reset: true as const } : {}), complete: true, }, @@ -1605,7 +1615,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { subscriber: Subscriber, sessionId: string, runId: string, - event: Pick, + event: Pick, kind: SessionAssistantDelta['kind'], startOffset: number, text: string, @@ -1628,6 +1638,7 @@ export class SessionContinuityCoordinator implements SessionContinuityService { messageId: event.messageId, startOffset: startOffset + emittedCharacters, text, + ...(kind === 'text' && event.phase !== undefined ? { phase: event.phase } : {}), ...(reset && emittedCharacters === 0 ? { reset: true } : {}), }, }); diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 53f0be7507..bfad7d8f6f 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -6017,6 +6017,363 @@ describe('AiSdkBackend model history', () => { ); }); + test('continues once after an explicit commentary-only provider step', async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const phase = calls === 1 ? 'commentary' : 'final_answer'; + const text = + calls === 1 ? 'I am checking the implementation.' : 'The implementation is ready.'; + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: `text-${calls}`, + providerMetadata: { openai: { phase } }, + }, + { type: 'text-delta', id: `text-${calls}`, delta: text }, + { + type: 'text-end', + id: `text-${calls}`, + providerMetadata: { openai: { phase } }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-1', 'inspect the implementation'); + const assistants: AssistantMessage[] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') assistants.push(message); + }, + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(calls, 2); + assert.deepEqual( + events.flatMap((event) => + event.type === 'text_complete' ? [{ text: event.text, phase: event.phase }] : [], + ), + [ + { text: 'I am checking the implementation.', phase: 'commentary' }, + { text: 'The implementation is ready.', phase: 'final_answer' }, + ], + ); + assert.deepEqual( + assistants.map((message) => ({ text: message.text, phase: message.phase })), + [ + { text: 'I am checking the implementation.', phase: 'commentary' }, + { text: 'The implementation is ready.', phase: 'final_answer' }, + ], + ); + assert.match(JSON.stringify(model.doStreamCalls[1]), /commentary_continuation/); + }); + + test('keeps commentary and final answer as separate messages within one provider step', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: 'text-commentary', + providerMetadata: { openai: { phase: 'commentary' } }, + }, + { + type: 'text-delta', + id: 'text-commentary', + delta: 'I am checking the implementation.', + }, + { + type: 'text-end', + id: 'text-commentary', + providerMetadata: { + openai: { itemId: 'text-commentary', phase: 'commentary' }, + }, + }, + { + type: 'text-start', + id: 'text-final', + providerMetadata: { openai: { phase: 'final_answer' } }, + }, + { + type: 'text-delta', + id: 'text-final', + delta: 'The implementation is ready.', + }, + { + type: 'text-end', + id: 'text-final', + providerMetadata: { openai: { itemId: 'text-final', phase: 'final_answer' } }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] satisfies LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const assistants: AssistantMessage[] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') assistants.push(message); + }, + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + const events: SessionEvent[] = []; + await collectEvents( + backend.send({ turnId: 'turn-1', text: 'inspect the implementation', context: [] }), + events, + ); + + assert.deepEqual( + assistants.map((message) => ({ text: message.text, phase: message.phase })), + [ + { text: 'I am checking the implementation.', phase: 'commentary' }, + { text: 'The implementation is ready.', phase: 'final_answer' }, + ], + ); + assert.deepEqual( + events.flatMap((event) => + event.type === 'text_complete' + ? [{ messageId: event.messageId, text: event.text, phase: event.phase }] + : [], + ), + [ + { + messageId: assistants[0]?.id, + text: 'I am checking the implementation.', + phase: 'commentary', + }, + { + messageId: assistants[1]?.id, + text: 'The implementation is ready.', + phase: 'final_answer', + }, + ], + ); + }); + + test('fails after one bounded continuation when the provider repeats commentary', async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: `text-${calls}`, + providerMetadata: { openai: { phase: 'commentary' } }, + }, + { + type: 'text-delta', + id: `text-${calls}`, + delta: `Progress update ${calls}.`, + }, + { + type: 'text-end', + id: `text-${calls}`, + providerMetadata: { openai: { phase: 'commentary' } }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-1', 'inspect the implementation'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(calls, 2); + assert.equal( + events.some( + (event) => + event.type === 'error' && event.message.includes('commentary instead of a final answer'), + ), + true, + ); + assert.equal( + events.some((event) => event.type === 'complete' && event.stopReason === 'error'), + true, + ); + }); + + test('infers commentary for phase-less text followed by a client tool call', async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const chunks: LanguageModelV4StreamPart[] = + calls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-commentary' }, + { + type: 'text-delta', + id: 'text-commentary', + delta: 'I will inspect the file.', + }, + { type: 'text-end', id: 'text-commentary' }, + { + type: 'tool-call', + toolCallId: 'tool-1', + toolName: 'Read', + input: JSON.stringify({ path: 'README.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { type: 'text-delta', id: 'text-final', delta: 'README inspected.' }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-1', 'inspect README'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.deepEqual( + events.flatMap((event) => + event.type === 'text_complete' ? [{ text: event.text, phase: event.phase }] : [], + ), + [ + { text: 'I will inspect the file.', phase: 'commentary' }, + { text: 'README inspected.', phase: 'final_answer' }, + ], + ); + }); + + test('does not carry a tool-only step into the next step text phase', async () => { + const loop = countingToolLoopModel(1); + const durable = durableTurnHarness('turn-1', 'inspect README'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => loop.model, + tools: [testTool('Read', z.object({ path: z.string() }))], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + const textComplete = events.find((event) => event.type === 'text_complete'); + + assert.equal( + textComplete?.type === 'text_complete' ? textComplete.phase : undefined, + 'final_answer', + ); + }); + test('after-step stop preserves the current provider step usage and prevents another step', async () => { const loop = countingToolLoopModel(); const durable = durableTurnHarness('turn-1', 'hi'); diff --git a/packages/runtime/src/__tests__/main-session-prompt.test.ts b/packages/runtime/src/__tests__/main-session-prompt.test.ts new file mode 100644 index 0000000000..22b9c3da00 --- /dev/null +++ b/packages/runtime/src/__tests__/main-session-prompt.test.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { assembleMainSessionSystemPrompt } from '../system-prompt/main-session-prompt.js'; + +test('main-session prompt distinguishes progress updates from runtime activity and final output', () => { + const prompt = assembleMainSessionSystemPrompt(['Project instructions']); + + assert.match(prompt, /progress update before the first non-trivial tool call/); + assert.match(prompt, /before the next tool call in the same response/); + assert.match(prompt, /Do not end a response after merely saying what you will do/); + assert.match(prompt, /do not expose hidden reasoning or repeat raw tool activity/); + assert.match(prompt, /distinct final answer/); + assert.match(prompt, /Project instructions$/); +}); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 003a28e53d..7008814933 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -541,6 +541,45 @@ describe('ModelAdapter stream and error normalization', () => { ); }); + test('preserves OpenAI Responses assistant text phases', () => { + const adapter = newAdapter(); + type Chunk = Parameters[0]; + + assert.deepEqual( + adapter.translateChunk({ + type: 'text-start', + id: 'message-commentary', + providerMetadata: { openai: { phase: 'commentary' } }, + } as Chunk), + [{ kind: 'text-start', phase: 'commentary' }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'text-end', + id: 'message-final', + providerMetadata: { openai: { itemId: 'message-final', phase: 'final_answer' } }, + } as Chunk), + [ + { + kind: 'text-metadata', + phase: 'final_answer', + providerOptions: { openai: { itemId: 'message-final', phase: 'final_answer' } }, + }, + ], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'text-start', + id: 'message-unknown', + providerMetadata: { + openai: { phase: 'analysis' }, + otherProvider: { phase: 'commentary' }, + }, + } as Chunk), + [{ kind: 'text-start' }], + ); + }); + test('normalizes Anthropic web search results and server-tool errors', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 045f94d21b..42eb632893 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -624,6 +624,48 @@ const projectionRunHeader: AgentRunHeader = { }; describe('SessionEvent projection coverage', () => { + test('preserves assistant text phase through mapping, projection, and legacy backfill', () => { + const runtimeEvent = mapSessionEventToRuntimeEvent( + ev({ + type: 'text_complete', + messageId: 'message-1', + text: 'I am checking the implementation.', + phase: 'commentary', + providerOptions: { openai: { itemId: 'message-1', phase: 'commentary' } }, + }), + ctx, + createSessionEventMapMemory(), + ); + assert.deepEqual(runtimeEvent.content, { + kind: 'text', + text: 'I am checking the implementation.', + phase: 'commentary', + providerOptions: { openai: { itemId: 'message-1', phase: 'commentary' } }, + }); + + const projected = projectRuntimeEventsToStoredMessages([runtimeEvent], { + runHeaders: [projectionRunHeader], + }); + assert.deepEqual(projected.messages[0], { + type: 'assistant', + id: 'message-1', + turnId: 'turn-1', + ts: runtimeEvent.ts, + text: 'I am checking the implementation.', + phase: 'commentary', + modelId: 'model-1', + providerOptions: { openai: { itemId: 'message-1', phase: 'commentary' } }, + }); + + const backfilled = backfillRuntimeEventsFromStoredMessages({ + run: projectionRunHeader, + messages: projected.messages, + newId: () => 'backfilled-1', + now: () => 100, + }); + assert.deepEqual(backfilled.events[0]?.content, runtimeEvent.content); + }); + test('keeps Host admission facts out of durable Runtime events', () => { assert.throws( () => diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 880a44922e..ecf0031e39 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -2179,6 +2179,94 @@ describe('SessionManager claimed graph intent execution', () => { }); describe('SessionManager child-session runtime primitive', () => { + test('does not use commentary as a completed child summary', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const parentGate = makeGate(); + backends.register('ai-sdk', (ctx) => + ctx.header.subagentRuntime + ? new CommentaryOnlyBackend(ctx) + : new TestBackend(ctx, parentGate), + ); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], + newId: nextId(), + now: nextNow(80), + }); + const parent = await manager.createSession(makeInput()); + const parentTurn = manager + .sendMessage(parent.id, { turnId: 'parent-turn-commentary', text: 'delegate' }) + [Symbol.asyncIterator](); + await parentTurn.next(); + const [parentRun] = await runStore.listSessionRuns(parent.id); + if (!parentRun) throw new Error('parent run was not recorded'); + + const result = await manager.spawnChildSession(parent.id, { + spawnedBy: { + parentRunId: parentRun.runId, + parentTurnId: parentRun.turnId, + toolCallId: 'tool-call-commentary', + }, + agentProfile: LOCAL_READ_AGENT_PROFILE, + prompt: 'inspect the storage boundary', + }); + + expect(result.status).toBe('completed'); + expect(result.summary).toBe(''); + + parentGate.release(); + while (!(await parentTurn.next()).done) {} + }); + + test('prefers a child final answer over later unphased compatibility text', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const parentGate = makeGate(); + backends.register('ai-sdk', (ctx) => + ctx.header.subagentRuntime + ? new FinalThenLegacyBackend(ctx) + : new TestBackend(ctx, parentGate), + ); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], + newId: nextId(), + now: nextNow(80), + }); + const parent = await manager.createSession(makeInput()); + const parentTurn = manager + .sendMessage(parent.id, { turnId: 'parent-turn-final', text: 'delegate' }) + [Symbol.asyncIterator](); + await parentTurn.next(); + const [parentRun] = await runStore.listSessionRuns(parent.id); + if (!parentRun) throw new Error('parent run was not recorded'); + + const result = await manager.spawnChildSession(parent.id, { + spawnedBy: { + parentRunId: parentRun.runId, + parentTurnId: parentRun.turnId, + toolCallId: 'tool-call-final', + }, + agentProfile: LOCAL_READ_AGENT_PROFILE, + prompt: 'inspect the storage boundary', + }); + + expect(result.status).toBe('completed'); + expect(result.summary).toBe('Final child answer.'); + + parentGate.release(); + while (!(await parentTurn.next()).done) {} + }); + test('creates a fresh read-only child with a session-inline first run and no parent history', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -12820,6 +12908,91 @@ class TestBackend implements AgentBackend { } } +class CommentaryOnlyBackend implements AgentBackend { + readonly kind = 'ai-sdk' as const; + readonly sessionId: string; + + constructor(ctx: BackendFactoryContext) { + this.sessionId = ctx.sessionId; + } + + async *send(input: BackendSendInput): AsyncIterable { + yield { + type: 'text_delta', + id: `${input.turnId}-commentary-delta`, + turnId: input.turnId, + ts: 1, + messageId: `${input.turnId}-commentary`, + text: 'Inspecting the storage boundary.', + phase: 'commentary', + }; + yield { + type: 'text_complete', + id: `${input.turnId}-commentary-complete`, + turnId: input.turnId, + ts: 2, + messageId: `${input.turnId}-commentary`, + text: 'Inspecting the storage boundary.', + phase: 'commentary', + }; + yield { + type: 'complete', + id: `${input.turnId}-complete`, + turnId: input.turnId, + ts: 3, + stopReason: 'end_turn', + }; + } + + async stop(): Promise {} + + async respondToSandboxBoundary(): Promise {} + + async dispose(): Promise {} +} + +class FinalThenLegacyBackend implements AgentBackend { + readonly kind = 'ai-sdk' as const; + readonly sessionId: string; + + constructor(ctx: BackendFactoryContext) { + this.sessionId = ctx.sessionId; + } + + async *send(input: BackendSendInput): AsyncIterable { + yield { + type: 'text_complete', + id: `${input.turnId}-final`, + turnId: input.turnId, + ts: 1, + messageId: `${input.turnId}-final-message`, + text: 'Final child answer.', + phase: 'final_answer', + }; + yield { + type: 'text_complete', + id: `${input.turnId}-legacy`, + turnId: input.turnId, + ts: 2, + messageId: `${input.turnId}-legacy-message`, + text: 'Legacy compatibility text.', + }; + yield { + type: 'complete', + id: `${input.turnId}-complete`, + turnId: input.turnId, + ts: 3, + stopReason: 'end_turn', + }; + } + + async stop(): Promise {} + + async respondToSandboxBoundary(): Promise {} + + async dispose(): Promise {} +} + class PermissionBroadcastBackend extends TestBackend { permissionResponses = 0; diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f298b8c3b7..39d5b7ed65 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -39,6 +39,7 @@ */ import type { + AssistantTextPhase, SessionEvent, CompleteEvent, AbortEvent, @@ -310,6 +311,13 @@ const CHILD_STEP_BUDGET_FINALIZATION_PROMPT = [ '', ].join('\n'); +const COMMENTARY_CONTINUATION_PROMPT = [ + '', + 'Your previous assistant message was a progress update, not the final answer.', + 'Continue the task now. If the work is complete, provide the final answer.', + '', +].join('\n'); + function providerToolResultContent( toolName: string, output: unknown, @@ -502,6 +510,25 @@ function mergeTextProviderOptions( return merged; } +function normalizedAssistantTextPhase(input: { + providerPhase: AssistantTextPhase | undefined; + hasClientToolCall: boolean; + completedStep: boolean; +}): AssistantTextPhase | undefined { + // Responses supplies phase directly. Chat Completions and Anthropic Messages + // do not, so their response topology is the portable signal: text before a + // client tool call is progress, and completed text-only output is terminal. + if (input.hasClientToolCall) return 'commentary'; + return input.providerPhase ?? (input.completedStep ? 'final_answer' : undefined); +} + +function crossesCommentaryBoundary( + current: AssistantTextPhase | undefined, + next: AssistantTextPhase | undefined, +): boolean { + return current !== next && (current === 'commentary' || next === 'commentary'); +} + // ============================================================================ // AgentBackend interface — port contract now lives in @maka/core/backend-types; // re-exported here for backward compatibility with existing import sites. @@ -1442,15 +1469,16 @@ export class AiSdkBackend implements AgentBackend { const queue = new AsyncEventQueue(); const codeModeExecTool = this.createCodeModeExecTool(scope, queue); - // One AssistantMessage is flushed per provider step (not per turn), so the - // ledger records the text↔tool timeline at step granularity and each step's - // Anthropic thinking signature stays paired with its own thinking text. The - // turn's first step reuses this id; every later step rotates to a fresh one - // at its step boundary (see the stream loop below). + // AssistantMessages normally flush per provider step, but an explicit + // commentary↔final phase transition within one response also starts a new + // message. This preserves the model-authored progress/final boundary while + // keeping same-phase text items and their annotations coalesced. let currentStepMessageId = this.newId(); let stepText = ''; + let stepTextPhase: AssistantTextPhase | undefined; let stepTextProviderOptions: NonNullable | undefined; let stepTextPartStartOffset = 0; + let stepHasClientToolCall = false; let stepThinking = ''; let sawStepThinking = false; let stepThinkingProviderOptions: NonNullable | undefined; @@ -1468,10 +1496,26 @@ export class AiSdkBackend implements AgentBackend { // precedes text_complete so the read-model attaches this step's reasoning to // this step's assistant row. Hoisted to send() scope so both the streaming // path and the abort/error handler can flush a partial step. - const flushStep = async (): Promise => { + let lastFlushedTextPhase: AssistantTextPhase | undefined; + let lastFlushedTextPhaseWasExplicit = false; + const flushStep = async (completedStep = true): Promise => { const hasThinking = sawStepThinking || stepSignature !== undefined; - if (stepText.length === 0 && !hasThinking) return; + if (stepText.length === 0 && !hasThinking) { + stepTextPhase = undefined; + stepHasClientToolCall = false; + return; + } const stepId = currentStepMessageId; + const textPhase = + stepText.length > 0 + ? normalizedAssistantTextPhase({ + providerPhase: stepTextPhase, + hasClientToolCall: stepHasClientToolCall, + completedStep, + }) + : undefined; + lastFlushedTextPhase = textPhase; + lastFlushedTextPhaseWasExplicit = stepTextPhase !== undefined; const thinkingParts: AssistantThinkingPart[] = stepResponsesThinkingParts.length > 0 ? stepResponsesThinkingParts @@ -1490,6 +1534,7 @@ export class AiSdkBackend implements AgentBackend { turnId, ts: this.now(), text: stepText, + ...(textPhase !== undefined ? { phase: textPhase } : {}), ...(stepTextProviderOptions !== undefined ? { providerOptions: stepTextProviderOptions } : {}), @@ -1539,14 +1584,18 @@ export class AiSdkBackend implements AgentBackend { ts: this.now(), messageId: stepId, text: stepText, + ...(textPhase !== undefined ? { phase: textPhase } : {}), ...(stepTextProviderOptions !== undefined ? { providerOptions: stepTextProviderOptions } : {}), } satisfies TextCompleteEvent); - scope.finalAssistantText = stepText.length > 0 ? stepText : undefined; + scope.finalAssistantText = + textPhase === 'final_answer' && stepText.length > 0 ? stepText : undefined; stepText = ''; + stepTextPhase = undefined; stepTextProviderOptions = undefined; stepTextPartStartOffset = 0; + stepHasClientToolCall = false; stepThinking = ''; sawStepThinking = false; stepThinkingProviderOptions = undefined; @@ -2148,6 +2197,8 @@ export class AiSdkBackend implements AgentBackend { let providerOutcome: ModelStepOutcome; let finishReason: ModelFinishReason = 'stop'; let terminalProviderError: unknown; + let commentaryContinuationPending = false; + let commentaryContinuationUsed = false; agentLoop: for (;;) { await this.drainSteeringInto(scope, input, queue); if (this.input.loadTurnRuntimeEvents) { @@ -2194,11 +2245,15 @@ export class AiSdkBackend implements AgentBackend { : boundaryAwareToolNames(shaped?.activeTools ?? plan.currentRepairToolNames()); const requestSystemPrompt = joinPromptFragments([ systemPrompt, + commentaryContinuationPending ? COMMENTARY_CONTINUATION_PROMPT : undefined, finalChildSummaryStep ? CHILD_STEP_BUDGET_FINALIZATION_PROMPT : undefined, toolRuntime.hasSandboxBoundaryDenial() ? SANDBOX_BOUNDARY_DENIED_FOR_TURN : undefined, sandboxBoundaryFinalizationStep ? SANDBOX_BOUNDARY_FINALIZATION_PROMPT : undefined, ]); + commentaryContinuationPending = false; providerRequestTracker?.setStep(runtimeSteps); + lastFlushedTextPhase = undefined; + lastFlushedTextPhaseWasExplicit = false; let attemptMessages = projectedMessages; let providerAttempt = 1; let idleWatchdogRetryCount = 0; @@ -2332,7 +2387,14 @@ export class AiSdkBackend implements AgentBackend { } } if (event.kind === 'text-start') { + if (stepText.length > 0 && crossesCommentaryBoundary(stepTextPhase, event.phase)) { + await flushStep(); + currentStepMessageId = this.newId(); + } stepTextPartStartOffset = stepText.length; + if (event.phase !== undefined || stepText.length === 0) { + stepTextPhase = event.phase; + } } else if (event.kind === 'text') { stepText += event.text; if (event.text.length > 0) attemptSawText = true; @@ -2343,9 +2405,11 @@ export class AiSdkBackend implements AgentBackend { ts: this.now(), messageId: currentStepMessageId, text: event.text, + ...(stepTextPhase !== undefined ? { phase: stepTextPhase } : {}), } satisfies TextDeltaEvent); } else if (event.kind === 'text-metadata') { attemptSawContinuationMetadata = true; + if (event.phase !== undefined) stepTextPhase = event.phase; stepTextProviderOptions = mergeTextProviderOptions( stepTextProviderOptions, stripUndefinedDeep(event.providerOptions) as NonNullable< @@ -2456,6 +2520,7 @@ export class AiSdkBackend implements AgentBackend { : {}), } satisfies ToolStartEvent); } else { + stepHasClientToolCall = true; returnedToolCalls.push(event.toolCall); } } else if (event.kind === 'provider-tool-result') { @@ -2814,6 +2879,28 @@ export class AiSdkBackend implements AgentBackend { currentStepMessageId = this.newId(); continue agentLoop; } + const endedWithExplicitCommentary = + returnedToolCalls.length === 0 && + lastFlushedTextPhaseWasExplicit && + lastFlushedTextPhase === 'commentary'; + if (endedWithExplicitCommentary) { + if ( + mayTakeAnotherStep && + this.input.loadTurnRuntimeEvents && + !commentaryContinuationUsed + ) { + commentaryContinuationUsed = true; + commentaryContinuationPending = true; + currentStepMessageId = this.newId(); + continue agentLoop; + } + throw { + type: 'model_failure', + kind: 'unknown', + retryable: false, + message: 'Provider ended the turn with commentary instead of a final answer', + } satisfies ModelFailure; + } // Continuing the turn needs the durable current-run reader, for the // same reason the tool-call edge above demands it: the next request // has to carry the assistant output this step just produced, and only @@ -3009,7 +3096,7 @@ export class AiSdkBackend implements AgentBackend { // `finish-step`; this keeps their and this step's streamed-out output on // BOTH exits — user stop and provider error / watchdog timeout — so // partialOutputRetained reflects what the user actually saw. - await flushStep().catch(() => {}); + await flushStep(false).catch(() => {}); if (!scope.aborted && midTurnState?.exhaustedDetail) { // Mid-turn compaction could not produce a provider-safe request: end // the turn with the explicit first-class outcome, not a raw error. diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index b1cc2a60b2..fa50d5d33b 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -17,7 +17,12 @@ * under the License. */ -import type { ErrorEvent, CompleteEvent } from '@maka/core/events'; +import { + isAssistantTextPhase, + type AssistantTextPhase, + type ErrorEvent, + type CompleteEvent, +} from '@maka/core/events'; import { openai } from '@ai-sdk/openai'; import { anthropic } from '@ai-sdk/anthropic'; import { @@ -948,6 +953,14 @@ function plaintextSummaryItemIdFromChunk( return safePlaintextResponsesReasoningItemId((chunk as { id?: unknown }).id); } +function assistantTextPhaseFromProviderMetadata(metadata: unknown): AssistantTextPhase | undefined { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return undefined; + const openai = (metadata as { openai?: unknown }).openai; + if (!openai || typeof openai !== 'object' || Array.isArray(openai)) return undefined; + const phase = (openai as { phase?: unknown }).phase; + return isAssistantTextPhase(phase) ? phase : undefined; +} + /** * Translate one raw AI SDK stream chunk into zero or more Maka-owned * `ModelStreamEvent`s. The sole site that parses SDK chunk names; the backend @@ -964,18 +977,22 @@ function translateChunk( const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); return reasoningItemId ? [{ kind: 'thinking', text: '', reasoningItemId }] : []; } - case 'text-start': - return [{ kind: 'text-start' }]; + case 'text-start': { + const phase = assistantTextPhaseFromProviderMetadata(chunk.providerMetadata); + return [{ kind: 'text-start', ...(phase !== undefined ? { phase } : {}) }]; + } case 'text-delta': { const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? ''; return text ? [{ kind: 'text', text }] : []; } case 'text-end': { if (!chunk.providerMetadata || typeof chunk.providerMetadata !== 'object') return []; + const phase = assistantTextPhaseFromProviderMetadata(chunk.providerMetadata); return [ { kind: 'text-metadata', providerOptions: chunk.providerMetadata as NonNullable, + ...(phase !== undefined ? { phase } : {}), }, ]; } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index be9c0d0c8c..68453fac75 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -36,6 +36,7 @@ * for this seam. */ +import type { AssistantTextPhase } from '@maka/core/events'; import type { CacheMissInputSource } from '@maka/core/usage-stats/types'; // --------------------------------------------------------------------------- @@ -386,9 +387,13 @@ export interface ModelRequestMetadata { * recovery and terminal error emission. */ export type ModelStreamEvent = - | { kind: 'text-start' } + | { kind: 'text-start'; phase?: AssistantTextPhase } | { kind: 'text'; text: string } - | { kind: 'text-metadata'; providerOptions: ProviderOptions } + | { + kind: 'text-metadata'; + providerOptions: ProviderOptions; + phase?: AssistantTextPhase; + } | { kind: 'thinking'; text: string; diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 044fdfb8e2..e37df3d2b3 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -145,7 +145,14 @@ export function backfillRuntimeEventsFromStoredMessages( id: newId(), role: 'model', author: 'agent', - content: { kind: 'text', text: message.text }, + content: { + kind: 'text', + text: message.text, + ...(message.phase !== undefined ? { phase: message.phase } : {}), + ...(message.providerOptions !== undefined + ? { providerOptions: structuredClone(message.providerOptions) } + : {}), + }, actions: { stateDelta: recoveryState(now, message) }, refs: { storedMessageId: message.id }, }); diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 1df0853dea..8eea2fdfd0 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -652,6 +652,7 @@ function projectText( turnId: event.turnId, ts: event.ts, text: event.content.text, + ...(event.content.phase !== undefined ? { phase: event.content.phase } : {}), ...(event.content.providerOptions !== undefined ? { providerOptions: structuredClone(event.content.providerOptions) } : {}), diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 3044d110f9..606b4aa143 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -182,7 +182,11 @@ function mapBackendSessionEvent( partial: true, role: 'model', author: 'agent', - content: { kind: 'text', text: event.text }, + content: { + kind: 'text', + text: event.text, + ...(event.phase !== undefined ? { phase: event.phase } : {}), + }, refs: { providerEventId: event.messageId }, }; case 'text_complete': @@ -193,6 +197,7 @@ function mapBackendSessionEvent( content: { kind: 'text', text: event.text, + ...(event.phase !== undefined ? { phase: event.phase } : {}), ...(event.providerOptions !== undefined ? { providerOptions: structuredClone(event.providerOptions) } : {}), diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index b284c62328..cbdfd1103a 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -43,6 +43,7 @@ import type { PermissionRequestEvent, ShellRunUpdate, MessageContent, + AssistantTextPhase, } from '@maka/core/events'; import { messageContentsEqual, normalizeMessageContent } from '@maka/core/events'; import type { @@ -3212,12 +3213,13 @@ export class SessionManager { this.finalizeAndListChildTurnArtifacts(child.id, run.turnId, run.status), ]); const storedSummary = - messages - .filter( - (message): message is Extract => - message.type === 'assistant' && message.turnId === run.turnId, - ) - .at(-1)?.text ?? ''; + preferredAssistantText( + messages.flatMap((message) => + message.type === 'assistant' && message.turnId === run.turnId + ? [{ text: message.text, phase: message.phase }] + : [], + ), + ) ?? ''; const runtimeText = runtimeEvents.filter( ( event, @@ -3225,10 +3227,13 @@ export class SessionManager { content: Extract, { kind: 'text' }>; } => event.role === 'model' && event.content?.kind === 'text', ); - const durableRuntimeSummary = runtimeText.filter((event) => !event.partial).at(-1) - ?.content.text; + const durableRuntimeSummary = preferredAssistantText( + runtimeText + .filter((event) => !event.partial) + .map((event) => ({ text: event.content.text, phase: event.content.phase })), + ); const partialRuntimeSummary = runtimeText - .filter((event) => event.partial) + .filter((event) => event.partial && event.content.phase !== 'commentary') .map((event) => event.content.text) .join(''); const completedAt = run.completedAt ?? run.updatedAt; @@ -5287,11 +5292,24 @@ function trimSummary(text: string): string { : `${trimmed.slice(0, CHILD_AGENT_SUMMARY_MAX_CHARS - 1)}…`; } +function preferredAssistantText( + texts: readonly { readonly text: string; readonly phase?: AssistantTextPhase }[], +): string | undefined { + let explicitFinal: string | undefined; + let legacyFallback: string | undefined; + for (const text of texts) { + if (text.phase === 'final_answer') explicitFinal = text.text; + else if (text.phase === undefined) legacyFallback = text.text; + } + return explicitFinal ?? legacyFallback; +} + class ChildAgentSummaryAccumulator { eventCount = 0; failureClass: string | undefined; private terminalStatus: SpawnChildSessionResult['status'] | undefined; - private lastTextComplete = ''; + private lastFinalTextComplete = ''; + private lastLegacyTextComplete = ''; private textDeltaTail = ''; private textDeltaTruncated = false; private lastError = ''; @@ -5300,10 +5318,19 @@ class ChildAgentSummaryAccumulator { this.eventCount += 1; switch (event.type) { case 'text_complete': - this.lastTextComplete = trimSummary(event.text); + if (event.phase === 'commentary') { + this.textDeltaTail = ''; + this.textDeltaTruncated = false; + break; + } + if (event.phase === 'final_answer') { + this.lastFinalTextComplete = trimSummary(event.text); + } else { + this.lastLegacyTextComplete = trimSummary(event.text); + } break; case 'text_delta': - this.appendTextDelta(event.text); + if (event.phase !== 'commentary') this.appendTextDelta(event.text); break; case 'error': this.terminalStatus = 'failed'; @@ -5327,7 +5354,8 @@ class ChildAgentSummaryAccumulator { } text(): string { - if (this.lastTextComplete.trim()) return this.lastTextComplete; + if (this.lastFinalTextComplete.trim()) return this.lastFinalTextComplete; + if (this.lastLegacyTextComplete.trim()) return this.lastLegacyTextComplete; if (this.textDeltaTail.trim()) { return this.textDeltaTruncated ? `…${this.textDeltaTail.slice(1)}` diff --git a/packages/runtime/src/system-prompt/main-session-prompt.ts b/packages/runtime/src/system-prompt/main-session-prompt.ts index 72467be668..6529278dfa 100644 --- a/packages/runtime/src/system-prompt/main-session-prompt.ts +++ b/packages/runtime/src/system-prompt/main-session-prompt.ts @@ -55,10 +55,26 @@ Prefer descriptive link text for external sources when it is available. Follow a more specific format requested by the user or task.`; } +function buildProgressUpdatesPromptFragment(): string { + return `## Progress updates + +For tasks that require tools or multiple steps, send a brief user-facing progress update before the first non-trivial tool call. +Send another update only when you reach a meaningful phase change, discover information that changes the plan, or finish a long-running operation. +When more work remains, put the progress update before the next tool call in the same response. Do not end a response after merely saying what you will do. +Keep updates to one or two concise sentences. Describe your intent or findings; do not expose hidden reasoning or repeat raw tool activity that the interface already shows. +Skip progress updates for simple answers and trivial single-step actions. +End the turn with a distinct final answer that states the outcome.`; +} + export function assembleMainSessionSystemPrompt( fragments: readonly (string | undefined)[], ): string { - return [buildIdentityPromptFragment(), buildResponseFormatPromptFragment(), ...fragments] + return [ + buildIdentityPromptFragment(), + buildResponseFormatPromptFragment(), + buildProgressUpdatesPromptFragment(), + ...fragments, + ] .filter((fragment): fragment is string => Boolean(fragment?.trim())) .join('\n\n'); } diff --git a/packages/storage/src/__tests__/codex-session-adapter.test.ts b/packages/storage/src/__tests__/codex-session-adapter.test.ts index 7add9b40ca..023c37ac81 100644 --- a/packages/storage/src/__tests__/codex-session-adapter.test.ts +++ b/packages/storage/src/__tests__/codex-session-adapter.test.ts @@ -271,6 +271,10 @@ describe('CodexSessionAdapter', () => { }); assert.equal(session.messages[2]?.type, 'assistant'); assert.equal(session.messages[2]?.text, 'I found the issue.'); + assert.equal( + session.messages[2]?.type === 'assistant' ? session.messages[2].phase : undefined, + 'commentary', + ); assert.deepEqual(session.messages[3], { type: 'tool_call', id: 'call-wait-1', @@ -348,6 +352,7 @@ describe('CodexSessionAdapter', () => { turnId: 'codex-turn-item-completed', ts: Date.parse('2026-08-22T00:00:04.000Z'), text: 'Use canvas. Then process the pixels.', + phase: 'final_answer', modelId: 'gpt-codex-item-test', contentOrder: ['text'], }); diff --git a/packages/storage/src/codex-session-adapter.ts b/packages/storage/src/codex-session-adapter.ts index 4c3164db69..f43465233f 100644 --- a/packages/storage/src/codex-session-adapter.ts +++ b/packages/storage/src/codex-session-adapter.ts @@ -21,6 +21,7 @@ import type { Dirent } from 'node:fs'; import { open, readdir, realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, join, resolve, sep } from 'node:path'; +import { isAssistantTextPhase, type AssistantTextPhase } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; import { isSupportedCodexThreadSource, sanitizeForeignTitle } from '@maka/core/foreign-session'; import { externalSessionMatchesQuery } from '@maka/core/external-session'; @@ -327,6 +328,7 @@ function convertCodexRollout( if (itemType === 'agentmessage') { const text = codexCompletedItemText(item); if (text.length === 0) continue; + const phase = codexAssistantTextPhase(item); messages.push({ type: 'assistant', id: @@ -335,6 +337,7 @@ function convertCodexRollout( turnId: ensureTurnId(record.line), ts: timestampFor(record), text, + ...(phase !== undefined ? { phase } : {}), modelId: activeModel, contentOrder: ['text'], }); @@ -383,12 +386,14 @@ function convertCodexRollout( if (eventType === 'agent_message') { const text = stringField(payload, 'message'); if (!text) continue; + const phase = codexAssistantTextPhase(payload); messages.push({ type: 'assistant', id: generatedCodexId(expectedSessionId, 'assistant', record.line), turnId: ensureTurnId(record.line), ts: timestampFor(record), text, + ...(phase !== undefined ? { phase } : {}), modelId: activeModel, contentOrder: ['text'], }); @@ -773,6 +778,11 @@ function stringField(record: JsonRecord | undefined, field: string): string | un return typeof value === 'string' && value.length > 0 ? value : undefined; } +function codexAssistantTextPhase(record: JsonRecord | undefined): AssistantTextPhase | undefined { + const phase = record?.phase; + return isAssistantTextPhase(phase) ? phase : undefined; +} + function isSafeCodexSessionId(value: unknown): value is string { return typeof value === 'string' && CODEX_SESSION_ID_PATTERN.test(value); } diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 63d788c85f..a3aa2f77c4 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -500,6 +500,30 @@ describe('applyLiveTurnEvent', () => { ); }); + it('preserves commentary phase in the live timeline', () => { + const streaming = applyLiveTurnEvent(undefined, { + type: 'text_delta', + id: 'commentary-delta', + messageId: 'step-1', + turnId: 'turn-1', + ts: 100, + text: 'Checking', + phase: 'commentary', + }); + const completed = applyLiveTurnEvent(streaming, { + type: 'text_complete', + id: 'commentary-complete', + messageId: 'step-1', + turnId: 'turn-1', + ts: 101, + text: 'Checking the repository', + phase: 'commentary', + }); + + const item = overlayLiveTurn([], completed)[0]?.timeline[0]; + assert.equal(item?.kind === 'text' ? item.phase : undefined, 'commentary'); + }); + it('appends late thinking without moving an already visible tool', () => { const tool = applyLiveTurnEvent(undefined, { diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 179be9ea18..8b29884015 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -24,6 +24,7 @@ import { materializeChat, materializeTools, materializeTurns, + finalAssistantReplyText, overlayLiveTurn, type TurnTimelineItem, } from "../materialize.js"; @@ -61,6 +62,93 @@ function timelineText(turn: ReturnType[number] | undefi ) ?? []; } +describe("assistant text phases", () => { + test("keeps commentary in the timeline while selecting only the final answer", () => { + const [turn] = materializeTurns([ + originalUser, + { + type: "assistant", + id: "commentary", + turnId: "t1", + ts: 2, + text: "I am checking the implementation.", + phase: "commentary", + modelId: "fixture", + }, + { + type: "assistant", + id: "final", + turnId: "t1", + ts: 3, + text: "The implementation is ready.", + phase: "final_answer", + modelId: "fixture", + }, + ]); + + assert.deepEqual( + turn?.timeline.flatMap((item) => + item.kind === "text" ? [{ text: item.text, phase: item.phase }] : [], + ), + [ + { text: "I am checking the implementation.", phase: "commentary" }, + { text: "The implementation is ready.", phase: "final_answer" }, + ], + ); + assert.equal(turn ? finalAssistantReplyText(turn) : undefined, "The implementation is ready."); + }); + + test("keeps an unphased legacy final answer in a mixed imported timeline", () => { + const [turn] = materializeTurns([ + originalUser, + { + type: "assistant", + id: "commentary", + turnId: "t1", + ts: 2, + text: "I am checking the implementation.", + phase: "commentary", + modelId: "fixture", + }, + { + type: "assistant", + id: "legacy-final", + turnId: "t1", + ts: 3, + text: "Legacy final answer", + modelId: "fixture", + }, + ]); + + assert.equal(turn ? finalAssistantReplyText(turn) : undefined, "Legacy final answer"); + }); + + test("prefers an explicit final answer over later unphased compatibility text", () => { + const [turn] = materializeTurns([ + originalUser, + { + type: "assistant", + id: "final", + turnId: "t1", + ts: 2, + text: "Explicit final answer", + phase: "final_answer", + modelId: "fixture", + }, + { + type: "assistant", + id: "legacy-after-final", + turnId: "t1", + ts: 3, + text: "Legacy compatibility text", + modelId: "fixture", + }, + ]); + + assert.equal(turn ? finalAssistantReplyText(turn) : undefined, "Explicit final answer"); + }); +}); + describe("steering timeline", () => { test("keeps a steering message at its conversational position", () => { const [turn] = materializeTurns([ diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index fd5f74afb2..a816df0726 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -19,6 +19,7 @@ import { decodeToolStepProgress, + type AssistantTextPhase, type MessageContent, type ProviderRetryEvent, type SessionEvent, @@ -75,6 +76,7 @@ export type LiveTurnStepContentKind = 'thinking' | 'text' | 'tools'; export interface LiveTextProjection { text: string; + phase?: AssistantTextPhase; truncated: boolean; complete: boolean; /** Raw source length, independent of redaction and display truncation. */ @@ -343,6 +345,9 @@ export function applyLiveTurnEvent( ...step, text: { text: applied.text, + ...(event.phase ?? step.text?.phase + ? { phase: event.phase ?? step.text?.phase } + : {}), truncated: (step.text?.truncated ?? false) || applied.truncated, complete: false, ...(delta.sourceEndOffset === undefined @@ -359,6 +364,9 @@ export function applyLiveTurnEvent( ...step, text: { text: applied.text, + ...(event.phase ?? step.text?.phase + ? { phase: event.phase ?? step.text?.phase } + : {}), truncated: applied.truncated, complete: true, ...(step.text?.sourceEndOffset === undefined diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 13c15cff93..e450d02b0e 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -27,6 +27,7 @@ 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 { + AssistantTextPhase, AttachmentRef, InlineReference, MessageContent, @@ -340,6 +341,7 @@ export type TurnTimelineItem = kind: "text"; text: string; messageId: string; + phase?: AssistantTextPhase; ts?: number; live?: boolean; complete?: boolean; @@ -533,6 +535,7 @@ export function overlayLiveTurn( kind: "text", text: step.text.text, messageId: step.stepId, + ...(step.text.phase !== undefined ? { phase: step.text.phase } : {}), live: true, complete: step.text.complete, truncated: step.text.truncated, @@ -826,11 +829,16 @@ export function materializeTurns( * turns with no timeline text entry. */ export function finalAssistantReplyText(turn: TurnViewModel): string { + let legacyReply: string | undefined; + let sawPhasedText = false; for (let index = turn.timeline.length - 1; index >= 0; index -= 1) { const item = turn.timeline[index]; - if (item?.kind === "text" && item.text.length > 0) return item.text; + if (item?.kind !== "text" || item.text.length === 0) continue; + if (item.phase === "final_answer") return item.text; + if (item.phase !== undefined) sawPhasedText = true; + else legacyReply ??= item.text; } - return turn.assistant?.text ?? ""; + return legacyReply ?? (sawPhasedText ? "" : (turn.assistant?.text ?? "")); } /** @@ -1036,6 +1044,7 @@ function buildTurnTimeline( kind: "text", text: message.text, messageId: rowId, + ...(message.phase !== undefined ? { phase: message.phase } : {}), ts: message.ts, }); } else if (kind === "tools") { @@ -1059,6 +1068,7 @@ function buildTurnTimeline( kind: "text", text: message.text, messageId: rowId, + ...(message.phase !== undefined ? { phase: message.phase } : {}), ts: message.ts, }); } From 2fbfba515424be380772869fbfe2b270e217fd36 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 31 Aug 2026 16:59:24 +0800 Subject: [PATCH 02/13] feat(ui): present commentary as a collapsible work log Add a provider-neutral ProgressUpdate transport for phase-less model APIs, hide its implementation-detail activity, and present commentary, reasoning, and tool work as a Codex-style log that folds when the final answer begins. Preserve native Responses phases and keep CLI final-output selection phase-aware. Generated-by: OpenAI Codex --- .../src/renderer/styles/chat-message.css | 143 ++++++++ apps/desktop/stories/app-shell.stories.tsx | 6 +- .../cli/src/__tests__/pi-transcript.test.ts | 76 +++++ packages/cli/src/pi-transcript.ts | 58 ++-- packages/core/src/events.ts | 6 + .../src/__tests__/ai-sdk-backend.test.ts | 211 +++++++++++- .../src/__tests__/code-mode-backend.test.ts | 8 +- .../computer-use-provider-protocol.test.ts | 6 +- .../src/__tests__/main-session-prompt.test.ts | 17 +- packages/runtime/src/ai-sdk-backend.ts | 90 ++++-- .../runtime/src/assistant-progress-tool.ts | 53 +++ packages/runtime/src/model-adapter.ts | 11 +- .../src/system-prompt/main-session-prompt.ts | 14 +- packages/runtime/src/tool-availability.ts | 2 + .../__tests__/live-turn-projection.test.ts | 40 ++- .../markdown-rhythm-contract.test.tsx | 27 +- packages/ui/src/__tests__/materialize.test.ts | 39 +++ .../src/__tests__/processing-block.test.tsx | 247 ++++++++++++++ .../src/__tests__/processing-summary.test.ts | 132 ++++++++ .../__tests__/turn-running-spinner.test.tsx | 23 ++ packages/ui/src/chat-turn.tsx | 305 +++++++++++++----- packages/ui/src/chat-view.tsx | 4 +- packages/ui/src/conversation-copy.ts | 17 +- packages/ui/src/live-turn-projection.ts | 16 + packages/ui/src/markdown-body.tsx | 2 + packages/ui/src/markdown.tsx | 3 + packages/ui/src/materialize.ts | 28 +- packages/ui/src/processing-summary.ts | 194 +++++++++++ packages/ui/src/streaming-presentation.ts | 2 +- packages/ui/src/styles.css | 12 +- packages/ui/src/tool-activity.tsx | 22 +- 31 files changed, 1635 insertions(+), 179 deletions(-) create mode 100644 packages/runtime/src/assistant-progress-tool.ts create mode 100644 packages/ui/src/__tests__/processing-block.test.tsx create mode 100644 packages/ui/src/__tests__/processing-summary.test.ts create mode 100644 packages/ui/src/processing-summary.ts diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 9fa775b629..61fa30c60f 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -116,11 +116,154 @@ padding: var(--space-0-5) 0 0; } +.maka-work-log { + min-width: 0; +} + +.maka-work-log:not([data-collapsible="true"]), +.maka-work-log:not([data-collapsible="true"]) .maka-work-log-content { + display: contents; +} + +.maka-work-log-header { + display: flex; + width: 100%; + min-height: var(--h-control-sm); + align-items: center; + gap: var(--space-1); + padding: var(--space-0-5) 0; + border: 0; + border-bottom: var(--border-width-hairline) solid var(--border-soft); + color: var(--muted-foreground); + background: transparent; + font: var(--maka-text-body); + text-align: start; + cursor: default; +} + +.maka-work-log-label { + min-width: 0; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.maka-work-log-chevron { + flex: 0 0 auto; + color: inherit; + transition: transform var(--duration-fast) var(--ease-standard); +} + +.maka-work-log-header[aria-expanded="true"] .maka-work-log-chevron { + transform: rotate(90deg); +} + +@media (hover: hover) { + .maka-work-log-header:hover { + color: var(--foreground-secondary); + } +} + +.maka-work-log-header:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + +.maka-work-log-content { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-2); + padding-top: var(--space-2); +} + +.maka-work-log-content[hidden] { + display: none; +} + +.maka-processing-block { + min-width: 0; +} + +.maka-processing-header { + display: flex; + width: 100%; + min-height: var(--h-control-sm); + align-items: center; + gap: var(--space-1-5); + padding: var(--space-0-5) 0; + border: 0; + border-radius: var(--radius-element); + color: var(--muted-foreground); + background: transparent; + font: var(--maka-text-body); + text-align: start; + cursor: default; +} + +.maka-processing-icon { + display: inline-flex; + width: var(--icon-control); + height: var(--icon-control); + flex: 0 0 auto; + align-items: center; + justify-content: center; + color: inherit; +} + +.maka-processing-summary { + min-width: 0; + flex: 1; + overflow: hidden; + color: inherit; + font-weight: var(--font-weight-normal); + text-overflow: ellipsis; + white-space: nowrap; +} + +.maka-processing-chevron { + flex: 0 0 auto; + color: var(--muted-foreground); + transition: transform var(--duration-fast) var(--ease-standard); +} + +.maka-processing-header[aria-expanded="true"] .maka-processing-chevron { + transform: rotate(90deg); +} + +.maka-processing-block[data-running="true"] .maka-processing-summary { + color: var(--foreground-secondary); +} + +.maka-processing-block[data-error="true"] .maka-processing-icon, +.maka-processing-block[data-error="true"] .maka-processing-summary { + color: var(--destructive); +} + +@media (hover: hover) { + .maka-processing-header:hover { + background: var(--foreground-5); + } +} + +.maka-processing-header:focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .maka-processing-sequence { display: flex; min-width: 0; flex-direction: column; gap: var(--space-1); + margin-inline-start: var(--space-2); + padding-inline-start: var(--space-2-5); + border-inline-start: var(--border-width-hairline) solid var(--border-soft); +} + +.maka-processing-sequence[hidden] { + display: none; } /* Expanded activity headers stay reachable while their own detail is being diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 858aa5867a..00ce475da6 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -550,7 +550,7 @@ export const StreamingTurn: Story = { // attribute, and the elapsed clock is dropped rather than pinned under it, // because any value it could print is a real wall-clock difference that would // differ between two captures. In the app the same row reads -// "正在琢磨… · 2m 1s", with the phrase swapping every 20s. +// "等待模型输出… · 2m 1s". export const RunningStatusDuringToolRun: Story = { render: () => ( ( @@ -855,7 +855,7 @@ export const ManyTurns: Story = { // Real path: Desktop Computer Use is exposed through the Runtime Host Client // Capability bridge. The settled observation establishes the confirmed target; // the following sequence inherits it while live progress replaces the generic -// working phrase at the bottom of the turn. +// waiting status at the bottom of the turn. export const ComputerUseObservability: Story = { render: () => ( { + test('renders commentary without exposing the progress transport tool', () => { + const state = createMakaPiTranscriptState(); + appendUserPrompt(state, 'inspect the repository', 'message-1', true); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'text_delta', + messageId: 'step-1', + text: 'I am checking the recent repository activity.', + phase: 'commentary', + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_start', + stepId: 'step-1', + toolUseId: 'progress-1', + toolName: ASSISTANT_PROGRESS_TOOL_NAME, + args: { text: 'I am checking the recent repository activity.' }, + }), + ); + applyMakaSessionEventToTranscript( + state, + event({ + type: 'tool_result', + toolUseId: 'progress-1', + isError: false, + content: { kind: 'json', value: { status: 'displayed' } }, + }), + ); + + assert.deepEqual( + state.entries.map((entry) => entry.kind), + ['user', 'assistant'], + ); + assert.equal( + state.entries[1]?.kind === 'assistant' ? state.entries[1].phase : undefined, + 'commentary', + ); + }); + + test('omits empty durable assistant steps between thinking and tools', () => { + const state = createMakaPiTranscriptState(); + replaceTranscriptWithStoredMessages(state, [ + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 1, + text: '', + thinking: { text: 'Need to inspect the file.' }, + modelId: 'model-1', + }, + { + type: 'tool_call', + id: 'read-1', + turnId: 'turn-1', + ts: 2, + toolName: 'Read', + args: { path: 'package.json' }, + }, + ]); + + assert.deepEqual( + state.entries.map((entry) => entry.kind), + ['thinking', 'tool'], + ); + const rendered = renderMakaPiTranscript(state, meta(), 80).map(stripAnsi); + const thinkingIndex = rendered.findIndex((line) => line.includes('Thinking…')); + const toolIndex = rendered.findIndex((line) => line.includes('Read')); + assert.equal(toolIndex - thinkingIndex, 2); + }); + test('renders manual compaction from the typed terminal outcome', async () => { for (const [outcome, expected] of [ [{ kind: 'compacted' as const, checkpointId: 'checkpoint-1' }, 'Context compacted.'], diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index d102125340..a676b99f5c 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -18,16 +18,17 @@ */ import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; -import type { - AssistantTextPhase, - ProviderRetryEvent, - ProviderRetryScheduledEvent, - SandboxBoundaryRequestEvent, - UserQuestionRequestEvent, - SessionEvent, - ShellRunSnapshotResult, - ToolOutputStream, - ToolResultContent, +import { + ASSISTANT_PROGRESS_TOOL_NAME, + type AssistantTextPhase, + type ProviderRetryEvent, + type ProviderRetryScheduledEvent, + type SandboxBoundaryRequestEvent, + type UserQuestionRequestEvent, + type SessionEvent, + type ShellRunSnapshotResult, + type ToolOutputStream, + type ToolResultContent, } from '@maka/core/events'; import { deriveTurnRecords, @@ -815,9 +816,10 @@ export function applyMakaSessionEventToTranscript( // renders normally and the tool_result fold below still applies. const ref = event.shellRunRef ?? readArgsRef(event.args); const suppressed = - (event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') && - !!ref && - !!findShellRunParent(state, ref, event.toolUseId); + event.toolName === ASSISTANT_PROGRESS_TOOL_NAME || + ((event.toolName === 'Read' || event.toolName === 'StopBackgroundTask') && + !!ref && + !!findShellRunParent(state, ref, event.toolUseId)); state.entries.push({ kind: 'tool', turnId: event.turnId, @@ -841,6 +843,10 @@ export function applyMakaSessionEventToTranscript( case 'tool_result': { const tool = findToolEntry(state, event.toolUseId); + if (tool && tool.toolName === ASSISTANT_PROGRESS_TOOL_NAME) { + state.entries.splice(state.entries.indexOf(tool), 1); + break; + } if (tool?.suppressed && event.contentOmitted && !event.isError) { state.entries.splice(state.entries.indexOf(tool), 1); break; @@ -1088,15 +1094,18 @@ function storedMessagesToTranscriptEntries( expanded: false, }); } - entries.push({ - kind: 'assistant', - messageId: message.id, - text: message.text, - ...(message.phase !== undefined ? { phase: message.phase } : {}), - }); + if (message.text.trim()) { + entries.push({ + kind: 'assistant', + messageId: message.id, + text: message.text, + ...(message.phase !== undefined ? { phase: message.phase } : {}), + }); + } break; } case 'tool_call': + if (message.toolName === ASSISTANT_PROGRESS_TOOL_NAME) break; entries.push( storedToolToTranscriptEntry( message, @@ -1528,7 +1537,7 @@ function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number) case 'goal_continuation': return renderGoalContinuationBlock(entry.text, contentWidth); case 'assistant': - return renderAssistantBlock(entry.text, contentWidth); + return renderAssistantBlock(entry.text, contentWidth, entry.phase); case 'thinking': return renderThinkingBlock(entry, contentWidth, entry.expanded); case 'tool': @@ -1565,7 +1574,7 @@ function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): case 'assistant': // text_complete authoritatively replaces streamed text, including with a // same-length final, so the full value must participate in the cache key. - return `assistant|${width}|${entry.text}`; + return `assistant|${width}|${entry.phase ?? ''}|${entry.text}`; case 'thinking': // Not just the length: `thinking_complete` can replace the streamed text // in place with a same-length final, which a length-only key would miss and @@ -2183,11 +2192,14 @@ function renderGoalContinuationBlock(text: string, width: number): string[] { } /** An assistant turn: bare markdown prose, no speaker label or indent. */ -function renderAssistantBlock(text: string, width: number): string[] { +function renderAssistantBlock(text: string, width: number, phase?: AssistantTextPhase): string[] { if (!text.trim()) return []; - return new Markdown(text, 0, 0, markdownTheme, undefined, { preserveOrderedListMarkers: true }) + const lines = new Markdown(text, 0, 0, markdownTheme, undefined, { + preserveOrderedListMarkers: true, + }) .render(width) .map((line) => fitLine(line, width)); + return phase === 'commentary' ? lines.map(ansi.muted) : lines; } function renderNotice(entry: MakaPiNoticeEntry, width: number): string[] { diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 40e061f2c2..89ec231006 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -69,6 +69,12 @@ export const TOOL_ACTIVITY_KINDS = [ ] as const; export type ToolActivityKind = (typeof TOOL_ACTIVITY_KINDS)[number]; export const ASSISTANT_TEXT_PHASES = ['commentary', 'final_answer'] as const; +/** + * Runtime-owned compatibility transport for providers without native + * assistant text phases. Clients render the projected commentary text and + * hide this implementation-detail tool from the activity log. + */ +export const ASSISTANT_PROGRESS_TOOL_NAME = 'ProgressUpdate'; /** * Maka-owned assistant text semantics. * diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index bfad7d8f6f..33e23602fe 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -35,7 +35,7 @@ import { type ExecutionBoundary, } from '@maka/core/sandbox-boundary'; import type { SessionHeader } from '@maka/core/session'; -import type { StorageRef } from '@maka/core/events'; +import { ASSISTANT_PROGRESS_TOOL_NAME, type StorageRef } from '@maka/core/events'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; @@ -6093,6 +6093,7 @@ describe('AiSdkBackend model history', () => { { text: 'The implementation is ready.', phase: 'final_answer' }, ], ); + assert.equal(modelToolNames(model).includes(ASSISTANT_PROGRESS_TOOL_NAME), false); assert.match(JSON.stringify(model.doStreamCalls[1]), /commentary_continuation/); }); @@ -6348,6 +6349,204 @@ describe('AiSdkBackend model history', () => { ); }); + test('projects ProgressUpdate tool calls as commentary for phase-less providers', async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const chunks: LanguageModelV4StreamPart[] = + calls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'progress-1', + toolName: ASSISTANT_PROGRESS_TOOL_NAME, + input: JSON.stringify({ + text: 'I am checking the recent repository activity.', + }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { + type: 'text-delta', + id: 'text-final', + delta: 'The repository activity is summarized.', + }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-1', 'summarize recent activity'); + const assistants: AssistantMessage[] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') assistants.push(message); + }, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(calls, 2); + assert.equal(modelToolNames(model).includes(ASSISTANT_PROGRESS_TOOL_NAME), true); + assert.deepEqual(model.doStreamCalls[0]?.toolChoice, { + type: 'tool', + toolName: ASSISTANT_PROGRESS_TOOL_NAME, + }); + assert.deepEqual(model.doStreamCalls[1]?.toolChoice, { type: 'auto' }); + assert.deepEqual( + events.flatMap((event) => + event.type === 'text_complete' ? [{ text: event.text, phase: event.phase }] : [], + ), + [ + { + text: 'I am checking the recent repository activity.', + phase: 'commentary', + }, + { + text: 'The repository activity is summarized.', + phase: 'final_answer', + }, + ], + ); + assert.deepEqual( + assistants.map((message) => ({ text: message.text, phase: message.phase })), + [ + { + text: 'I am checking the recent repository activity.', + phase: 'commentary', + }, + { + text: 'The repository activity is summarized.', + phase: 'final_answer', + }, + ], + ); + }); + + test('does not force ProgressUpdate again when the provider also emits commentary text', async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + const chunks: LanguageModelV4StreamPart[] = + calls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-commentary' }, + { + type: 'text-delta', + id: 'text-commentary', + delta: 'I am checking the recent repository activity.', + }, + { type: 'text-end', id: 'text-commentary' }, + { + type: 'tool-call', + toolCallId: 'progress-1', + toolName: ASSISTANT_PROGRESS_TOOL_NAME, + input: JSON.stringify({ + text: 'I am checking the recent repository activity.', + }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-final' }, + { + type: 'text-delta', + id: 'text-final', + delta: 'The repository activity is summarized.', + }, + { type: 'text-end', id: 'text-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-1', 'summarize recent activity'); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + appendMessage: async () => {}, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + const events = await drainDurably(backend.send(durable.input()), durable); + + assert.equal(calls, 2); + assert.deepEqual(model.doStreamCalls[0]?.toolChoice, { + type: 'tool', + toolName: ASSISTANT_PROGRESS_TOOL_NAME, + }); + assert.deepEqual(model.doStreamCalls[1]?.toolChoice, { type: 'auto' }); + assert.deepEqual( + events.flatMap((event) => + event.type === 'text_complete' ? [{ text: event.text, phase: event.phase }] : [], + ), + [ + { + text: 'I am checking the recent repository activity.', + phase: 'commentary', + }, + { + text: 'The repository activity is summarized.', + phase: 'final_answer', + }, + ], + ); + }); + test('does not carry a tool-only step into the next step text phase', async () => { const loop = countingToolLoopModel(1); const durable = durableTurnHarness('turn-1', 'inspect README'); @@ -9481,16 +9680,20 @@ describe('AiSdkBackend request-shape diagnostics', () => { events.push(event); } - assert.deepEqual(modelToolNames(model), sortedModelToolNames(['Read', 'WebFetch'])); + assert.deepEqual( + modelToolNames(model), + sortedModelToolNames(['Read', 'WebFetch', ASSISTANT_PROGRESS_TOOL_NAME]), + ); assert.equal(modelToolNames(model).includes(TOOL_SEARCH_NAME), false); - // toolCount tracks the model-visible (active) tools — the two real tools. + // toolCount tracks the model-visible tools, including the runtime-owned + // progress transport for phase-less providers. // The invalid fallback lives in providerTools but is never advertised, so // it is not counted (toolCount is the wire-visible subset). const usageEvent = events.find( (event): event is Extract => event.type === 'token_usage', ); - assert.equal(toolSchemaPromptSegment(usageEvent)?.toolCount, 2); + assert.equal(toolSchemaPromptSegment(usageEvent)?.toolCount, 3); }); test('volatile turn-tail facts do not churn the durable prefix hash', async () => { diff --git a/packages/runtime/src/__tests__/code-mode-backend.test.ts b/packages/runtime/src/__tests__/code-mode-backend.test.ts index 61b37f8c83..6a3f4a229c 100644 --- a/packages/runtime/src/__tests__/code-mode-backend.test.ts +++ b/packages/runtime/src/__tests__/code-mode-backend.test.ts @@ -27,7 +27,7 @@ import { } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { type LlmConnection } from '@maka/core/llm-connections'; -import { type SessionEvent } from '@maka/core/events'; +import { ASSISTANT_PROGRESS_TOOL_NAME, type SessionEvent } from '@maka/core/events'; import { type SessionHeader } from '@maka/core/session'; import type { McpToolBinding } from '@maka/core/mcp'; import { MockLanguageModelV4, convertArrayToReadableStream } from 'ai/test'; @@ -64,8 +64,8 @@ test('adds exec only for the explicit code_mode provider surface', async () => { }), ); - assert.deepEqual(directSurface[0], ['lookup']); - assert.deepEqual(codeSurface[0], ['exec', 'lookup']); + assert.deepEqual(directSurface[0], [ASSISTANT_PROGRESS_TOOL_NAME, 'lookup']); + assert.deepEqual(codeSurface[0], [ASSISTANT_PROGRESS_TOOL_NAME, 'exec', 'lookup']); }); test('allows a custom exec tool in direct mode', async () => { @@ -83,7 +83,7 @@ test('allows a custom exec tool in direct mode', async () => { }).send({ turnId: 'turn-direct-exec', text: 'inspect', context: [], toolMode: 'direct' }), ); - assert.deepEqual(surface[0], ['exec']); + assert.deepEqual(surface[0], [ASSISTANT_PROGRESS_TOOL_NAME, 'exec']); }); test('rejects an invalid runtime tool mode instead of enabling Code Mode', async () => { diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index cbac5ac106..015813e9ec 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -24,7 +24,7 @@ import type { AgentRunHeader } from '@maka/core/agent-run'; import type { LlmConnection } from '@maka/core/llm-connections'; -import type { SessionEvent } from '@maka/core/events'; +import { ASSISTANT_PROGRESS_TOOL_NAME, type SessionEvent } from '@maka/core/events'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; @@ -196,7 +196,7 @@ describe('Anthropic-compatible Computer Use product loops', () => { assert.deepEqual(toolResults, [{ isError: false }, { isError: false }, { isError: false }]); assert.deepEqual( (requestBodies[0].tools as Array<{ name: string }>).map((tool) => tool.name), - ['maka_computer'], + ['maka_computer', ASSISTANT_PROGRESS_TOOL_NAME], ); if (provider.expectedThinking) { for (const body of requestBodies) { @@ -755,7 +755,7 @@ describe('OpenAI-compatible product loops', () => { (requestBodies[0]!.tools as Array<{ function?: { name?: string } }>).map( (tool) => tool.function?.name, ), - ['maka_computer'], + ['maka_computer', ASSISTANT_PROGRESS_TOOL_NAME], ); assertOpenAiReasoningAndToolPair(requestBodies[1]?.messages, 1); assertOpenAiReasoningAndToolPair(requestBodies[2]?.messages, 2); diff --git a/packages/runtime/src/__tests__/main-session-prompt.test.ts b/packages/runtime/src/__tests__/main-session-prompt.test.ts index 22b9c3da00..07f03701a1 100644 --- a/packages/runtime/src/__tests__/main-session-prompt.test.ts +++ b/packages/runtime/src/__tests__/main-session-prompt.test.ts @@ -25,9 +25,24 @@ test('main-session prompt distinguishes progress updates from runtime activity a const prompt = assembleMainSessionSystemPrompt(['Project instructions']); assert.match(prompt, /progress update before the first non-trivial tool call/); + assert.match(prompt, /concrete area you will inspect or change/); + assert.match(prompt, /concrete finding or completed milestone/); + assert.match(prompt, /never more than two short sentences/); + assert.match(prompt, /Avoid empty narration/); assert.match(prompt, /before the next tool call in the same response/); assert.match(prompt, /Do not end a response after merely saying what you will do/); - assert.match(prompt, /do not expose hidden reasoning or repeat raw tool activity/); + assert.match(prompt, /When the ProgressUpdate tool is available/); + assert.match(prompt, /Runtime may require it as a separate first step/); + assert.match(prompt, /Never call a work tool before the first ProgressUpdate/); + assert.match(prompt, /Otherwise call ProgressUpdate alone first/); + assert.match(prompt, /A ProgressUpdate is not a final answer/); + assert.match( + prompt, + /Do not expose hidden reasoning or repeat commands, tool names, counts, durations/, + ); + assert.match(prompt, /exactly one obvious, quick tool call/); + assert.match(prompt, /unless Runtime explicitly requires ProgressUpdate/); + assert.match(prompt, /several requested facts is a multi-step task/); assert.match(prompt, /distinct final answer/); assert.match(prompt, /Project instructions$/); }); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 39d5b7ed65..c91403c9d3 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -38,26 +38,27 @@ * └─ yield from queue */ -import type { - AssistantTextPhase, - SessionEvent, - CompleteEvent, - AbortEvent, - ErrorEvent, - TextCompleteEvent, - ThinkingCompleteEvent, - TokenUsageEvent, - TextDeltaEvent, - ThinkingDeltaEvent, - ProviderRetryEvent, - ProviderRetryReason, - ToolResultEvent, - ToolResultContent, - ToolStartEvent, - StorageRef, - AttachmentRef, - QuoteRef, - ContextBudgetExhaustedDetail, +import { + ASSISTANT_PROGRESS_TOOL_NAME, + type AssistantTextPhase, + type SessionEvent, + type CompleteEvent, + type AbortEvent, + type ErrorEvent, + type TextCompleteEvent, + type ThinkingCompleteEvent, + type TokenUsageEvent, + type TextDeltaEvent, + type ThinkingDeltaEvent, + type ProviderRetryEvent, + type ProviderRetryReason, + type ToolResultEvent, + type ToolResultContent, + type ToolStartEvent, + type StorageRef, + type AttachmentRef, + type QuoteRef, + type ContextBudgetExhaustedDetail, } from '@maka/core/events'; import type { StoredMessage, @@ -243,6 +244,7 @@ import { type ToolAvailabilityConfig, type ToolAvailabilityPlan, } from './tool-availability.js'; +import { assistantProgressText, buildAssistantProgressTool } from './assistant-progress-tool.js'; import { renderSwarmModePrompt } from './swarm-mode.js'; import { renderGraphModePrompt } from './graph-mode.js'; import { @@ -1081,6 +1083,7 @@ export class AiSdkBackend implements AgentBackend { private readonly modelAdapter: ModelAdapter; private readonly resolvedProviderOptions: Record; private readonly toolAvailabilityRuntime: ToolAvailabilityRuntime; + private readonly forceInitialProgressUpdate: boolean; private readonly applyPatchProfile: ApplyPatchProfile | null; /** Bounds outstanding Code Mode cells on this backend. */ @@ -1154,6 +1157,9 @@ export class AiSdkBackend implements AgentBackend { appendTurnTailPrompt: (content, turnTailPrompt) => this.appendTurnTailPrompt(content, turnTailPrompt), }); + if (input.tools.some((tool) => tool.name === ASSISTANT_PROGRESS_TOOL_NAME)) { + throw new Error(`Tool name "${ASSISTANT_PROGRESS_TOOL_NAME}" is reserved by Runtime`); + } if ( input.tools.some( (tool) => tool.name === MEMORY_REMEMBER_TOOL_NAME || tool.name === MEMORY_EXTRACT_TOOL_NAME, @@ -1161,6 +1167,7 @@ export class AiSdkBackend implements AgentBackend { ) { throw new Error('Long-term Memory trigger tool names are reserved by Runtime'); } + const runtime = resolveModelRuntime(input.connection, input.modelId); const memoryTools = input.memoryExtraction ? buildMemoryExtractionTriggerTools({ capabilities: input.memoryExtraction, @@ -1177,13 +1184,21 @@ export class AiSdkBackend implements AgentBackend { : {}), }) : []; - const runtime = resolveModelRuntime(input.connection, input.modelId); + const progressTools = + runtime.wire !== 'openai-responses' && input.header.subagentParent === undefined + ? [buildAssistantProgressTool()] + : []; + this.forceInitialProgressUpdate = + progressTools.length > 0 && runtime.parallelToolCalls !== true; this.applyPatchProfile = runtime.applyPatchProfile; const modelTools = routeApplyPatchTools(input.tools, this.applyPatchProfile); this.toolAvailabilityRuntime = new ToolAvailabilityRuntime( // The archive decoder is a runtime protocol tool, not a host binding: // this session's placeholders name it, so this session advertises it. - bindToolResultArchiveDecoder([...modelTools, ...memoryTools], input.toolResultArchive), + bindToolResultArchiveDecoder( + [...modelTools, ...memoryTools, ...progressTools], + input.toolResultArchive, + ), input.toolAvailability, buildInvalidMakaTool(), ); @@ -2199,6 +2214,7 @@ export class AiSdkBackend implements AgentBackend { let terminalProviderError: unknown; let commentaryContinuationPending = false; let commentaryContinuationUsed = false; + let initialProgressUpdatePending = this.forceInitialProgressUpdate; agentLoop: for (;;) { await this.drainSteeringInto(scope, input, queue); if (this.input.loadTurnRuntimeEvents) { @@ -2314,6 +2330,15 @@ export class AiSdkBackend implements AgentBackend { messages: attemptMessages, tools: modelTools, activeTools: activeToolsForRequest, + ...(initialProgressUpdatePending && + activeToolsForRequest.includes(ASSISTANT_PROGRESS_TOOL_NAME) + ? { + toolChoice: { + type: 'tool' as const, + toolName: ASSISTANT_PROGRESS_TOOL_NAME, + }, + } + : {}), onStreamActivity: () => requestWatchdog?.markActivity(), repairToolCall: async ({ toolCall, @@ -2520,6 +2545,27 @@ export class AiSdkBackend implements AgentBackend { : {}), } satisfies ToolStartEvent); } else { + const progressText = + event.toolCall.toolName === ASSISTANT_PROGRESS_TOOL_NAME + ? assistantProgressText(event.toolCall.input) + : undefined; + if (progressText !== undefined) { + initialProgressUpdatePending = false; + if (stepText.length === 0) { + stepTextPhase = 'commentary'; + stepText += progressText; + attemptSawText = true; + queue.push({ + type: 'text_delta', + id: this.newId(), + turnId, + ts: this.now(), + messageId: currentStepMessageId, + text: progressText, + phase: 'commentary', + } satisfies TextDeltaEvent); + } + } stepHasClientToolCall = true; returnedToolCalls.push(event.toolCall); } diff --git a/packages/runtime/src/assistant-progress-tool.ts b/packages/runtime/src/assistant-progress-tool.ts new file mode 100644 index 0000000000..9ace15dee6 --- /dev/null +++ b/packages/runtime/src/assistant-progress-tool.ts @@ -0,0 +1,53 @@ +/* + * 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. + */ + +import { ASSISTANT_PROGRESS_TOOL_NAME } from '@maka/core/events'; +import { z } from 'zod'; + +import type { MakaTool } from './tool-runtime.js'; + +export const ASSISTANT_PROGRESS_MAX_CHARS = 500; + +const assistantProgressInputSchema = z + .object({ + text: z + .string() + .trim() + .min(1) + .max(ASSISTANT_PROGRESS_MAX_CHARS) + .describe('A brief user-facing progress update.'), + }) + .strict(); + +export function buildAssistantProgressTool(): MakaTool<{ text: string }> { + return { + name: ASSISTANT_PROGRESS_TOOL_NAME, + description: + 'Required before the first work tool in a multi-step task: send one brief user-facing progress update. If it cannot be called alongside the work tool, call it alone first. This is not a final answer; continue the task afterward.', + parameters: assistantProgressInputSchema, + nesting: 'direct_only', + recoveryMode: 'idempotent', + impl: () => ({ status: 'displayed' as const }), + }; +} + +export function assistantProgressText(input: unknown): string | undefined { + const decoded = assistantProgressInputSchema.safeParse(input); + return decoded.success ? decoded.data.text : undefined; +} diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index fa50d5d33b..f48e0b96f6 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -129,6 +129,7 @@ export interface ModelAdapterStreamInput { messages: ModelMessage[]; tools: ModelToolSet; activeTools: string[]; + toolChoice?: 'auto' | 'none' | { type: 'tool'; toolName: string }; /** Observe each successfully pulled SDK stream part before semantic translation. */ onStreamActivity: () => void; system?: string; @@ -297,6 +298,10 @@ export class ModelAdapter { const providerSystem = input.system ? remapProviderToolNamesInText(input.system, providerToolName) : undefined; + const toolChoice = + typeof input.toolChoice === 'object' + ? { ...input.toolChoice, toolName: providerToolName(input.toolChoice.toolName) } + : input.toolChoice; const providerOptions = usesNativeOpenAiResponses(this.input.connection, this.runtime) ? mergeOpenAiResponsesProviderOptions( this.input.providerOptions, @@ -315,7 +320,11 @@ export class ModelAdapter { // ordinary text when the SDK leaves toolChoice at its `auto` default. // The child-agent finalization step relies on this boundary to spend its // last budgeted request on a summary instead of one more unusable call. - ...(input.activeTools.length === 0 ? { toolChoice: 'none' } : {}), + ...(toolChoice !== undefined + ? { toolChoice } + : input.activeTools.length === 0 + ? { toolChoice: 'none' } + : {}), repairToolCall: async ({ toolCall, error, diff --git a/packages/runtime/src/system-prompt/main-session-prompt.ts b/packages/runtime/src/system-prompt/main-session-prompt.ts index 6529278dfa..88b00324a6 100644 --- a/packages/runtime/src/system-prompt/main-session-prompt.ts +++ b/packages/runtime/src/system-prompt/main-session-prompt.ts @@ -59,10 +59,18 @@ function buildProgressUpdatesPromptFragment(): string { return `## Progress updates For tasks that require tools or multiple steps, send a brief user-facing progress update before the first non-trivial tool call. -Send another update only when you reach a meaningful phase change, discover information that changes the plan, or finish a long-running operation. +The opening update should name the concrete area you will inspect or change and what you expect to learn or accomplish. +Send another update only when you reach a meaningful phase change, discover information that changes the plan, finish a long-running operation, or have completed several non-trivial tool calls without any user-visible update. +Later updates should state a concrete finding or completed milestone and the next action when more work remains. When more work remains, put the progress update before the next tool call in the same response. Do not end a response after merely saying what you will do. -Keep updates to one or two concise sentences. Describe your intent or findings; do not expose hidden reasoning or repeat raw tool activity that the interface already shows. -Skip progress updates for simple answers and trivial single-step actions. +When the ProgressUpdate tool is available, it is the required transport for these updates. Runtime may require it as a separate first step; when that happens, describe the concrete task you are about to perform and then continue. Never call a work tool before the first ProgressUpdate in a multi-step task. +Call ProgressUpdate alongside the next work tool when the provider supports multiple tool calls. Otherwise call ProgressUpdate alone first, then continue with the work tool in the next response. +A ProgressUpdate is not a final answer, so continue the task after sending it. +Keep most updates to one concise sentence and never more than two short sentences. +Avoid empty narration such as "I will take a look", "Working on it", "Continuing", or announcing a routine tool choice. Describe useful intent, findings, decisions, or changed direction instead. +Do not expose hidden reasoning or repeat commands, tool names, counts, durations, or other raw activity that the interface already shows. +Skip progress updates only when no tool is needed or exactly one obvious, quick tool call answers the whole request, unless Runtime explicitly requires ProgressUpdate for that step. +Checking several requested facts is a multi-step task even when those checks could be combined into one shell command. End the turn with a distinct final answer that states the outcome.`; } diff --git a/packages/runtime/src/tool-availability.ts b/packages/runtime/src/tool-availability.ts index cba36581e7..00836692b9 100644 --- a/packages/runtime/src/tool-availability.ts +++ b/packages/runtime/src/tool-availability.ts @@ -17,6 +17,7 @@ * under the License. */ +import { ASSISTANT_PROGRESS_TOOL_NAME } from '@maka/core/events'; import type { ToolAvailabilityDiagnostic } from '@maka/core/usage-stats/types'; import MiniSearch from 'minisearch'; import { z } from 'zod'; @@ -35,6 +36,7 @@ export const TOOL_SEARCH_MAX_SCHEMA_CHARS = 64 * 1024; /** Tools that remain visible whenever they are bound. */ const DIRECT_TOOL_NAMES: ReadonlySet = new Set([ + ASSISTANT_PROGRESS_TOOL_NAME, 'Bash', 'Read', 'ArchiveRead', diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index a3aa2f77c4..e584816763 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -19,7 +19,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import { encodeToolStepProgress } from '@maka/core/events'; +import { ASSISTANT_PROGRESS_TOOL_NAME, encodeToolStepProgress } from '@maka/core/events'; import { applyLiveTurnEvent, armLiveTurn, @@ -79,6 +79,44 @@ describe('provider retry copy', () => { }); describe('applyLiveTurnEvent', () => { + it('hides the progress transport while preserving projected commentary', () => { + let projection = applyLiveTurnEvent(undefined, { + type: 'text_delta', + id: 'commentary-delta', + turnId: 'turn-1', + messageId: 'step-1', + ts: 1, + text: 'I am checking the recent repository activity.', + phase: 'commentary', + }); + projection = applyLiveTurnEvent(projection, { + type: 'tool_start', + id: 'progress-start', + turnId: 'turn-1', + stepId: 'step-1', + toolUseId: 'progress-1', + toolName: ASSISTANT_PROGRESS_TOOL_NAME, + args: { text: 'I am checking the recent repository activity.' }, + ts: 2, + }); + projection = applyLiveTurnEvent(projection, { + type: 'tool_result', + id: 'progress-result', + turnId: 'turn-1', + toolUseId: 'progress-1', + isError: false, + content: { kind: 'json', value: { status: 'displayed' } }, + ts: 3, + }); + + assert.equal(projection?.steps[0]?.text?.phase, 'commentary'); + assert.equal( + projection?.steps[0]?.text?.text, + 'I am checking the recent repository activity.', + ); + assert.deepEqual(projection?.steps[0]?.tools, []); + }); + it('keeps every streamed prefix oracle-equivalent and drops raw state on terminal events', () => { const input = 'api_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY tail'; let projection: LiveTurnProjection | undefined; diff --git a/packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx b/packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx index 9b4899025d..f6d415feb4 100644 --- a/packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx +++ b/packages/ui/src/__tests__/markdown-rhythm-contract.test.tsx @@ -135,8 +135,33 @@ describe('transcript markdown rhythm', () => { rule[1], /padding-block\s*:\s*0/, 'ListItem block padding is no longer zeroed on the compact surface, so the real ' + - 'list-item gap is padding + gap and the declared ladder is not the spacing you get. ' + + 'list-item gap is padding + gap and the declared ladder is not the spacing you get. ' + `Found: { ${rule[1].trim()} }`, ); }); + + it('gives muted markdown a semantic color boundary for nested Astryx controls', async () => { + const markup = renderToStaticMarkup( + , + ); + assert.match( + markup, + /data-maka-markdown-tone="muted"/, + 'muted markdown lost the DOM hook used to recolor nested Astryx controls', + ); + + const css = (await readFile(join(UI_SRC, 'styles.css'), 'utf8')).replace(/\/\*[\s\S]*?\*\//g, ''); + const rule = new RegExp( + String.raw`data-maka-markdown-tone="muted"[^{]*\.astryx-markdown\s*\{([^}]*)\}`, + ).exec(css); + assert.ok( + rule, + 'muted markdown no longer scopes its Astryx token overrides to the document root', + ); + assert.match( + rule[1], + /--color-text-primary\s*:\s*var\(--muted-foreground\)/, + 'nested Markdown controls can restore primary text and visually compete with commentary', + ); + }); }); diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 8b29884015..bb4200c196 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -19,6 +19,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; +import { ASSISTANT_PROGRESS_TOOL_NAME } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; import { materializeChat, @@ -381,6 +382,44 @@ test('retains persisted nested tool activity identity', () => { }); }); +test('hides the persisted progress transport while preserving commentary text', () => { + const messages: StoredMessage[] = [ + userMsg('turn-1', 1, 'inspect the repository'), + { + type: 'assistant', + id: 'commentary-1', + turnId: 'turn-1', + ts: 2, + text: 'I am checking the recent repository activity.', + phase: 'commentary', + modelId: 'fixture', + }, + { + type: 'tool_call', + id: 'progress-1', + turnId: 'turn-1', + ts: 3, + toolName: ASSISTANT_PROGRESS_TOOL_NAME, + args: { text: 'I am checking the recent repository activity.' }, + stepId: 'commentary-1', + }, + { + type: 'tool_result', + id: 'progress-result-1', + turnId: 'turn-1', + ts: 4, + toolUseId: 'progress-1', + isError: false, + content: { kind: 'json', value: { status: 'displayed' } }, + }, + ]; + + assert.deepEqual(materializeTools(messages), []); + assert.deepEqual(timelineText(materializeTurns(messages)[0]), [ + 'text:I am checking the recent repository activity.', + ]); +}); + function shellRunResult(revision: number) { return { kind: "shell_run" as const, diff --git a/packages/ui/src/__tests__/processing-block.test.tsx b/packages/ui/src/__tests__/processing-block.test.tsx new file mode 100644 index 0000000000..61a3eb6a9e --- /dev/null +++ b/packages/ui/src/__tests__/processing-block.test.tsx @@ -0,0 +1,247 @@ +/* + * 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. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { parseHTML } from 'linkedom'; +import { TurnView } from '../chat-turn.js'; +import { LocaleProvider } from '../locale-context.js'; +import type { ToolActivityItem, TurnViewModel } from '../materialize.js'; + +const originalGlobals = { + cancelAnimationFrame: globalThis.cancelAnimationFrame, + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function domRoot() { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + cancelAnimationFrame() {}, + document, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + window.matchMedia = globalThis.matchMedia; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return { container, root }; +} + +function renderTurn( + root: ReturnType, + turn: TurnViewModel, + live: boolean, +): Promise { + return act(() => { + root.render( + + + , + ); + }) as unknown as Promise; +} + +function fixtureTurn(timeline: TurnViewModel['timeline']): TurnViewModel { + return { + turnId: 'turn-1', + status: 'running', + partialOutputRetained: false, + tools: timeline.flatMap((item) => item.kind === 'tools' ? item.items : []), + notes: [], + timeline, + startedAt: 1, + }; +} + +function workTimeline( + includeFinalAnswer: boolean, + finalAnswerLive = true, +): TurnViewModel['timeline'] { + const read: ToolActivityItem = { + toolUseId: 'read-1', + toolName: 'Read', + activityKind: 'read', + status: 'completed', + args: {}, + }; + return [ + { + kind: 'text', + text: '准备检查 package.json 的 name 字段。', + messageId: 'commentary-1', + phase: 'commentary', + }, + { + kind: 'thinking', + text: 'reasoning', + messageId: 'thinking-1', + }, + { kind: 'tools', items: [read] }, + ...(includeFinalAnswer + ? [{ + kind: 'text' as const, + text: 'package name 是 maka。', + messageId: 'final-1', + phase: 'final_answer' as const, + live: finalAnswerLive, + }] + : []), + ]; +} + +test('folds completed reasoning and tool activity into one collapsed work log', () => { + const turn = { + ...fixtureTurn(workTimeline(true, false)), + status: 'completed' as const, + durationMs: 273_000, + }; + + const markup = renderToStaticMarkup( + createElement(LocaleProvider, { + locale: 'zh', + children: createElement(TurnView, { turn }), + }), + ); + + assert.match(markup, /准备检查 package\.json 的 name 字段/); + assert.match(markup, /data-processing="block"/); + assert.match(markup, /class="maka-work-log-header" aria-expanded="false"/); + assert.match(markup, /class="maka-work-log-content" hidden=""/); + assert.match(markup, /aria-expanded="false"/); + assert.match(markup, /用时 4 分钟 33 秒/); + assert.match(markup, /读取 1 个文件/); + assert.match(markup, /class="maka-processing-sequence" hidden=""/); + assert.match(markup, /package name 是 maka/); + + const { document } = parseHTML(markup); + const workLogContent = document.querySelector('.maka-work-log-content'); + const finalAnswer = [...document.querySelectorAll('.maka-chat-message-bubble-assistant')] + .find((element) => element.textContent.includes('package name 是 maka')); + assert.ok(workLogContent); + assert.ok(finalAnswer); + assert.equal(workLogContent.textContent.includes('package name 是 maka'), false); + assert.equal(workLogContent.contains(finalAnswer), false); +}); + +test('keeps live work expanded, then collapses it once when the final answer appears', async () => { + const { container, root } = domRoot(); + + await renderTurn(root, fixtureTurn(workTimeline(false)), true); + const processingHeader = container.querySelector('.maka-processing-header'); + assert.ok(processingHeader); + assert.equal(container.querySelector('.maka-work-log-header'), null); + assert.equal(processingHeader.getAttribute('aria-expanded'), 'true'); + assert.equal(container.querySelector('.maka-processing-sequence')?.hasAttribute('hidden'), false); + + await renderTurn(root, fixtureTurn(workTimeline(true)), true); + const workLogHeader = container.querySelector('.maka-work-log-header'); + assert.ok(workLogHeader); + assert.equal(workLogHeader.getAttribute('aria-expanded'), 'false'); + assert.equal(container.querySelector('.maka-work-log-content')?.hasAttribute('hidden'), true); + assert.equal(processingHeader.getAttribute('aria-expanded'), 'false'); + + await act(() => workLogHeader.dispatchEvent(new window.Event('click', { bubbles: true }))); + assert.equal(workLogHeader.getAttribute('aria-expanded'), 'true'); + + await renderTurn(root, fixtureTurn([ + ...workTimeline(false), + { + kind: 'text', + text: 'package name 是 maka,验证完成。', + messageId: 'final-1', + phase: 'final_answer', + live: true, + }, + ]), true); + assert.equal( + workLogHeader.getAttribute('aria-expanded'), + 'true', + 'later final-answer deltas do not override a manual reopen', + ); +}); + +test('does not create a work log for a direct final answer', () => { + const turn = { + ...fixtureTurn([ + { + kind: 'text' as const, + text: '直接答案。', + messageId: 'final-1', + phase: 'final_answer' as const, + }, + ]), + status: 'completed' as const, + }; + + const markup = renderToStaticMarkup( + createElement(LocaleProvider, { + locale: 'zh', + children: createElement(TurnView, { turn }), + }), + ); + + assert.doesNotMatch(markup, /data-work-log="true"/); + assert.match(markup, /直接答案/); +}); + +test('collapses commentary-only failed work while leaving the failure outcome outside', () => { + const turn = { + ...fixtureTurn(workTimeline(false)), + status: 'failed' as const, + }; + const markup = renderToStaticMarkup( + createElement(LocaleProvider, { + locale: 'zh', + children: createElement(TurnView, { + turn, + failedReasonLabel: '模型请求失败', + }), + }), + ); + + const { document } = parseHTML(markup); + const workLogContent = document.querySelector('.maka-work-log-content'); + assert.ok(workLogContent); + assert.equal(workLogContent.hasAttribute('hidden'), true); + assert.equal(workLogContent.textContent.includes('准备检查 package.json'), true); + assert.equal(workLogContent.textContent.includes('模型请求失败'), false); + assert.match(markup, /模型请求失败/); +}); diff --git a/packages/ui/src/__tests__/processing-summary.test.ts b/packages/ui/src/__tests__/processing-summary.test.ts new file mode 100644 index 0000000000..707ff566e5 --- /dev/null +++ b/packages/ui/src/__tests__/processing-summary.test.ts @@ -0,0 +1,132 @@ +/* + * 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. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { ToolActivityItem, TurnTimelineItem } from '../materialize.js'; +import { + isProcessingRunning, + processingHasError, + summarizeProcessing, +} from '../processing-summary.js'; +import type { FoldedTimelineChild } from '../timeline-fold.js'; + +function thinking(live = false): FoldedTimelineChild { + return { kind: 'thinking', text: 'reasoning', messageId: 'thinking-1', live }; +} + +function tools(...items: ToolActivityItem[]): FoldedTimelineChild { + return { kind: 'tools', items }; +} + +function tool( + toolUseId: string, + toolName: string, + status: ToolActivityItem['status'], + activityKind?: ToolActivityItem['activityKind'], + intent?: string, +): ToolActivityItem { + return { + toolUseId, + toolName, + status, + args: {}, + ...(activityKind ? { activityKind } : {}), + ...(intent ? { intent } : {}), + }; +} + +describe('processing summary', () => { + test('summarizes settled activity without counting folded reasoning', () => { + const entries = [ + thinking(), + tools( + tool('read-1', 'Read', 'completed', 'read'), + tool('bash-1', 'Bash', 'errored', 'command'), + ), + ]; + + assert.equal(summarizeProcessing(entries, 'zh'), '读取 1 个文件,运行 1 条命令,1 个失败'); + assert.equal(processingHasError(entries), true); + }); + + test('uses the latest running activity rather than replaying the raw tool log', () => { + const entries = [ + tools( + tool('read-1', 'Read', 'completed', 'read'), + tool('bash-1', 'Bash', 'running', 'command', '运行类型检查'), + ), + ]; + + assert.equal(summarizeProcessing(entries, 'zh'), '正在运行类型检查'); + assert.equal(isProcessingRunning(entries), true); + }); + + test('localizes a running tool when no intent is available', () => { + const entries = [tools(tool('read-1', 'Read', 'running', 'read'))]; + + assert.equal(summarizeProcessing(entries, 'zh'), '正在读取文件'); + assert.equal(summarizeProcessing(entries, 'en'), 'Reading a file'); + }); + + test('shows thinking when it is the latest live activity', () => { + const entries = [ + tools(tool('read-1', 'Read', 'running', 'read')), + thinking(true), + ]; + + assert.equal(summarizeProcessing(entries, 'zh'), '正在深度思考'); + }); +}); + +test('commentary text remains a boundary around folded activity', async () => { + const { foldTimeline } = await import('../timeline-fold.js'); + const timeline: TurnTimelineItem[] = [ + { + kind: 'text', + text: '准备检查实现。', + messageId: 'commentary-1', + phase: 'commentary', + }, + thinking(), + tools(tool('read-1', 'Read', 'completed', 'read')), + { + kind: 'text', + text: '已经定位到入口,接下来验证行为。', + messageId: 'commentary-2', + phase: 'commentary', + }, + tools(tool('bash-1', 'Bash', 'completed', 'command')), + { + kind: 'text', + text: '验证通过。', + messageId: 'final-1', + phase: 'final_answer', + }, + ]; + + const folded = foldTimeline(timeline); + assert.deepEqual(folded.map((entry) => entry.kind), [ + 'text', + 'processing', + 'text', + 'processing', + 'text', + ]); +}); diff --git a/packages/ui/src/__tests__/turn-running-spinner.test.tsx b/packages/ui/src/__tests__/turn-running-spinner.test.tsx index 8aef69d12a..9f8f4d185d 100644 --- a/packages/ui/src/__tests__/turn-running-spinner.test.tsx +++ b/packages/ui/src/__tests__/turn-running-spinner.test.tsx @@ -50,6 +50,24 @@ function statusHasSpinner(toolStatuses: readonly ('running' | 'completed')[]): b return document.querySelector('.maka-turn-processing .astryx-spinner') !== null; } +function runningStatusText(locale: 'en' | 'zh'): string { + const turn: TurnViewModel = { + turnId: 'turn-1', + status: 'running', + partialOutputRetained: false, + tools: [], + notes: [], + startedAt: 1, + timeline: [], + }; + const markup = renderToStaticMarkup( + + + , + ); + return parseHTML(markup).document.querySelector('.maka-turn-processing')?.textContent ?? ''; +} + test('hands the spinner to the turn status after the tool settles', () => { assert.equal(statusHasSpinner(['running']), false); assert.equal(statusHasSpinner(['completed']), true); @@ -58,3 +76,8 @@ test('hands the spinner to the turn status after the tool settles', () => { test('keeps the turn spinner when a collapsed group hides the running tool', () => { assert.equal(statusHasSpinner(['running', 'completed']), true); }); + +test('describes provider silence without inventing semantic progress', () => { + assert.equal(runningStatusText('zh'), '等待模型输出…'); + assert.equal(runningStatusText('en'), 'Waiting for model output…'); +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 53deba2af2..2b0e0a2bd7 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -17,9 +17,9 @@ * under the License. */ -import { Fragment, memo, useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; +import { Fragment, memo, useEffect, useId, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import { useMountedRef } from './use-mounted-ref.js'; -import { ICON_SIZE, Ban, Check, Copy, GitBranch, Info, Pencil, RefreshCcw, Timer } from './icons.js'; +import { ICON_SIZE, Ban, BookOpen, Check, ChevronRight, Copy, GitBranch, Info, Pencil, RefreshCcw, Timer } from './icons.js'; import { type ClipboardCopyPhase, useClipboardCopyFeedback } from './clipboard-feedback.js'; import { Markdown } from './markdown.js'; import { formatTurnDuration, turnAbortStatusLabel } from './chat-display-helpers.js'; @@ -65,6 +65,11 @@ import { AttachmentKindIcon } from './attachment-kinds.js'; import { QuoteRefChip } from './quote-ref-chip.js'; import { Marker, markerVariants } from './primitives/chat.js'; import { ToolTrow, toolTrowHasVisibleSpinner } from './tool-activity.js'; +import { + isProcessingRunning, + processingHasError, + summarizeProcessing, +} from './processing-summary.js'; import { formatBytes } from './tool-activity/preview-utils.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; @@ -619,6 +624,51 @@ export const TurnView = memo(function TurnView(props: { segment.repliesTo === undefined ? 'assistant-opening' : `assistant-after-${segment.repliesTo}`; + const segmentSettled = + !ownsTurnChrome || + (!props.liveStreaming && turn.status !== 'running'); + const finalAnswerIndex = segmentFinalAnswerIndex(segment.items, segmentSettled); + const workItems = + finalAnswerIndex < 0 ? segment.items : segment.items.slice(0, finalAnswerIndex); + const answerItems = + finalAnswerIndex < 0 ? [] : segment.items.slice(finalAnswerIndex); + const workLogCollapsed = segmentSettled || finalAnswerIndex >= 0; + const renderTimelineItem = ( + item: AssistantFoldedTimelineEntry, + index: number, + collapseProcessing: boolean, + ): ReactNode => { + if (item.kind !== 'processing') { + return ( + props.onSwitchToBypassAndRetry?.(turn.turnId) + : undefined + } + initialLiveContent={props.liveStreaming?.initialLiveContent} + /> + ); + } + return ( + props.onSwitchToBypassAndRetry?.(turn.turnId) + : undefined + } + initialLiveContent={props.liveStreaming?.initialLiveContent} + /> + ); + }; return (
{/* The turn timeline is the rendering source of truth - (materialize.ts): each step's 深度思考 disclosure, answer bubble, - and Astryx tool group in the order the model produced them. - #1307: runs of reasoning + tools between answer texts render - through the derived fold as collapsed Processing blocks. */} - {segment.items.map((item, index) => - item.kind === 'processing' ? ( - props.onSwitchToBypassAndRetry?.(turn.turnId) - : undefined - } - initialLiveContent={props.liveStreaming?.initialLiveContent} - /> - ) : ( - props.onSwitchToBypassAndRetry?.(turn.turnId) - : undefined - } - initialLiveContent={props.liveStreaming?.initialLiveContent} - /> - ), + (materialize.ts). Before a final answer, commentary and activity + stay exposed. Once the answer begins they remain mounted inside + one work-log disclosure, while the final answer stays outside. */} + {workItems.length > 0 && ( + + {workItems.map((item, index) => + renderTimelineItem(item, index, workLogCollapsed) + )} + + )} + {answerItems.map((item, index) => + renderTimelineItem(item, workItems.length + index, true) )} {/* A failed turn's banner states the OUTCOME of the turn, so it belongs after the work it is the outcome of. `description` @@ -819,6 +854,22 @@ function splitTimelineAtUserMessages( return segments; } +function segmentFinalAnswerIndex( + items: readonly AssistantFoldedTimelineEntry[], + settled: boolean, +): number { + const explicit = items.findIndex( + (item) => item.kind === 'text' && item.phase === 'final_answer', + ); + if (explicit >= 0) return explicit; + if (!settled) return -1; + const hasPhasedText = items.some( + (item) => item.kind === 'text' && item.phase !== undefined, + ); + if (hasPhasedText) return -1; + return items.findLastIndex((item) => item.kind === 'text'); +} + export interface TurnFooterActionMeta { id: 'regenerate' | 'branch' | 'copy' | 'info'; label: string; @@ -984,22 +1035,17 @@ const STATUS_FOOTER_ICON: Record = { info: