diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 669e5507ae..d23898ba63 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -785,7 +785,7 @@ "react": 1 }, "importSpecifiers": 18, - "nonTriviaTokens": 1410 + "nonTriviaTokens": 1408 }, "src/renderer/app-shell.tsx": { "importDeclarations": 102, diff --git a/apps/desktop/src/renderer/app-shell-turn-view-model.ts b/apps/desktop/src/renderer/app-shell-turn-view-model.ts index 09c6c450a8..138ece5f49 100644 --- a/apps/desktop/src/renderer/app-shell-turn-view-model.ts +++ b/apps/desktop/src/renderer/app-shell-turn-view-model.ts @@ -21,13 +21,13 @@ import { useRef } from 'react'; import type { UiLocale } from '@maka/core/ui-locale'; import { deriveTurnLineageMap, + finalAssistantReplyText, formatTurnDuration, isSandboxDeniedTool, type TurnFooterActionMeta, type TurnLineageBadge, type TurnLineageTarget, type TurnPresentation, - type TurnPresentationDeriver, type TurnViewModel, } from '@maka/ui'; import { @@ -207,7 +207,7 @@ function deriveTurnPresentationEntry(input: { const footerActions = deriveTurnFooterActions({ status: turn.status, locale: uiLocale, - hasContent: Boolean(turn.assistant?.text && turn.assistant.text.trim().length > 0), + hasContent: finalAssistantReplyText(turn).trim().length > 0, // Match the badge lineage rule (regenerate ?? legacy retry) so a turn // that already has a parallel answer hints at it in the tooltip too. ...((lineageEntry?.regeneratedToTurnId ?? lineageEntry?.retriedToTurnId) @@ -267,7 +267,7 @@ export function deriveAppShellTurnPresentation( */ export function useAppShellTurnPresentation( context: AppShellTurnPresentationContext, -): TurnPresentationDeriver { +): (turns: readonly TurnViewModel[]) => TurnPresentation { const derivation = useRef(undefined); derivation.current ??= createAppShellTurnPresentationDerivation(); return (turns) => derivation.current!.derive(turns, context); diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index feeb77b8cf..4ca276f5ed 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -24,6 +24,7 @@ import { ChatSurfaceLayout, Composer, ClientCapabilityPrompt, + finalAssistantReplyText, SandboxBoundaryPrompt, UserQuestionPrompt, useToast, @@ -247,7 +248,7 @@ export function QuoteCompanionPanel(props: { deriveTurnFooterActions({ status: turn.status, locale, - hasContent: Boolean(turn.assistant?.text?.trim()), + hasContent: finalAssistantReplyText(turn).trim().length > 0, ...(companion.regeneratePendingTurnId === turn.turnId ? { pendingActions: new Set(['regenerate'] as const) } : {}), diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index f9376cbee2..e8f71643d1 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -9981,6 +9981,97 @@ describe('AiSdkBackend RunTrace', () => { assert.equal(typeof failureTrace?.data?.redactedErrorStackSha256, 'string'); }); + test('retries an idle watchdog timeout after an unstarted Responses text item', async () => { + const timers = manualWatchdogTimer(); + let calls = 0; + const appended: StoredMessage[] = []; + const model = new MockLanguageModelV4({ + doStream: async (options) => { + calls += 1; + if (calls === 1) { + return { + stream: hangingProviderStream( + [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: 'text-1', + providerMetadata: { + openai: { itemId: 'message-item-1', phase: 'commentary' }, + }, + }, + ], + options.abortSignal, + ), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-2' }, + { type: 'text-delta', id: 'text-2', delta: 'recovered' }, + { type: 'text-end', id: 'text-2' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + appended.push(message); + }, + connection: { + ...connection(), + slug: 'openai', + providerType: 'openai', + models: [{ id: 'gpt-5.6', apiProtocol: 'openai-responses' }], + }, + apiKey: 'sk-test', + modelId: 'gpt-5.6', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + streamWatchdogTimer: timers.clock, + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + const eventsPromise = collectEvents( + backend.send({ turnId: 'turn-1', text: 'hi', context: [] }), + events, + ); + await waitFor(() => calls === 1 && timers.armCount() >= 3); + timers.fire(); + await eventsPromise; + + assert.equal(calls, 2); + assert.equal( + events.some((event) => event.type === 'provider_retry' && event.phase === 'started'), + true, + ); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); + const recovered = appended.find( + (message): message is AssistantMessage => message.type === 'assistant', + ); + assert.equal(recovered?.text, 'recovered'); + assert.equal(recovered?.providerOptions, undefined); + }); + test('does not retry an idle watchdog timeout after provider continuation metadata', async () => { const timers = manualWatchdogTimer(); let calls = 0; @@ -14828,6 +14919,7 @@ describe('AiSdkBackend steering durability and identity', () => { (message): message is AssistantMessage => message.type === 'assistant', ); assert.equal(assistant?.text, 'OneTwo'); + assert.equal(assistant?.contentOrder, undefined); assert.deepEqual(assistant?.providerOptions, { openai: { annotations: [ @@ -14838,6 +14930,187 @@ describe('AiSdkBackend steering durability and identity', () => { }); }); + test('preserves native Responses text item boundaries as separate assistant messages', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: 'text-1', + providerMetadata: { + openai: { itemId: 'message-1', phase: 'commentary' }, + }, + }, + { type: 'text-delta', id: 'text-1', delta: 'I am checking it.' }, + { + type: 'text-end', + id: 'text-1', + providerMetadata: { + openai: { itemId: 'message-1', phase: 'commentary' }, + }, + }, + { + type: 'text-start', + id: 'text-2', + providerMetadata: { + openai: { itemId: 'message-2', phase: 'final_answer' }, + }, + }, + { type: 'text-delta', id: 'text-2', delta: 'It is ready.' }, + { + type: 'text-end', + id: 'text-2', + providerMetadata: { + openai: { itemId: 'message-2', phase: 'final_answer' }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const appended: StoredMessage[] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + appended.push(message); + }, + connection: { + ...connection(), + slug: 'openai', + providerType: 'openai', + defaultModel: 'gpt-5', + }, + apiKey: 'sk-test', + modelId: 'gpt-5', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'inspect it', context: [] })); + + assert.deepEqual( + appended + .filter((message): message is AssistantMessage => message.type === 'assistant') + .map((message) => ({ + text: message.text, + providerOptions: message.providerOptions, + })), + [ + { + text: 'I am checking it.', + providerOptions: { + openai: { itemId: 'message-1', phase: 'commentary' }, + }, + }, + { + text: 'It is ready.', + providerOptions: { + openai: { itemId: 'message-2', phase: 'final_answer' }, + }, + }, + ], + ); + }); + + test('does not carry metadata from an empty Responses text item into the next item', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'text-start', + id: 'text-empty', + providerMetadata: { + openai: { itemId: 'message-empty', phase: 'commentary' }, + }, + }, + { + type: 'text-end', + id: 'text-empty', + providerMetadata: { + openai: { itemId: 'message-empty', phase: 'commentary' }, + }, + }, + { + type: 'text-start', + id: 'text-final', + providerMetadata: { + openai: { itemId: 'message-final', phase: 'final_answer' }, + }, + }, + { type: 'text-delta', id: 'text-final', delta: 'Done.' }, + { + type: 'text-end', + id: 'text-final', + providerMetadata: { + openai: { itemId: 'message-final', phase: 'final_answer' }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const appended: StoredMessage[] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + appended.push(message); + }, + connection: { + ...connection(), + slug: 'openai', + providerType: 'openai', + defaultModel: 'gpt-5', + }, + apiKey: 'sk-test', + modelId: 'gpt-5', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'finish it', context: [] })); + + assert.deepEqual( + appended + .filter((message): message is AssistantMessage => message.type === 'assistant') + .map((message) => ({ + text: message.text, + providerOptions: message.providerOptions, + })), + [ + { + text: 'Done.', + providerOptions: { + openai: { itemId: 'message-final', phase: 'final_answer' }, + }, + }, + ], + ); + }); + test('executes native WebSearch inside the primary provider stream', async () => { const model = new MockLanguageModelV4({ doStream: async () => ({ @@ -14941,6 +15214,7 @@ describe('AiSdkBackend steering durability and identity', () => { const assistant = appended.find( (message): message is AssistantMessage => message.type === 'assistant', ); + assert.deepEqual(assistant?.contentOrder, ['tools', 'text']); assert.deepEqual(assistant?.providerOptions, { openai: { itemId: 'message-1', 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..d65600740b --- /dev/null +++ b/packages/runtime/src/__tests__/main-session-prompt.test.ts @@ -0,0 +1,42 @@ +/* + * 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, /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.doesNotMatch(prompt, /ProgressUpdate/); + 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, /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 3fa8751fe9..cbc78b7034 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -555,7 +555,7 @@ describe('ModelAdapter stream and error normalization', () => { } as Chunk), [ { - kind: 'text-metadata', + kind: 'text-end', providerOptions: { openai: { itemId: 'message-1', @@ -575,6 +575,77 @@ describe('ModelAdapter stream and error normalization', () => { ); }); + test('preserves native Responses item boundaries and terminal metadata', () => { + const adapter = new ModelAdapter({ + connection: { + slug: 'openai', + providerType: 'openai', + defaultModel: 'gpt-5', + }, + apiKey: 'sk-test', + modelId: 'gpt-5', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + const metadata = { + openai: { + itemId: 'message-1', + phase: 'commentary', + }, + }; + + assert.deepEqual( + adapter.translateChunk({ + type: 'text-start', + id: 'message-1', + providerMetadata: metadata, + }), + [{ kind: 'text-start', providerItemBoundary: true }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'text-end', + id: 'message-1', + providerMetadata: metadata, + }), + [{ kind: 'text-end', providerOptions: metadata, providerItemBoundary: true }], + ); + }); + + test('does not treat Chat Completions metadata as a Responses item boundary', () => { + const adapter = new ModelAdapter({ + connection: { + slug: 'openai-chat', + providerType: 'openai-compatible', + defaultModel: 'chat-model', + }, + apiKey: 'sk-test', + modelId: 'chat-model', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + const metadata = { openai: { itemId: 'message-1', phase: 'commentary' } }; + + assert.deepEqual( + adapter.translateChunk({ + type: 'text-start', + id: 'message-1', + providerMetadata: metadata, + }), + [{ kind: 'text-start' }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'text-end', + id: 'message-1', + providerMetadata: metadata, + }), + [{ kind: 'text-end', providerOptions: metadata }], + ); + }); + test('normalizes Anthropic web search results and server-tool errors', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 4f2db9e6d4..80930c7d93 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -61,6 +61,7 @@ import type { import type { StoredMessage, AssistantMessage, + AssistantStepContentKind, AssistantThinkingPart, ToolCallMessage, ToolResultMessage, @@ -164,6 +165,7 @@ import { import { buildProviderOptions } from './model-factory.js'; import { persistedOpenAiResponsesStepMessages } from './openai-responses-continuation.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; +import { nonCanonicalContentOrder } from './runtime-event-read-model.js'; import { composeRequestProjection, type DispatchRequestShape, @@ -1453,8 +1455,12 @@ export class AiSdkBackend implements AgentBackend { let stepTextPartStartOffset = 0; let stepThinkingParts: AssistantThinkingPart[] = []; let stepThinkingPartsById = new Map(); + let stepContentOrder: AssistantStepContentKind[] = []; const startedAt = this.now(); + const recordStepContent = (kind: AssistantStepContentKind): void => { + if (!stepContentOrder.includes(kind)) stepContentOrder.push(kind); + }; // Flush the current step's AssistantMessage (text + thinking) and the paired // terminal thinking/text events, then clear the per-step accumulators. // Persist when the step produced text OR reasoning — a thinking-only step @@ -1464,11 +1470,23 @@ 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 resetStep = (): void => { + stepText = ''; + stepTextProviderOptions = undefined; + stepTextPartStartOffset = 0; + stepThinkingParts = []; + stepThinkingPartsById = new Map(); + stepContentOrder = []; + }; const flushStep = async (): Promise => { const hasThinking = stepThinkingParts.length > 0; - if (stepText.length === 0 && !hasThinking) return; + if (stepText.length === 0 && !hasThinking) { + resetStep(); + return; + } const stepId = currentStepMessageId; const thinkingText = stepThinkingParts.map((part) => part.text).join(''); + const contentOrder = nonCanonicalContentOrder(stepContentOrder); const msg: AssistantMessage = { type: 'assistant', id: stepId, @@ -1478,6 +1496,7 @@ export class AiSdkBackend implements AgentBackend { ...(stepTextProviderOptions !== undefined ? { providerOptions: stepTextProviderOptions } : {}), + ...(contentOrder ? { contentOrder } : {}), modelId: this.input.modelId, ...(hasThinking ? { @@ -1530,11 +1549,7 @@ export class AiSdkBackend implements AgentBackend { : {}), } satisfies TextCompleteEvent); scope.finalAssistantText = stepText.length > 0 ? stepText : undefined; - stepText = ''; - stepTextProviderOptions = undefined; - stepTextPartStartOffset = 0; - stepThinkingParts = []; - stepThinkingPartsById = new Map(); + resetStep(); }; let tokenUsage: NormalizedAiSdkUsage | undefined; let tokenUsageCostUsd: number | undefined; @@ -2319,8 +2334,13 @@ export class AiSdkBackend implements AgentBackend { } } if (event.kind === 'text-start') { + if (stepText.length > 0 && event.providerItemBoundary === true) { + await flushStep(); + currentStepMessageId = this.newId(); + } stepTextPartStartOffset = stepText.length; } else if (event.kind === 'text') { + if (event.text.length > 0) recordStepContent('text'); stepText += event.text; if (event.text.length > 0) attemptSawText = true; queue.push({ @@ -2331,15 +2351,21 @@ export class AiSdkBackend implements AgentBackend { messageId: currentStepMessageId, text: event.text, } satisfies TextDeltaEvent); - } else if (event.kind === 'text-metadata') { - attemptSawContinuationMetadata = true; - stepTextProviderOptions = mergeTextProviderOptions( - stepTextProviderOptions, - stripUndefinedDeep(event.providerOptions) as NonNullable< - ModelMessage['providerOptions'] - >, - stepTextPartStartOffset, - ); + } else if (event.kind === 'text-end') { + if (event.providerOptions !== undefined) { + attemptSawContinuationMetadata = true; + stepTextProviderOptions = mergeTextProviderOptions( + stepTextProviderOptions, + stripUndefinedDeep(event.providerOptions) as NonNullable< + ModelMessage['providerOptions'] + >, + stepTextPartStartOffset, + ); + } + if (event.providerItemBoundary === true) { + await flushStep(); + currentStepMessageId = this.newId(); + } } else if (event.kind === 'thinking-start') { if (event.providerOptions !== undefined) { attemptSawContinuationMetadata = true; @@ -2355,6 +2381,7 @@ export class AiSdkBackend implements AgentBackend { stepThinkingPartsById.set(event.reasoningPartId, part); } } else if (event.kind === 'thinking') { + if (event.text.length > 0) recordStepContent('thinking'); if (event.text.length > 0) attemptSawThinking = true; if (event.providerOptions !== undefined) { if (event.providerOptionsOrigin !== 'maka_transport') { @@ -2440,6 +2467,7 @@ export class AiSdkBackend implements AgentBackend { attemptSawToolActivity = true; } else if (event.kind === 'tool-call') { attemptSawToolActivity = true; + recordStepContent('tools'); if (event.toolCall.providerExecuted) { providerToolActivityCount += 1; providerToolInputs.set(event.toolCall.toolCallId, event.toolCall.input); diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index b5444bb529..24d5c26f4a 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -981,18 +981,40 @@ function translateChunk( }, ]; } - case 'text-start': - return [{ kind: 'text-start' }]; + case 'text-start': { + const providerOptions = + chunk.providerMetadata && typeof chunk.providerMetadata === 'object' + ? (chunk.providerMetadata as NonNullable) + : undefined; + const providerItemBoundary = + runtime !== undefined && + hasOpenAiResponsesAdapter(runtime) && + providerOptions !== undefined; + return [ + { + kind: 'text-start', + ...(providerItemBoundary ? { providerItemBoundary: true } : {}), + }, + ]; + } 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 providerOptions = + chunk.providerMetadata && typeof chunk.providerMetadata === 'object' + ? (chunk.providerMetadata as NonNullable) + : undefined; + const providerItemBoundary = + runtime !== undefined && + hasOpenAiResponsesAdapter(runtime) && + providerOptions !== undefined; return [ { - kind: 'text-metadata', - providerOptions: chunk.providerMetadata as NonNullable, + kind: 'text-end', + ...(providerOptions !== undefined ? { providerOptions } : {}), + ...(providerItemBoundary ? { providerItemBoundary: true } : {}), }, ]; } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index 8e771cc3ff..68e8a32c85 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -386,9 +386,18 @@ export interface ModelRequestMetadata { * recovery and terminal error emission. */ export type ModelStreamEvent = - | { kind: 'text-start' } + | { + kind: 'text-start'; + /** Native Responses output item boundary; internal to adapter/backend replay. */ + providerItemBoundary?: true; + } | { kind: 'text'; text: string } - | { kind: 'text-metadata'; providerOptions: ProviderOptions } + | { + kind: 'text-end'; + providerOptions?: ProviderOptions; + /** Native Responses output item boundary; internal to adapter/backend replay. */ + providerItemBoundary?: true; + } | { kind: 'thinking-start'; reasoningPartId?: string; diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 62ab789737..e837a17f83 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -687,7 +687,7 @@ export function projectRuntimeEventUserMessage( }; } -function nonCanonicalContentOrder( +export function nonCanonicalContentOrder( order: readonly AssistantStepContentKind[] | undefined, ): AssistantStepContentKind[] | undefined { if (!order?.length) return undefined; diff --git a/packages/runtime/src/system-prompt/main-session-prompt.ts b/packages/runtime/src/system-prompt/main-session-prompt.ts index 5a46c19f5b..d7fc83a0d4 100644 --- a/packages/runtime/src/system-prompt/main-session-prompt.ts +++ b/packages/runtime/src/system-prompt/main-session-prompt.ts @@ -55,10 +55,30 @@ 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. +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 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. +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..dfc65b4761 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.deepEqual( + session.messages[2]?.type === 'assistant' ? session.messages[2].providerOptions : undefined, + { openai: { phase: 'commentary' } }, + ); assert.deepEqual(session.messages[3], { type: 'tool_call', id: 'call-wait-1', @@ -348,6 +352,11 @@ describe('CodexSessionAdapter', () => { turnId: 'codex-turn-item-completed', ts: Date.parse('2026-08-22T00:00:04.000Z'), text: 'Use canvas. Then process the pixels.', + providerOptions: { + openai: { + 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..e9aa2e982c 100644 --- a/packages/storage/src/codex-session-adapter.ts +++ b/packages/storage/src/codex-session-adapter.ts @@ -327,6 +327,7 @@ function convertCodexRollout( if (itemType === 'agentmessage') { const text = codexCompletedItemText(item); if (text.length === 0) continue; + const providerOptions = codexAssistantProviderOptions(item); messages.push({ type: 'assistant', id: @@ -335,6 +336,7 @@ function convertCodexRollout( turnId: ensureTurnId(record.line), ts: timestampFor(record), text, + ...(providerOptions !== undefined ? { providerOptions } : {}), modelId: activeModel, contentOrder: ['text'], }); @@ -383,12 +385,14 @@ function convertCodexRollout( if (eventType === 'agent_message') { const text = stringField(payload, 'message'); if (!text) continue; + const providerOptions = codexAssistantProviderOptions(payload); messages.push({ type: 'assistant', id: generatedCodexId(expectedSessionId, 'assistant', record.line), turnId: ensureTurnId(record.line), ts: timestampFor(record), text, + ...(providerOptions !== undefined ? { providerOptions } : {}), modelId: activeModel, contentOrder: ['text'], }); @@ -773,6 +777,18 @@ function stringField(record: JsonRecord | undefined, field: string): string | un return typeof value === 'string' && value.length > 0 ? value : undefined; } +function codexAssistantProviderOptions( + record: JsonRecord | undefined, +): Record | undefined { + const phase = record?.phase; + if (phase !== 'commentary' && phase !== 'final_answer') return undefined; + return { + openai: { + phase, + }, + }; +} + function isSafeCodexSessionId(value: unknown): value is string { return typeof value === 'string' && CODEX_SESSION_ID_PATTERN.test(value); } 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 1fedef9546..1ff7db9a17 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -999,22 +999,17 @@ const STATUS_FOOTER_ICON: Record = { info: