fix(desktop): require structure-bearing row for live-tail same-turn carry

Structure-only same-turn carry used (live(previous) || live(message)), so a
new live text-only assistant at a compression-rewritten ordinal could inherit
reasoning/tool parts from an unrelated historical structured row.

Require the structure-bearing cached row itself to be live-tail (pending /
assistant-stream-* / interim). Add regressions for non-extending live dump
carry and the compression graft rejection.

Addresses salvage path on #76744 / #76444.
This commit is contained in:
686f6c61
2026-08-03 14:36:33 +02:00
committed by Brooklyn Nicholson
parent 2ecc1db276
commit dbefd27ffb
2 changed files with 73 additions and 5 deletions
@@ -73,4 +73,70 @@ describe('reconcileResumeMessages — structural parts on a mid-turn switch', ()
expect(assistant.parts.filter(p => p.type === 'tool-call')).toHaveLength(1)
})
it('keeps live-tail structure when the flat dump is not a strict text extension', () => {
// Mid-turn sandwich path: cache holds reasoning/tools; resume returns a
// longer non-extending dump. Structure source must be live-tail.
const cached: ChatMessage[] = [
{
id: 'assistant-stream-1',
pending: true,
parts: [
{ type: 'reasoning', text: 'thinking about tools' },
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal', args: {} },
{ type: 'text', text: 'partial' }
],
role: 'assistant'
}
]
const authoritative: ChatMessage[] = [
{
id: 'assistant-stream-1',
pending: true,
parts: [{ type: 'text', text: 'thinking about tools\nRan terminal\npartial and more dump' }],
role: 'assistant'
}
]
const [assistant] = reconcileResumeMessages(authoritative, cached)
expect(assistant.parts.some(part => part.type === 'reasoning')).toBe(true)
expect(assistant.parts.some(part => part.type === 'tool-call')).toBe(true)
expect(assistant.parts.filter(part => part.type === 'text').map(part => ('text' in part ? part.text : ''))).toEqual([
'partial'
])
})
it('does not graft historical structure onto a live text-only row after compression rewrote ordinals', () => {
// Previous cache still has a completed structured assistant at ordinal 0.
// Resume after compression returns a new live text-only assistant at the
// same role ordinal for an unrelated turn — must not inherit foreign parts.
const cached: ChatMessage[] = [
{
id: 'old-assistant',
parts: [
{ type: 'reasoning', text: 'old thinking' },
{ type: 'tool-call', toolCallId: 'old-call', toolName: 'terminal', args: {} },
{ type: 'text', text: 'old answer' }
],
role: 'assistant'
}
]
const authoritative: ChatMessage[] = [
{
id: 'assistant-stream-runtime-1',
pending: true,
parts: [{ type: 'text', text: 'brand new partial' }],
role: 'assistant'
}
]
const [assistant] = reconcileResumeMessages(authoritative, cached)
expect(assistant.parts.some(part => part.type === 'reasoning')).toBe(false)
expect(assistant.parts.some(part => part.type === 'tool-call')).toBe(false)
expect(assistant.parts).toEqual([{ type: 'text', text: 'brand new partial' }])
})
})
@@ -295,10 +295,12 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
// the strict equality path — they reconcile a SETTLED row, and a growing
// row is by definition not settled.
//
// Live-tail identity: only treat structure as same-turn evidence when the
// cached row is still the in-flight stream (pending / stream id), not a
// historical completed assistant that merely shares a role ordinal after
// compression rewrote history (#76444 review).
// Live-tail identity: structure-only same-turn carry is allowed only when
// the *structure-bearing cached row* is still the in-flight stream
// (pending / stream id / interim). Marking only the text-only next row
// live is not enough — after compression a new live assistant can share a
// role ordinal with an unrelated historical structured row and must not
// inherit its reasoning/tool parts (#76444 review / salvage).
const isLiveTailRow = (row: ChatMessage): boolean =>
Boolean(row.pending) ||
row.id.startsWith('assistant-stream-') ||
@@ -311,7 +313,7 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
previous.role === 'assistant' &&
hasStructuralParts(previous) &&
!hasStructuralParts(message) &&
(isLiveTailRow(previous) || isLiveTailRow(message)))
isLiveTailRow(previous))
if (sameTurn) {
preserved = preserveStructuralParts(preserved, previous)