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:
committed by
Brooklyn Nicholson
parent
34f0427f7f
commit
53398ff485
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user