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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user