From 34d49b0b3b6a2ee71613ba797dc9d44f91f14726 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Tue, 25 Aug 2026 20:02:10 +0800 Subject: [PATCH] fix(ui): settle live turn from persisted terminal state Generated-by: OpenAI Codex --- .../__tests__/live-turn-projection.test.ts | 53 +++++++++++++++++++ packages/ui/src/live-turn-projection.ts | 29 ++++++---- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 5b6656f08b..66fd53ca77 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -839,6 +839,59 @@ describe('reconcileTerminalLiveTurn', () => { ]), textTurn); }); + it('terminalizes live text when persisted history proves a missed terminal event', () => { + const live: LiveTurnProjection = { + turnId: 'turn-1', + phase: 'streamed', + providerRetry: { + type: 'provider_retry', + id: 'retry-1', + turnId: 'turn-1', + ts: 2, + phase: 'started', + attempt: 2, + maxAttempts: 3, + reason: 'network', + }, + steps: [{ + stepId: 'assistant-1', + thinking: { text: 'reasoning', truncated: false, complete: false }, + text: { text: 'answer', truncated: false, complete: false }, + tools: [], + }], + }; + + assert.deepEqual(reconcileTerminalLiveTurn(live, [ + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 3, + text: 'answer', + thinking: { text: 'reasoning' }, + modelId: 'm', + }, + { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 4, + status: 'completed', + partialOutputRetained: false, + }, + ]), { + turnId: 'turn-1', + phase: 'streamed', + terminal: true, + steps: [{ + stepId: 'assistant-1', + thinking: { text: 'reasoning', truncated: false, complete: true }, + text: { text: 'answer', truncated: false, complete: true }, + tools: [], + }], + }); + }); + it('settles a persisted thinking-only step whose text slot is empty', () => { const thinkingOnly: LiveTurnProjection = { turnId: 'turn-1', diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 5de4682f30..7d43cd7f34 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -607,15 +607,24 @@ export function reconcileTerminalLiveTurn( const transcriptReachedTerminal = turnMessages.some( (message) => message.type === 'turn_state' && message.status !== 'running', ); + let projection = current; + if (transcriptReachedTerminal && current.terminal !== true) { + const { providerRetry: _providerRetry, ...withoutRetry } = confirmed(current); + projection = { + ...withoutRetry, + terminal: true, + steps: terminalizeLiveSteps(current.steps), + }; + } if ( - current.terminal === true - && liveSteeringMessages(current).length > 0 + projection.terminal === true + && liveSteeringMessages(projection).length > 0 && !transcriptReachedTerminal - ) return current; + ) return projection; const assistantIds = new Set(turnMessages.flatMap((message) => message.type === 'assistant' ? [message.id] : [])); const toolCallIds = new Set(turnMessages.flatMap((message) => message.type === 'tool_call' ? [message.id] : [])); const toolResultIds = new Set(turnMessages.flatMap((message) => message.type === 'tool_result' ? [message.toolUseId] : [])); - let steps = current.steps.filter((step) => { + let steps = projection.steps.filter((step) => { if (step.text?.text.length) return true; if (step.thinking && !assistantIds.has(step.stepId)) return true; const toolsCovered = step.tools.every((tool) => { @@ -633,9 +642,9 @@ export function reconcileTerminalLiveTurn( // Once persisted turn_state records the terminal handoff, the transcript is // authoritative for accepted steering; retaining the live copy would leave // a duplicate or a nacked ghost instruction on screen. - const steeringSettled = current.terminal === true + const steeringSettled = projection.terminal === true && transcriptReachedTerminal - && liveSteeringMessages(current).length > 0; + && liveSteeringMessages(projection).length > 0; if (steeringSettled) { steps = steps.map((step) => { if (!step.leadingSteering) return step; @@ -643,9 +652,9 @@ export function reconcileTerminalLiveTurn( return withoutSteering; }); } - if (steps.length === current.steps.length && !steeringSettled) return current; - if (steps.length === 0 && current.terminal) return undefined; - if (!steeringSettled) return { ...current, steps }; - const { pendingSteering: _pendingSteering, ...withoutSteering } = current; + if (steps.length === projection.steps.length && !steeringSettled) return projection; + if (steps.length === 0 && projection.terminal) return undefined; + if (!steeringSettled) return { ...projection, steps }; + const { pendingSteering: _pendingSteering, ...withoutSteering } = projection; return { ...withoutSteering, steps }; }