fix(desktop): stop a finished reply rendering twice after history catches up

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>
This commit is contained in:
Brooklyn Nicholson 2026-08-03 07:23:07 -05:00
parent b6f15f546d
commit 34f0427f7f
2 changed files with 46 additions and 1 deletions

View File

@ -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', () => {

View File

@ -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
}