fix(desktop): settle high-overlap sealed replies

This commit is contained in:
Abarajithan
2026-09-22 17:47:01 +05:30
committed by brooklyn!
parent c6f964f6d4
commit 675f29da03
2 changed files with 86 additions and 5 deletions

View File

@@ -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<number>(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

View File

@@ -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()