fix(desktop): dedupe acknowledged prompt against all newly committed user rows (#121088)
preserveLocalPendingTurnMessages compared the optimistic prompt only against the single newest authoritative user row. After an in-place compaction handoff or preserved-task notice the newest user-role row is the synthetic one, ordinal pairing shifts, and the committed copy of the prompt is missed — so the optimistic user- row is re-appended below the whole refreshed turn. Compare against every newly committed durable user row (rowId-bearing, non-[System: marker), identity-gated via conflictingTranscriptIdentity, keeping the newest-row candidate as before for rowId-less positional hydration windows. A genuinely unacknowledged repeat of an older question survives: its committed twin predates the acknowledged boundary and never enters the window. Regression covers the handoff-row-before-copy trigger plus the repeat-must-survive invariant.
This commit is contained in:
@@ -711,6 +711,61 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
expect(preserveLocalPendingTurnMessages([answer], [...previous, unacknowledged])).toEqual([answer, unacknowledged])
|
||||
})
|
||||
|
||||
it('drops the acknowledged prompt when a compaction handoff precedes its committed copy', () => {
|
||||
// #121088: an in-place compaction handoff (or preserved-task notice) is a
|
||||
// synthetic USER-role row, so the committed copy of the prompt is no
|
||||
// longer the newest user row — and one lands after the reply too. A
|
||||
// newest-only compare misses the committed copy and re-appends the
|
||||
// optimistic row below the whole refreshed turn.
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'first', { rowId: 100 }),
|
||||
msg('2-assistant', 'assistant', 'first answer', { rowId: 101 }),
|
||||
msg('user-optimistic', 'user', 'unable to publish')
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user-stored', 'user', 'first', { rowId: 100 }),
|
||||
msg('2-assistant-stored', 'assistant', 'first answer', { rowId: 101 }),
|
||||
msg('3-handoff', 'user', 'Context was compacted; continuing.'),
|
||||
msg('4-user-stored', 'user', 'unable to publish', { rowId: 200 }),
|
||||
msg('5-assistant-stored', 'assistant', 'stored answer', { rowId: 201 }),
|
||||
msg('6-notice', 'user', 'Preserved task notice')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
|
||||
'1-user-stored',
|
||||
'2-assistant-stored',
|
||||
'3-handoff',
|
||||
'4-user-stored',
|
||||
'5-assistant-stored',
|
||||
'6-notice'
|
||||
])
|
||||
})
|
||||
|
||||
it('still keeps a genuinely unacknowledged repetition of an older question', () => {
|
||||
// The committed twin of a genuine repeat predates the acknowledged
|
||||
// boundary and never enters the newly committed window, so widening
|
||||
// the acknowledged-prompt compare must not swallow it.
|
||||
const previous = [
|
||||
msg('1-user', 'user', 'what time is it?', { rowId: 100 }),
|
||||
msg('2-assistant', 'assistant', 'noon', { rowId: 101 }),
|
||||
msg('user-optimistic', 'user', 'what time is it?')
|
||||
]
|
||||
|
||||
const next = [
|
||||
msg('1-user-stored', 'user', 'what time is it?', { rowId: 100 }),
|
||||
msg('2-assistant-stored', 'assistant', 'noon', { rowId: 101 }),
|
||||
msg('3-system-user', 'user', 'Preserved task notice')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
|
||||
'1-user-stored',
|
||||
'2-assistant-stored',
|
||||
'3-system-user',
|
||||
'user-optimistic'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a newer equal reply and its prompt until that occurrence is persisted', () => {
|
||||
const previousAnswer = msg('stored-answer', 'assistant', 'Completed.', { rowId: 10 })
|
||||
const prompt = msg('user-new', 'user', 'Repeat the check', { rowId: 11 })
|
||||
|
||||
@@ -782,7 +782,24 @@ export function preserveLocalPendingTurnMessages(
|
||||
}
|
||||
}
|
||||
|
||||
const latestAuthoritativeUser = [...remainingNext].reverse().find(message => message.role === 'user')
|
||||
// #121088: the acknowledged prompt's committed twin can sit anywhere in the
|
||||
// newly committed window — a compaction handoff or preserved-task notice
|
||||
// (synthetic user rows) may be NEWER than it, so the newest-only compare
|
||||
// misses the committed copy and the optimistic row is re-appended below the
|
||||
// whole refreshed turn. Dedupe against EVERY newly committed durable user
|
||||
// row, plus the newest user row as it was before (a rowId-less positional
|
||||
// hydration window keeps parity with the legacy compare). Every candidate
|
||||
// is identity-gated: a rowId-bearing optimistic row is never matched
|
||||
// against a committed row it provably is not, so a genuinely
|
||||
// unacknowledged repeat whose committed twin predates the acknowledged
|
||||
// boundary (and never enters this window) still survives.
|
||||
const newestAuthoritativeUser = [...remainingNext].reverse().find(message => message.role === 'user')
|
||||
const acknowledgedUserCandidates = remainingNext.filter(
|
||||
message =>
|
||||
message.role === 'user' &&
|
||||
!isGatewaySystemMarker(message) &&
|
||||
(message.rowId !== undefined || message === newestAuthoritativeUser)
|
||||
)
|
||||
const preserved: ChatMessage[] = []
|
||||
// Authoritative id → richer local pending row. Replacing (not appending)
|
||||
// avoids painting both the empty inflight shell and the full stream bubble.
|
||||
@@ -866,10 +883,11 @@ export function preserveLocalPendingTurnMessages(
|
||||
|
||||
if (
|
||||
isOptimisticUser &&
|
||||
latestAuthoritativeUser &&
|
||||
!conflictingTranscriptIdentity(message, latestAuthoritativeUser) &&
|
||||
textWithoutReferenceLines(chatMessageText(latestAuthoritativeUser)) ===
|
||||
textWithoutReferenceLines(chatMessageText(message))
|
||||
acknowledgedUserCandidates.some(
|
||||
candidate =>
|
||||
!conflictingTranscriptIdentity(message, candidate) &&
|
||||
textWithoutReferenceLines(chatMessageText(candidate)) === textWithoutReferenceLines(chatMessageText(message))
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user