From 056a9608700592a686880ec24023f89595ef9618 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 25 Sep 2026 18:25:51 -0500 Subject: [PATCH] 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. --- .../src/lib/chat-messages/attachment-turn.ts | 18 ++++++++- .../lib/chat-messages/reconciliation.test.ts | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/chat-messages/attachment-turn.ts b/apps/desktop/src/lib/chat-messages/attachment-turn.ts index ff7be7e5fc..0f60efe3db 100644 --- a/apps/desktop/src/lib/chat-messages/attachment-turn.ts +++ b/apps/desktop/src/lib/chat-messages/attachment-turn.ts @@ -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 } diff --git a/apps/desktop/src/lib/chat-messages/reconciliation.test.ts b/apps/desktop/src/lib/chat-messages/reconciliation.test.ts index 7a0bb8207d..8977f2268c 100644 --- a/apps/desktop/src/lib/chat-messages/reconciliation.test.ts +++ b/apps/desktop/src/lib/chat-messages/reconciliation.test.ts @@ -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