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 b892c960b7..6fe5cfec3a 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 @@ -75,6 +75,61 @@ let streamMessageSeq = 0 const nextStreamMessageId = (prefix: string) => `${prefix}-${Date.now()}-${++streamMessageSeq}` +/** + * A sealed stream can lose a few characters while the authoritative final + * remains the same reply. Limit the tolerated edit distance so a separate + * assistant segment cannot replace a merely similar interim. + */ +function hasHighTextOverlap(left: string, right: string): boolean { + const maxLength = Math.max(left.length, right.length) + + if (maxLength < 160) { + return false + } + + const maxEdits = Math.max(1, Math.min(32, Math.floor(maxLength * 0.02))) + + if (Math.abs(left.length - right.length) > maxEdits) { + return false + } + + const [shorter, longer] = left.length < right.length ? [left, right] : [right, left] + + let previous = Array.from({ length: shorter.length + 1 }, (_, index) => + index <= maxEdits ? index : Number.POSITIVE_INFINITY + ) + + let current = new Array(shorter.length + 1).fill(Number.POSITIVE_INFINITY) + + for (let longerIndex = 1; longerIndex <= longer.length; longerIndex += 1) { + const start = Math.max(1, longerIndex - maxEdits) + const end = Math.min(shorter.length, longerIndex + maxEdits) + current.fill(Number.POSITIVE_INFINITY, start, end + 1) + current[start - 1] = start === 1 ? longerIndex : Number.POSITIVE_INFINITY + + let rowMinimum = Number.POSITIVE_INFINITY + + for (let shorterIndex = start; shorterIndex <= end; shorterIndex += 1) { + current[shorterIndex] = Math.min( + previous[shorterIndex] + 1, + current[shorterIndex - 1] + 1, + previous[shorterIndex - 1] + Number(longer[longerIndex - 1] !== shorter[shorterIndex - 1]) + ) + rowMinimum = Math.min(rowMinimum, current[shorterIndex]) + } + + if (rowMinimum > maxEdits) { + return false + } + + const nextPrevious = current + current = previous + previous = nextPrevious + } + + return previous[shorter.length] <= maxEdits +} + export function useMessageStream({ activeGatewayProfile = 'default', activeSessionIdRef, @@ -796,15 +851,18 @@ export function useMessageStream({ // 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. + // equality: streaming can drop a small number of characters and + // the final may add a trailing delta, so accept high overlap. // (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)) + (finalText === existingText || + finalText.startsWith(existingText) || + existingText.startsWith(finalText) || + hasHighTextOverlap(finalText, existingText)) ) // A bare `error` event (e.g. the agent build failing) already @@ -865,8 +923,8 @@ export function useMessageStream({ // final after streaming, e.g. pseudonym restore) shares the // same no-continuity shape, so it takes the same boundary gate. // - // • finalContinuesInterim (prefix-either-way continuity, same - // text or one a prefix of the other) is safe to settle + // • finalContinuesInterim (prefix-either-way or high-overlap + // continuity) is safe to settle // flag-free within this user occurrence: a `message.start` // reset between this turn's interim and completion must not // force an append of a duplicate bubble (#74560). This also 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 822e90a8b2..1128028a6d 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 @@ -238,6 +238,29 @@ describe('useMessageStream interim text sealing', () => { expect(texts[0]).toBe('partial answer continued') }) + it('settles a long final with a few dropped streamed characters onto its interim', async () => { + mountStream() + await start() + + const final = 'This long response contains enough context to identify the same streamed reply. '.repeat(12).trimEnd() + const streamed = final.replace('streamed', 'stremed').replace('response', 'respose') + + await interim(streamed) + await complete(final) + + expect(assistantMessages()).toEqual([final]) + }) + + it('keeps short near-matching assistant segments distinct', async () => { + mountStream() + await start() + + await interim('Status: B') + await complete('Status: A') + + expect(assistantMessages()).toEqual(['Status: B', 'Status: A']) + }) + it('settles final onto interim even after message.start reset the boundary flag (#74560)', async () => { mountStream() await start()