fix(desktop): do not sandwich structured mid-turn rows with inflight dump

Skip pure-text inflight.assistant projections when the transcript already
has reasoning/tool-call structure, and only overlay journal answer text
on strict extension.

Fixes #76444
This commit is contained in:
686f6c61
2026-08-02 13:22:23 +02:00
committed by Brooklyn Nicholson
parent 34f0427f7f
commit 53398ff485
3 changed files with 118 additions and 8 deletions
@@ -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'
])
})
})
@@ -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) {
@@ -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