From b843659b69010f8b1d985afdd14bb85384dd275d Mon Sep 17 00:00:00 2001 From: x7peeps Date: Thu, 23 Jul 2026 23:52:25 +0800 Subject: [PATCH] fix(desktop): settle interim assistant onto final reply to prevent duplicate bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When message.interim seals an assistant bubble and message.complete arrives with the same (or a superset of) text, the live UI must collapse them into one bubble — the DB persists only one row, so rendering two is a duplicate. Previously, the dedup path required response_previewed=true to settle onto the interim. Without that flag, the fallback created a second assistant message even when the texts matched, causing intermittent duplicate rendering. Fix: broaden the interimBoundaryPending guard to cover the common case where the final text matches or extends the interim text, regardless of the response_previewed flag. Fix #70108 --- .../session/hooks/use-message-stream/index.ts | 17 +++++++----- .../interim-sealing.test.tsx | 27 +++++++++++++++---- 2 files changed, 33 insertions(+), 11 deletions(-) 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 3fe175b0fdaf4..46c01fab09c90 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 @@ -507,16 +507,21 @@ export function useMessageStream({ ) } else if ( interimBoundaryPending && - responsePreviewed && finalText && existingText && finalText.startsWith(existingText) ) { - // The verification candidate was published provisionally as an - // interim message and then reused as the terminal response - // (continuation-budget fallback). Settle the interim in place - // instead of creating a duplicate — the DB has one row, so the - // live UI must agree. (#65919 review: duplicate-message blocker) + // An interim message was just sealed (message.interim) and the + // terminal response (message.complete) either reuses it exactly + // or extends it. The DB will persist one row, so the live UI + // must settle onto the existing interim instead of appending a + // duplicate bubble. (#70108: Desktop intermittently renders + // duplicate assistant replies) + // + // This covers both the response_previewed path (continuation- + // budget fallback, #65919) and the common case where the + // gateway doesn't set response_previewed but the final text + // is the same as (or a superset of) the interim text. // // Prefix match (not exact equality): the final response may be // the streamed text plus a trailing delta. mergeFinalAssistantText 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 9a10e6c9b1836..66431381bb714 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 @@ -186,18 +186,19 @@ describe('useMessageStream interim text sealing', () => { expect(getState().interimBoundaryPending).toBe(true) }) - it('keeps an identical final completion distinct from an interim reply without response_previewed', async () => { + it('settles an identical final completion onto the interim even without response_previewed', async () => { await mountStream() await start() await interim('same reply') await complete('same reply') - // Without response_previewed, the interim and terminal replies are - // distinct messages — the gateway didn't signal that the final reuses - // the provisional candidate. + // The interim and final are byte-identical — the DB will have one row, + // so the live UI must collapse them into one bubble instead of + // rendering a duplicate. (#70108: Desktop intermittently renders + // duplicate assistant replies) const texts = assistantMessages() - expect(texts.filter(t => t === 'same reply')).toHaveLength(2) + expect(texts.filter(t => t === 'same reply')).toHaveLength(1) }) it('settles an identical final completion onto the interim when response_previewed', async () => { @@ -231,6 +232,22 @@ describe('useMessageStream interim text sealing', () => { expect(texts[0]).toBe('partial answer with more detail') }) + it('settles a prefix-matched final onto the interim even without response_previewed', async () => { + await mountStream() + await start() + + // The interim seals a partial answer, then the final response + // extends it — even without response_previewed, the DB will have + // one row, so the live UI must settle onto the interim. + await interim('partial answer') + await complete('partial answer with more detail') + + // One bubble, containing the full final text — not two. (#70108) + const texts = assistantMessages() + expect(texts.filter(t => t.includes('partial answer'))).toHaveLength(1) + expect(texts[0]).toBe('partial answer with more detail') + }) + it('dedupes partial-stream-then-nudge: streamed prefix + interim + previewed final settles to one bubble', async () => { await mountStream() await start()