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()