fix(desktop): pair repeated-caption attachment folds by paste ordinal (#122079)

Two pastes of the same captioned screenshot strip to identical tolerant
captions, so findIndex folded the second paste's error onto the FIRST paste's
settled reply, and the tail prompt match dropped the second paste's prompt as
already-represented by the first's committed row. Pair the n-th local
captioned paste with the n-th stored one; an unpaired ordinal keeps the
conservative preserve path.
This commit is contained in:
Hermes Agent
2026-09-26 16:59:21 -05:00
committed by brooklyn!
parent 820bb0b1af
commit 0f4a98f87c
2 changed files with 104 additions and 4 deletions

View File

@@ -330,6 +330,66 @@ it('never folds a captionless attachment error onto another paste\u2019s reply (
expect(merged.find(message => message.id === 'assistant-stream-x')?.error).toBe('upstream timeout') expect(merged.find(message => message.id === 'assistant-stream-x')?.error).toBe('upstream timeout')
}) })
it('folds a repeated-caption attachment error onto its own paste\u2019s reply, not the earlier one (#122079)', () => {
// Two pastes of the same captioned screenshot are indistinguishable by
// tolerant caption alone — the marker paths that separate them strip out of
// the compare — so a first-match fold stamps the SECOND paste's error onto
// the FIRST paste's settled reply. The errored turn must pair with the
// stored row at the same position: the n-th local captioned paste folds
// onto the n-th stored one.
const merged = preserveLocalAssistantErrors(
[
row('9-0-user', 'user', 'run the migration\n\n[Image attached at: /tmp/first.png]', { rowId: 18711 }),
row('9-1-assistant', 'assistant', 'first answer', { rowId: 18712 }),
row('9-2-user', 'user', 'run the migration\n\n[Image attached at: /tmp/second.png]', { rowId: 18811 }),
row('9-3-assistant', 'assistant', 'second answer', { rowId: 18812 })
],
[
row('9-0-user', 'user', 'run the migration\n\n[Image attached at: /tmp/first.png]', { rowId: 18711 }),
row('9-1-assistant', 'assistant', 'first answer', { rowId: 18712 }),
row('user-paste-2', 'user', 'run the migration', {
attachmentRefs: ['data:image/png;base64,AAAA']
}),
row('assistant-stream-x', 'assistant', 'never stored anywhere', { error: 'upstream timeout' })
]
)
expect(merged.map(message => message.id)).toEqual(['9-0-user', '9-1-assistant', '9-2-user', '9-3-assistant'])
expect(merged.find(message => message.id === '9-1-assistant')?.error).toBeUndefined()
expect(merged.find(message => message.id === '9-3-assistant')?.error).toBe('upstream timeout')
})
it('keeps a repeated-caption attachment error local when its prompt never committed (#122079)', () => {
// The first paste committed and settled; the second paste errored before
// its prompt was saved. The tail user row of the refreshed page is the
// FIRST paste — same tolerant caption, different turn — so neither the
// error fold nor the tail prompt match may claim it: the failed pair
// survives locally and the first paste's settled reply stays clean.
const merged = preserveLocalAssistantErrors(
[
row('9-0-user', 'user', 'run the migration\n\n[Image attached at: /tmp/first.png]', { rowId: 18711 }),
row('9-1-assistant', 'assistant', 'first answer', { rowId: 18712 })
],
[
row('9-0-user', 'user', 'run the migration\n\n[Image attached at: /tmp/first.png]', { rowId: 18711 }),
row('9-1-assistant', 'assistant', 'first answer', { rowId: 18712 }),
row('user-paste-2', 'user', 'run the migration', {
attachmentRefs: ['data:image/png;base64,AAAA']
}),
row('assistant-stream-x', 'assistant', 'never stored anywhere', { error: 'upstream timeout' })
]
)
expect(merged.map(message => message.id)).toEqual([
'9-0-user',
'9-1-assistant',
'user-paste-2',
'assistant-stream-x'
])
expect(merged.find(message => message.id === '9-1-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)', () => { 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 // The windowed hydrated page starts past the failed turn; the kept pair
// (user 210 + errored assistant 211) must land ABOVE the newer turn, not // (user 210 + errored assistant 211) must land ABOVE the newer turn, not

View File

@@ -1,4 +1,4 @@
import { sameAttachmentTurn } from './attachment-turn' import { attachmentTolerantUserText, sameAttachmentTurn } from './attachment-turn'
import { chatMessageText } from './parts' import { chatMessageText } from './parts'
import type { ChatMessage, ChatMessagePart } from './types' import type { ChatMessage, ChatMessagePart } from './types'
@@ -113,6 +113,12 @@ const tailTurnAssistantMatchIndex = (
* settled assistant reply after the match is the durable twin of the failed * settled assistant reply after the match is the durable twin of the failed
* turn. Returns -1 when no rewrite-marker pair exists — plain turns keep the * turn. Returns -1 when no rewrite-marker pair exists — plain turns keep the
* conservative preserve path. * conservative preserve path.
*
* #122079: two pastes of the SAME captioned screenshot strip to identical
* tolerant captions — the marker paths that tell them apart are removed by the
* compare — so a first-match fold pairs the second paste's error with the
* FIRST paste's settled reply. The pastes are ordered, so pair them by
* position: the n-th local captioned paste names the n-th stored one.
*/ */
const attachmentTurnAssistantMatchIndex = ( const attachmentTurnAssistantMatchIndex = (
storedMessages: ChatMessage[], storedMessages: ChatMessage[],
@@ -128,12 +134,29 @@ const attachmentTurnAssistantMatchIndex = (
return -1 return -1
} }
const storedUserIndex = storedMessages.findIndex(stored => stored.role === 'user' && sameAttachmentTurn(stored, localUser)) // How many same-tolerant-caption user rows precede the local one, prompt
// included: the ordinal of this paste among its caption twins.
const localCaption = attachmentTolerantUserText(chatMessageText(localUser))
if (storedUserIndex === -1) { const localCaptionOrdinal = localMessages
.slice(0, localMessages.indexOf(localUser))
.filter(message => message.role === 'user' && attachmentTolerantUserText(chatMessageText(message)) === localCaption)
.length
const matchingStoredUserIndices: number[] = []
storedMessages.forEach((stored, index) => {
if (stored.role === 'user' && sameAttachmentTurn(stored, localUser)) {
matchingStoredUserIndices.push(index)
}
})
if (matchingStoredUserIndices.length <= localCaptionOrdinal) {
return -1 return -1
} }
const storedUserIndex = matchingStoredUserIndices[localCaptionOrdinal]
const reply = storedMessages const reply = storedMessages
.slice(storedUserIndex + 1) .slice(storedUserIndex + 1)
.find(message => message.role === 'assistant' && !message.hidden && !message.pending && !message.interim) .find(message => message.role === 'assistant' && !message.hidden && !message.pending && !message.interim)
@@ -323,11 +346,28 @@ function localAssistantErrorIdsToPreserve(
// data: ref, with no rowId to bridge them (#120978). The tolerant arm is // data: ref, with no rowId to bridge them (#120978). The tolerant arm is
// gated on rewrite markers + local attachment evidence so plain repeats are // gated on rewrite markers + local attachment evidence so plain repeats are
// never swallowed. // never swallowed.
//
// #122079: two pastes of the SAME caption strip to identical tolerant
// captions, so an untethered tolerant claim drops the SECOND paste's prompt
// as "already represented" by the FIRST paste's committed row — an orphaned
// error bubble. Only the same paste-ordinal is the same turn.
const tailUserTolerantText = tailUserInNext ? attachmentTolerantUserText(chatMessageText(tailUserInNext)) : ''
const captionOrdinal = (messages: ChatMessage[], target: ChatMessage): number =>
messages
.slice(0, messages.indexOf(target))
.filter(message => message.role === 'user' && attachmentTolerantUserText(chatMessageText(message)) === tailUserTolerantText)
.length
const tailCaptionOrdinal = tailUserInNext ? captionOrdinal(mergedNextMessages, tailUserInNext) : 0
const matchesTailUserInNext = (candidate: ChatMessage): boolean => const matchesTailUserInNext = (candidate: ChatMessage): boolean =>
Boolean(tailUserInNext) && Boolean(tailUserInNext) &&
((normalizedMessageText(candidate) === tailUserText && ((normalizedMessageText(candidate) === tailUserText &&
(candidate.attachmentRefs ?? []).join('\n') === tailUserRefs) || (candidate.attachmentRefs ?? []).join('\n') === tailUserRefs) ||
(tailUserInNext ? sameAttachmentTurn(tailUserInNext, candidate) : false)) (tailUserInNext
? sameAttachmentTurn(tailUserInNext, candidate) && captionOrdinal(currentMessages, candidate) === tailCaptionOrdinal
: false))
for (let index = 0; index < currentMessages.length; index += 1) { for (let index = 0; index < currentMessages.length; index += 1) {
const message = currentMessages[index] const message = currentMessages[index]