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:
committed by
Brooklyn Nicholson
parent
2ecc1db276
commit
dbefd27ffb
+66
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user