From dbefd27ffbc68a5052ff52ad7dadc09432c924b2 Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Mon, 3 Aug 2026 14:36:33 +0200 Subject: [PATCH] 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. --- .../resume-structural-parts.test.ts | 66 +++++++++++++++++++ .../hooks/use-session-actions/utils.ts | 12 ++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/resume-structural-parts.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/resume-structural-parts.test.ts index 1ef5376d8d..b2c8a8e797 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/resume-structural-parts.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/resume-structural-parts.test.ts @@ -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' }]) + }) }) 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 404b26ab2a..96506e9a0e 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 @@ -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)