From 34f0427f7fdf7f6019b4b39835c37a3c104b8b4e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 3 Aug 2026 07:23:07 -0500 Subject: [PATCH] fix(desktop): stop a finished reply rendering twice after history catches up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a turn's reply commits under its own id, the settled local `assistant-stream-*` row shifts one assistant ordinal earlier, so ordinal pairing finds nothing at its slot and re-appends it — the same answer twice. Drop a settled stream row only when the authoritative transcript already carries that exact text. Keying `isPendingAssistant` on the explicit pending flag alone would also have fixed this, but it discards the sibling case in the same report: a reply that finished locally before the gateway committed it, where the local row is the only copy that exists. Co-authored-by: Dolverin <59100064+Dolverin@users.noreply.github.com> --- .../hooks/use-session-actions/utils.test.ts | 26 +++++++++++++++++++ .../hooks/use-session-actions/utils.ts | 21 ++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 81a00da36f8f4..849aeffa7202f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -945,6 +945,32 @@ describe('preserveLocalPendingTurnMessages', () => { expect(chatMessageText(preserved[1])).toBe('streamed body') }) + // #70209: history committed the reply under its own id, so the settled local + // stream row sits at a later ordinal, pairs with nothing, and gets appended — + // the same answer twice. + it('does not re-append a settled stream row the authoritative history already carries', () => { + const next = [msg('1-user-stored', 'user', 'question'), msg('2-assistant-stored', 'assistant', 'answer')] + const settledLocalStream = msg('assistant-stream-runtime-1', 'assistant', 'answer', { pending: false }) + + expect(preserveLocalPendingTurnMessages(next, [...next, settledLocalStream])).toBe(next) + }) + + // The reply finished locally but the gateway had not committed it when the + // session was reopened — the local row is the only copy and must survive. + it('keeps a settled stream row the authoritative history has not committed', () => { + const previous = [ + msg('1-user', 'user', 'question'), + msg('assistant-stream-sess', 'assistant', 'the finished reply', { pending: false }) + ] + + const next = [msg('1-user', 'user', 'question')] + + expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([ + '1-user', + 'assistant-stream-sess' + ]) + }) + // The whole point of replacing rather than appending: one reply on screen, // and the committed history around the live turn untouched. it('does not duplicate or rewrite committed history around the live turn', () => { 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 51b0323f22fac..3b73ae2a7d8c5 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 @@ -507,6 +507,24 @@ export function preserveLocalPendingTurnMessages( const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`) + // A settled stream row (`pending: false` after message.complete) whose reply + // the authoritative transcript already carries under its committed id is + // stale: ordinal pairing can't see it, because the commit shifted the row + // one ordinal earlier, and re-appending it renders the same answer twice + // (#70209). Only text-identical rows are dropped — a settled row the backend + // has NOT committed yet is the only copy of that reply and must survive. + if ( + isPendingAssistant && + message.pending !== true && + nextMessages.some( + candidate => + candidate.role === 'assistant' && + textWithoutReferenceLines(chatMessageText(candidate)) === textWithoutReferenceLines(chatMessageText(message)) + ) + ) { + continue + } + if (authoritative) { if (isPendingAssistant) { // Keep the local pending row when it is the same reply further along @@ -522,7 +540,8 @@ export function preserveLocalPendingTurnMessages( } if ( - textWithoutReferenceLines(chatMessageText(authoritative)) === textWithoutReferenceLines(chatMessageText(message)) + textWithoutReferenceLines(chatMessageText(authoritative)) === + textWithoutReferenceLines(chatMessageText(message)) ) { continue }