fix(desktop): keep the attachment-tolerance dedupe inside the identity gate (#122079)

The tolerant sameAttachmentTurn arm was an unparenthesised || operand, so a
rowId-bearing optimistic row could be swallowed by a committed row it provably
is not — pasting the same captioned screenshot twice dropped the second,
genuine turn. Gate both arms on !conflictingTranscriptIdentity, matching the
comment's invariant; the rowId-less paste keeps matching tolerantly.
This commit is contained in:
Hermes Agent
2026-09-26 16:59:20 -05:00
committed by brooklyn!
parent 056a960870
commit 820bb0b1af
2 changed files with 41 additions and 3 deletions

View File

@@ -2205,4 +2205,35 @@ describe('preserveLocalPendingTurnMessages attachment rewrites (#120978)', () =>
'user-plain-repeat'
])
})
it('never tolerance-matches a rowId-bearing optimistic row it provably is not (#122079)', () => {
// The submit receipt binds user_row_id onto the optimistic row while the
// stored page still ends at the earlier paste, so the row reaches the
// dedupe compare carrying a rowId none of the committed candidates hold.
// The tolerant arm must stay inside the identity gate: pasting the same
// captioned screenshot twice is a genuine new turn, not a duplicate.
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-1790168309-ab12cd', 'user', 'unable to publish', {
rowId: 901,
attachmentRefs: ['data:image/png;base64,AAAA']
})
]
const next = [
msg('1-user-stored', 'user', 'first', { rowId: 1 }),
msg('2-assistant-stored', 'assistant', 'first answer', { rowId: 2 }),
msg('3-user-stored', 'user', 'unable to publish\n\n[Image attached at: C:\\img\\shot.png]\n[screenshot]', {
rowId: 3
})
]
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
'1-user-stored',
'2-assistant-stored',
'3-user-stored',
'user-1790168309-ab12cd'
])
})
})

View File

@@ -795,12 +795,14 @@ export function preserveLocalPendingTurnMessages(
// 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.
@@ -886,9 +888,14 @@ export function preserveLocalPendingTurnMessages(
isOptimisticUser &&
acknowledgedUserCandidates.some(
candidate =>
(!conflictingTranscriptIdentity(message, candidate) &&
textWithoutReferenceLines(chatMessageText(candidate)) === textWithoutReferenceLines(chatMessageText(message))) ||
sameAttachmentTurn(candidate, message)
// #122079: the tolerant arm widens the TEXT compare only — it stays
// inside the identity gate, so a rowId-bearing optimistic row is
// never swallowed by a committed row it provably is not (a genuine
// repeat of the same captioned paste). The rowId-less paste from
// #120978 carries no identity and keeps matching tolerantly.
!conflictingTranscriptIdentity(message, candidate) &&
(textWithoutReferenceLines(chatMessageText(candidate)) === textWithoutReferenceLines(chatMessageText(message)) ||
sameAttachmentTurn(candidate, message))
)
) {
continue