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