From 71a98f69eefffff43c201a2a631cba1e53eee398 Mon Sep 17 00:00:00 2001 From: spfcraze Date: Sun, 2 Aug 2026 00:29:49 -0400 Subject: [PATCH] fix(desktop): settle final reply onto interim even after message.start reset the boundary flag completeAssistantMessage merged a turn's final text onto its sealed interim bubble only when the session's volatile interimBoundaryPending flag was still true. A subsequent message.start (chained turn, follow-up, or mid-turn compaction) resets that flag to false; when it landed between the same turn's message.interim and message.complete, the flag-based gate fell through and the UI appended a duplicate bubble. Key the merge on the message's OWN durable interim state instead of the session flag. finalContinuesInterim already requires existing.interim plus prefix continuity, so distinct replies (which don't continue the interim) are still appended as their own bubble. Adds a regression test: interim + message.start + completing final that continues the interim must yield one bubble, not two. --- .../session/hooks/use-message-stream/index.ts | 11 ++++++++++- .../use-message-stream/interim-sealing.test.tsx | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 979bbc1e2f..90a6fcad9a 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -632,7 +632,7 @@ export function useMessageStream({ nextMessages = prev.map((message, messageIndex) => messageIndex === index ? completeMessage(message) : message ) - } else if (interimBoundaryPending && (responsePreviewed || finalContinuesInterim)) { + } else if (existing.interim && (responsePreviewed || finalContinuesInterim)) { // Settle the interim in place instead of creating a duplicate — // the DB has one row, so the live UI must agree. Previously this // was gated on `responsePreviewed` alone, so a NON-previewed @@ -642,6 +642,15 @@ export function useMessageStream({ // for ordinary tool-call turns while `responsePreviewed` still // covers the verify-on-stop continuation-budget case even when the // final text was rewritten and no longer shares a prefix. + // + // We key on the message's OWN durable `interim` state, not the + // session's volatile `interimBoundaryPending` flag: the flag is + // reset to false by a subsequent `message.start` (a chained turn, + // follow-up, or mid-turn compaction). When that reset lands + // between this turn's `message.interim` and `message.complete`, the + // flag-based gate wrongly fell through to appending a duplicate + // bubble (#74560). The message row still says `interim`, so the + // continuation merges correctly regardless of the flag. nextMessages = prev.map((message, messageIndex) => messageIndex === index ? completeMessage(message) : message ) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx index 7825904d3d..90ac198a98 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx @@ -217,6 +217,23 @@ describe('useMessageStream interim text sealing', () => { expect(texts[0]).toBe('partial answer continued') }) + it('settles final onto interim even after message.start reset the boundary flag (#74560)', async () => { + await mountStream() + await start() + await delta('partial') + await interim('partial') + // A chained turn / follow-up re-emits message.start, which resets + // interimBoundaryPending to false BEFORE the same turn's message.complete. + // The continuation must still settle onto the interim — not append a + // duplicate bubble. Regression for #74560. + await start() + await complete('partial answer continued') + + const texts = assistantMessages() + expect(texts.filter(t => t.includes('partial'))).toHaveLength(1) + expect(texts[0]).toBe('partial answer continued') + }) + it('appends a genuinely different final as its own bubble (two real assistant segments)', async () => { await mountStream() await start()