fix(desktop): settle final reply onto interim even after message.start reset the boundary flag

completeAssistantMessage merged a turn's final text onto its sealed interim
bubble only when the session's volatile interimBoundaryPending flag was still
true. A subsequent message.start (chained turn, follow-up, or mid-turn
compaction) resets that flag to false; when it landed between the same turn's
message.interim and message.complete, the flag-based gate fell through and the
UI appended a duplicate bubble.

Key the merge on the message's OWN durable interim state instead of the
session flag. finalContinuesInterim already requires existing.interim plus
prefix continuity, so distinct replies (which don't continue the interim) are
still appended as their own bubble.

Adds a regression test: interim + message.start + completing final that
continues the interim must yield one bubble, not two.
This commit is contained in:
spfcraze
2026-08-02 00:29:49 -04:00
committed by Teknium
parent cf63b79467
commit 71a98f69ee
2 changed files with 27 additions and 1 deletions
@@ -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
)
@@ -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()