fix(desktop): never tolerance-match a captionless attachment turn (#122079 review)

Two captionless pasted-attachment turns strip to the same empty tolerant
caption, so sameAttachmentTurn matched ANY markers-only stored row against
ANY captionless local row and reconciliation folded one turn's error onto
another paste's reply. Empty now matches nothing: such rows take the
conservative preserve path. Regression test covers the two-paste shape.
This commit is contained in:
Hermes Agent
2026-09-25 18:25:51 -05:00
committed by brooklyn!
parent 4da9224467
commit 056a960870
2 changed files with 53 additions and 2 deletions

View File

@@ -15,7 +15,9 @@ import { chatMessageText } from '@/lib/chat-messages/parts'
* The tolerance is gated on ATTACHMENT EVIDENCE on both sides so a plain
* repeated prompt — or a second attempt under the same caption — is never
* swallowed: the stored row must carry rewrite markers AND the local row must
* carry its own attachment evidence (refs or markers).
* carry its own attachment evidence (refs or markers). A captionless paste —
* whose tolerant caption is empty on both sides — never matches either: empty
* cannot identify a turn, so it takes the conservative preserve path.
*/
// Rewrite markers the backend stamps onto the durable prompt of a pasted
@@ -57,6 +59,7 @@ export const sameAttachmentTurn = (stored: ChatMessage, local: ChatMessage): boo
}
const storedText = chatMessageText(stored)
const localTolerant = attachmentTolerantUserText(chatMessageText(local))
if (!carriesAttachmentRewrite(storedText)) {
return false
@@ -66,5 +69,16 @@ export const sameAttachmentTurn = (stored: ChatMessage, local: ChatMessage): boo
return false
}
return attachmentTolerantUserText(storedText) === attachmentTolerantUserText(chatMessageText(local))
const storedTolerant = attachmentTolerantUserText(storedText)
// A captionless paste strips to the empty caption on both sides, and empty
// cannot identify a turn: every markers-only stored row would compare equal
// against any captionless local row, folding one turn's error onto another
// paste's reply. Empty matches nothing; such rows take the conservative
// preserve path instead.
if (!storedTolerant || !localTolerant) {
return false
}
return storedTolerant === localTolerant
}

View File

@@ -293,6 +293,43 @@ it('never tolerance-matches a plain repeated prompt without attachment evidence
])
})
it('never folds a captionless attachment error onto another paste\u2019s reply (#120978)', () => {
// A captionless paste strips to the empty caption on both sides, and empty
// cannot identify a turn: two markers-only stored rows would compare equal
// against ANY captionless local row, so findIndex could fold an error raised
// on the first paste onto the first settled reply after the SECOND paste.
const merged = preserveLocalAssistantErrors(
[
row('9-0-user', 'user', '[Image attached at: /tmp/first.png]', { rowId: 18711 }),
row('9-1-assistant', 'assistant', 'first answer', { rowId: 18712 }),
row('9-2-user', 'user', '[Image attached at: /tmp/second.png]', { rowId: 18811 }),
row('9-3-assistant', 'assistant', 'second answer', { rowId: 18812 })
],
[
row('9-2-user', 'user', '[Image attached at: /tmp/second.png]', { rowId: 18811 }),
row('9-3-assistant', 'assistant', 'second answer', { rowId: 18812 }),
row('user-paste-1', 'user', '', {
attachmentRefs: ['data:image/png;base64,AAAA']
}),
row('assistant-stream-x', 'assistant', 'never stored anywhere', { error: 'upstream timeout' })
]
)
// The errored turn cannot be pinned to a durable reply: it must survive
// locally instead of stealing the second paste's settled reply.
expect(merged.map(message => message.id)).toEqual([
'9-0-user',
'9-1-assistant',
'9-2-user',
'9-3-assistant',
'user-paste-1',
'assistant-stream-x'
])
expect(merged.find(message => message.id === '9-1-assistant')?.error).toBeUndefined()
expect(merged.find(message => message.id === '9-3-assistant')?.error).toBeUndefined()
expect(merged.find(message => message.id === 'assistant-stream-x')?.error).toBe('upstream timeout')
})
it('splices an older-rowId preserved run in front of the first newer hydrated row (#120978)', () => {
// The windowed hydrated page starts past the failed turn; the kept pair
// (user 210 + errored assistant 211) must land ABOVE the newer turn, not