diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 849aeffa72..de03be31bd 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -1120,4 +1120,41 @@ describe('appendLiveSessionProjection', () => { expect(appendLiveSessionProjection(stored, { session_id: 'runtime-1' })).toBe(stored) }) + + it('does not sandwich a structured mid-turn row with the inflight flat dump (#76444)', () => { + const stored: ChatMessage[] = [ + msg('stored-user', 'user', 'do the work'), + { + id: 'live-assistant', + role: 'assistant', + pending: true, + parts: [ + { type: 'reasoning', text: 'thinking about tools' }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'terminal', args: {} }, + { type: 'text', text: 'partial' } + ] + } + ] + + const restored = appendLiveSessionProjection(stored, { + session_id: 'runtime-1', + inflight: { + user: 'do the work', + // Flat dump includes thinking chatter + tool narration — longer than + // the answer text alone, which is how the sandwich used to grow. + assistant: 'thinking about tools\nRan terminal\npartial and more dump', + streaming: true + } + }) + + const assistants = restored.filter(message => message.role === 'assistant') + expect(assistants).toHaveLength(1) + expect(assistants[0].id).toBe('live-assistant') + expect(assistants[0].parts.some(part => part.type === 'reasoning')).toBe(true) + expect(assistants[0].parts.some(part => part.type === 'tool-call')).toBe(true) + // Answer text stays the structured row's text, not the dump. + expect(assistants[0].parts.filter(part => part.type === 'text').map(part => ('text' in part ? part.text : ''))).toEqual([ + 'partial' + ]) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 3b73ae2a7d..6d88c83ed2 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -47,6 +47,27 @@ function withAppendedText(message: ChatMessage, suffix: string): ChatMessage { return appended ? { ...message, parts } : message } +/** Reasoning / tool-call parts that the gateway inflight dump cannot express. */ +function hasStructuralParts(message: ChatMessage): boolean { + return message.parts.some(part => part.type === 'reasoning' || part.type === 'tool-call') +} + +/** + * True when `next` is a pure forward extension of the previous *answer* text. + * Empty previous answer never accepts a dump as an extension — that is how the + * mid-turn inflight flat dump used to sandwich structured rows (#76444). + */ +export function isStrictAnswerTextExtension(next: string, previous: string): boolean { + const n = next.trim() + const p = previous.trim() + + if (!p || !n) { + return false + } + + return n.startsWith(p) +} + /** * Carry structural parts an authoritative row cannot express. * @@ -273,11 +294,33 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes // for structural carry-over. Attachment refs and image re-appending stay on // the strict equality path — they reconcile a SETTLED row, and a growing // row is by definition not settled. + // + // Structured local assistant + text-only live projection (gateway inflight + // dump) at the same ordinal is still the same turn even when answer text is + // still empty — without this the dump would drop structure and reappear as + // a plain-text sandwich (#76444). const sameTurn = - sameText || (nextText.length > 0 && previousTrimmed.length > 0 && nextText.startsWith(previousTrimmed)) + sameText || + (nextText.length > 0 && previousTrimmed.length > 0 && isStrictAnswerTextExtension(nextText, previousTrimmed)) || + (message.role === 'assistant' && + previous.role === 'assistant' && + hasStructuralParts(previous) && + !hasStructuralParts(message)) if (sameTurn) { preserved = preserveStructuralParts(preserved, previous) + + // Never replace structured answer text with a non-extending flat dump. + if ( + message.role === 'assistant' && + hasStructuralParts(previous) && + !hasStructuralParts(message) && + !isStrictAnswerTextExtension(nextText, previousVisibleText) + ) { + const nonText = preserved.parts.filter(part => part.type !== 'text') + const priorAnswer = previous.parts.filter(part => part.type === 'text') + preserved = { ...preserved, parts: [...nonText, ...priorAnswer] } + } } if ( @@ -646,14 +689,27 @@ export function appendLiveSessionProjection( // Keep a pending assistant boundary even before the first delta when a // queued user turn follows it. This preserves the two distinct turns. + // + // When the transcript already holds a structured mid-turn assistant row + // (reasoning / tool-call parts from the live stream or journal), do NOT + // append a pure-text projection of `inflight.assistant` — that flat dump + // re-renders thinking as answer text and sandwiches the structured parts + // (#76444). Errors still force a projection so the failure is visible. + const lastAssistant = [...messages].reverse().find(message => message.role === 'assistant') + const turnAlreadyStructured = Boolean(lastAssistant && hasStructuralParts(lastAssistant)) + if (inflightAssistant || inflightStreaming || inflightError || (inflightUser && queuedUser)) { - projected.push({ - id: `assistant-stream-${sessionId}`, - role: 'assistant', - parts: inflightAssistant ? [assistantTextPart(inflightAssistant)] : [], - pending: inflightStreaming, - ...(inflightError ? { error: inflightError } : {}) - }) + if (turnAlreadyStructured && !inflightError) { + // Structure is authoritative; skip the text-only dump row. + } else { + projected.push({ + id: `assistant-stream-${sessionId}`, + role: 'assistant', + parts: inflightAssistant ? [assistantTextPart(inflightAssistant)] : [], + pending: inflightStreaming, + ...(inflightError ? { error: inflightError } : {}) + }) + } } if (queuedUser) { diff --git a/apps/desktop/src/lib/inflight-turn-journal.ts b/apps/desktop/src/lib/inflight-turn-journal.ts index 2e4d0cb42f..050c4ff0c9 100644 --- a/apps/desktop/src/lib/inflight-turn-journal.ts +++ b/apps/desktop/src/lib/inflight-turn-journal.ts @@ -254,6 +254,10 @@ function assistantTextLength(message: ChatMessage): number { * BASE row's id so live deltas keep appending to the row the stream handler * already targets. */ +function hasStructuralParts(message: ChatMessage): boolean { + return message.parts.some(part => part.type === 'reasoning' || part.type === 'tool-call') +} + function overlayProjectionRow(projection: ChatMessage, journalRow: ChatMessage): ChatMessage { // A projected error (retained failed turn) must survive the overlay. const error = journalRow.error ?? projection.error @@ -271,7 +275,20 @@ function overlayProjectionRow(projection: ChatMessage, journalRow: ChatMessage): // Backend text is newer than the journal's last throttled write — swap it // into the journal's first text part, keeping tool calls and reasoning. + // When the journal already carries structure, only accept a *strict* + // extension of the answer text. A longer flat dump that starts with + // thinking chatter must not overwrite / insert as answer text (#76444). const projectionText = chatMessageText(projection) + const journalText = chatMessageText(journalRow).trim() + + if (hasStructuralParts(journalRow)) { + const next = projectionText.trim() + + if (!journalText || !next.startsWith(journalText)) { + return merged + } + } + const parts: ChatMessagePart[] = [] let textReplaced = false