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)