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 3fe175b0fda..c921a13f0a1 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 @@ -501,27 +501,38 @@ export function useMessageStream({ const existing = prev[index] const existingText = chatMessageText(existing).trim() + // The last assistant row is a sealed interim (a tool-call turn or a + // verify-on-stop candidate — `message.interim` fires for BOTH, see + // tui_gateway `_load_interim_assistant_messages`). When the final + // completion is the SAME turn's reply, settle it onto that interim + // instead of appending a second bubble. Continuity, not exact + // equality: streaming can drop characters and the final may add a + // trailing delta, so treat prefix-either-way as the same message. + // (mergeFinalAssistantText, via completeMessage, does the real + // text merge — replaces the interim's text with the full final.) + const finalContinuesInterim = Boolean( + existing.interim && + finalText && + existingText && + (finalText === existingText || + finalText.startsWith(existingText) || + existingText.startsWith(finalText)) + ) + if (existing.pending || (!interimBoundaryPending && finalText && existingText === finalText)) { nextMessages = prev.map((message, messageIndex) => messageIndex === index ? completeMessage(message) : message ) - } 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) - // - // Prefix match (not exact equality): the final response may be - // the streamed text plus a trailing delta. mergeFinalAssistantText - // (called via completeMessage) handles the actual merge — it - // strips the old text parts and appends the full final text. + } else if (interimBoundaryPending && (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 + // tool-call turn whose final matched its sealed interim appended a + // second bubble (the "renders twice: partial first copy + clean + // final" bug, #63679). `finalContinuesInterim` closes that gap + // 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. 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 9a10e6c9b18..7825904d3dc 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,51 @@ 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 onto a non-previewed interim (tool-call turn) instead of duplicating (#63679)', async () => { await mountStream() await start() + // A plain tool-call turn: the streamed text is sealed as an interim at the + // tool boundary (no response_previewed — that flag is only for verify-on- + // stop). The final completion is the SAME turn's reply. It must settle onto + // the interim, not append a second bubble — the DB has one row. This is the + // "renders twice" bug: partial streamed copy + clean final copy side by side. 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. const texts = assistantMessages() - expect(texts.filter(t => t === 'same reply')).toHaveLength(2) + expect(texts.filter(t => t === 'same reply')).toHaveLength(1) + }) + + it('settles a prefix-extended final onto a non-previewed interim (streamed + trailing delta)', async () => { + await mountStream() + await start() + + // The stream dropped/settled early at the tool boundary; the final adds a + // trailing delta. Same turn — one bubble with the full final text. + await delta('partial') + await interim('partial') + 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() + + // The interim is one segment (pre-tool commentary); the final is different + // content, not a continuation of it. These are two real messages and must + // both render — the fix must not over-collapse distinct replies. + await interim('let me check the files') + await complete('the answer is 42') + + const texts = assistantMessages() + expect(texts).toContain('let me check the files') + expect(texts).toContain('the answer is 42') + expect(texts).toHaveLength(2) }) it('settles an identical final completion onto the interim when response_previewed', async () => {