fix(native-chat): require complete image echo evidence

(cherry picked from commit 94f1cc66ee)
This commit is contained in:
Brennan Benson
2026-08-16 12:02:57 -07:00
parent f04cfaf54b
commit e2450ab9db
9 changed files with 183 additions and 67 deletions
@@ -124,4 +124,38 @@ describe('literal image-marker turns in native chat', () => {
expect(rowText(rows[0]!)).toBe('')
expect(rowImagePaths(rows[0]!)).toEqual(['/tmp/a.png'])
})
it('keeps a captioned multi-image echo until the transcript accounts for every image', () => {
const pending: NativeChatPendingSend[] = [
{
id: 'p1',
text: 'compare these',
imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'],
sentAt: 100
}
]
const transcript = [
userMessage('source-a', '[Image: source: /tmp/a.png]'),
userMessage('prompt', 'compare these [Image #1]')
]
const rows = renderedUserRows(transcript, pending)
expect(rows.map(rowText)).toEqual(['compare these', 'compare these'])
expect(rows.map(rowImagePaths)).toEqual([
['/tmp/a.png'],
['/tmp/a.png', '/tmp/b.png', '/tmp/c.png']
])
const complete = [
userMessage('source-a', '[Image: source: /tmp/a.png]'),
userMessage('source-b', '[Image: source: /tmp/b.png]'),
userMessage('source-c', '[Image: source: /tmp/c.png]'),
userMessage('complete-prompt', 'compare these [Image #1] [Image #2] [Image #3]')
]
const completeRows = renderedUserRows(complete, pending)
expect(completeRows).toHaveLength(1)
expect(rowText(completeRows[0]!)).toBe('compare these')
expect(rowImagePaths(completeRows[0]!)).toEqual(['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'])
})
})
@@ -1,7 +1,9 @@
import {
nativeChatUserMessageImageEvidenceCount,
nativeChatUserMessageMatchText,
nativeChatUserTextMatchText,
normalizeNativeChatUserText,
normalizeNativeChatUserTextWithLiteralFallback
normalizedNativeChatUserMessageText
} from '../../../../shared/native-chat-image-transcript-markers'
import { isImageRefBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
@@ -25,36 +27,47 @@ export function normalizeNativeChatPendingText(text: string): string {
function nativeChatPendingMatchText(
pending: Pick<NativeChatPendingOccurrence, 'text' | 'imagePaths'>
): string {
return pending.imagePaths?.some(Boolean)
? normalizeNativeChatPendingText(pending.text)
: normalizeNativeChatUserTextWithLiteralFallback(pending.text)
return nativeChatUserTextMatchText(pending.text, Boolean(pending.imagePaths?.some(Boolean)))
}
function nativeChatTextContentKey(text: string, imageCount: number): string {
return imageCount > 0 ? `text:${text}\0images:${imageCount}` : `text:${text}`
}
export function nativeChatPendingContentKey(
pending: Pick<NativeChatPendingOccurrence, 'text' | 'imagePaths'>
): string {
const text = nativeChatPendingMatchText(pending)
if (text) {
return `text:${text}`
}
const imagePaths = pending.imagePaths?.filter(Boolean) ?? []
if (text) {
return nativeChatTextContentKey(text, imagePaths.length)
}
return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty'
}
function nativeChatUserMessageContentKey(message: NativeChatMessage): string | null {
function nativeChatUserMessageContentKeys(message: NativeChatMessage): readonly string[] {
if (message.role !== 'user') {
return null
return []
}
const text = nativeChatUserMessageMatchText(message) ?? ''
if (text) {
return `text:${text}`
const keys = new Set<string>()
const matchText = nativeChatUserMessageMatchText(message) ?? ''
if (matchText) {
keys.add(nativeChatTextContentKey(matchText, 0))
}
const imagePaths = message.blocks
.filter(isImageRefBlock)
.map((block) => block.path)
.filter((path): path is string => Boolean(path))
const key = nativeChatPendingContentKey({ text: '', imagePaths })
return key === 'empty' ? null : key
const imageCount = nativeChatUserMessageImageEvidenceCount(message)
const imageText = normalizedNativeChatUserMessageText(message)
if (imageText && imageCount > 0) {
keys.add(nativeChatTextContentKey(imageText, imageCount))
} else if (imageCount > 0) {
const imagePaths = message.blocks
.filter(isImageRefBlock)
.map((block) => block.path)
.filter((path): path is string => Boolean(path))
if (imagePaths.length > 0) {
keys.add(`images:${JSON.stringify(imagePaths)}`)
}
}
return [...keys]
}
export function matchingNativeChatUserContentCounts(
@@ -62,8 +75,7 @@ export function matchingNativeChatUserContentCounts(
): Map<string, number> {
const counts = new Map<string, number>()
for (const message of messages) {
const key = nativeChatUserMessageContentKey(message)
if (key) {
for (const key of nativeChatUserMessageContentKeys(message)) {
counts.set(key, (counts.get(key) ?? 0) + 1)
}
}
@@ -77,8 +89,7 @@ export function advancedNativeChatUserContentCounts(
const waiting = new Map<string, number>()
for (const message of messages) {
if (message.role === 'user') {
const key = nativeChatUserMessageContentKey(message)
if (key) {
for (const key of nativeChatUserMessageContentKeys(message)) {
waiting.set(key, (waiting.get(key) ?? 0) + 1)
}
continue
@@ -180,10 +191,13 @@ export function selectPendingIndicesRepresentedByUserTexts(
}
const remaining = pending.map((entry, index) => ({
index,
text: nativeChatPendingMatchText(entry)
text: nativeChatPendingMatchText(entry),
hasImages: Boolean(entry.imagePaths?.some(Boolean))
}))
for (const userText of userTexts) {
const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0)
const open = remaining.filter(
(entry) => !entry.hasImages && !represented.has(entry.index) && entry.text.length > 0
)
const gluedCount = countLeadingPendingTextsGluedToUserText(
open.map((entry) => entry.text),
userText
@@ -48,10 +48,16 @@ export function normalizeNativeChatUserText(text: string): string {
/** Marker text that stripping would erase entirely is text the user typed
* literally — a real marker always rides on an image turn, which normalization
* has already folded away by the time echo matching runs. */
export function normalizeNativeChatUserTextWithLiteralFallback(text: string): string {
function normalizeNativeChatUserTextWithLiteralFallback(text: string): string {
return normalizeNativeChatUserText(text) || text.trim().replace(/\s+/g, ' ')
}
export function nativeChatUserTextMatchText(text: string, hasImages: boolean): string {
return hasImages
? normalizeNativeChatUserText(text)
: normalizeNativeChatUserTextWithLiteralFallback(text)
}
function joinedUserText(message: NativeChatMessage): string {
return message.blocks
.filter(isTextBlock)
@@ -74,11 +80,7 @@ export function nativeChatUserMessageMatchText(message: NativeChatMessage): stri
return null
}
const joined = joinedUserText(message)
const normalized = normalizeNativeChatUserText(joined)
if (normalized || message.blocks.some(isImageRefBlock)) {
return normalized || null
}
return normalizeNativeChatUserTextWithLiteralFallback(joined) || null
return nativeChatUserTextMatchText(joined, message.blocks.some(isImageRefBlock)) || null
}
function stripImagePromptMarkersFromTextBlocks(
@@ -132,6 +134,14 @@ export function countImagePromptMarkers(message: NativeChatMessage): number {
)
}
export function nativeChatUserMessageImageEvidenceCount(message: NativeChatMessage): number {
if (message.role !== 'user') {
return 0
}
const imageRefCount = message.blocks.filter(isImageRefBlock).length
return Math.max(imageRefCount, countImagePromptMarkers(message))
}
/** Claude records image paths as source turns followed by a prompt carrying
* image markers. Merge the whole run back into one native user turn. */
export function normalizeImageTranscriptMessages(