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:
Hermes Agent
2026-09-24 19:03:12 -05:00
committed by brooklyn!
parent e3841b1a0e
commit fc91608d85
2 changed files with 78 additions and 5 deletions

View File

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

View File

@@ -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
}