Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions apps/desktop/src/app/session/hooks/use-message-stream/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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()
Expand Down
Loading