fix(desktop): settle high-overlap sealed replies
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user