fix(native-chat): handle image markers in any position

This commit is contained in:
Brennan Benson
2026-08-12 19:21:12 -07:00
parent f1ee3088a5
commit 3d4658ed12
10 changed files with 195 additions and 17 deletions
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
findLandedImagePreviewEchoes,
findLandedUnconfirmedSends,
migrateImagePreviewMessageIds,
type PendingImagePreviewEcho
type PendingImagePreviewEcho,
type UnconfirmedSend
} from './mobile-native-chat-draft-reconcile'
function userText(id: string, text: string): NativeChatMessage {
@@ -21,6 +23,30 @@ function pending(id: string, images: string[], expectedOccurrence = 1): PendingI
}
describe('mobile native chat image preview reconciliation', () => {
it('reconciles a trailing-marker echo and hands its preview to that echo', () => {
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', 'look at this[Image #1]')
]
const preview = {
...pending('pending', ['file:///a.jpg']),
text: 'look at this'
}
const unconfirmed: UnconfirmedSend = {
draftKey: 'draft',
pendingKey: 'pending-key',
text: 'look at this',
normalizedText: 'look at this',
baselineTailMessageId: null,
deadline: null
}
expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([unconfirmed])
expect(findLandedImagePreviewEchoes(messages, [preview])).toEqual([
{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }
])
})
it('keeps separate adjacent image-only sends independently reconcilable', () => {
const landed = findLandedImagePreviewEchoes(
[
@@ -69,4 +95,17 @@ describe('mobile native chat image preview reconciliation', () => {
[sessionKey]: { prompt: ['file:///a.jpg'] }
})
})
it('moves an early standalone preview to a trailing-marker prompt id', () => {
const sessionKey = 'host\0worktree\0tab\0session'
const previous = { [sessionKey]: { source: ['file:///a.jpg'] } }
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', 'look[Image #1]')
]
expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({
[sessionKey]: { prompt: ['file:///a.jpg'] }
})
})
})
@@ -24,8 +24,8 @@ export function normalizedUserText(message: NativeChatMessage): string | null {
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join('')
// Claude echoes a captioned image send as `[Image #1] caption` — the sent
// text must still match its echo, so strip the marker before comparing.
// Claude can place `[Image #N]` anywhere in a caption echo, so strip it
// before comparing against the sent text.
const stripped = stripImagePromptMarker(text).trim()
return stripped || null
}
@@ -127,7 +127,7 @@ function imagePreviewReplacementMessageId(
}
/** Moves previews forward when a progressive source-only transcript frame later
* folds into the marker-prefixed prompt with a different authoritative id. */
* folds into the marker-bearing prompt with a different authoritative id. */
export function migrateImagePreviewMessageIds(
previous: Record<string, Record<string, string[]>>,
sessionKey: string,
@@ -1,6 +1,6 @@
// Single-sources the marker logic (pure functions over shared types):
// Claude records an attached image as `[Image: source: /path]` (+ `[Image #N]`
// prefix on the caption turn), and both render and echo reconciliation must
// on the caption turn), and both render and echo reconciliation must
// agree with desktop on how those marker turns are interpreted.
export {
imageSourcePathFromText,
@@ -92,8 +92,8 @@ describe('buildMobileNativeChatTransientData', () => {
})
it('folds transcript image marker turns into image-ref blocks (desktop parity)', () => {
// Claude records an attached image as `[Image: source: /path]` + an
// `[Image #1] `-prefixed caption turn; the fold must merge them into one
// Claude records an attached image as `[Image: source: /path]` plus a
// caption turn carrying `[Image #1]`; the fold must merge them into one
// user turn with an image-ref block instead of showing raw marker text.
const data = build(
[
@@ -111,6 +111,20 @@ describe('buildMobileNativeChatTransientData', () => {
])
})
it('folds a trailing-marker image echo into one user bubble', () => {
const data = build(
[user('u1', '[Image: source: /tmp/a.png]'), user('u2', 'look at this[Image #1]')],
null,
[]
)
expect(data).toHaveLength(1)
expect(data[0]?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'text', text: 'look at this' }
])
})
it('renders a lone image marker turn (no caption) as an image-ref block', () => {
const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, [])
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
@@ -316,8 +316,8 @@ describe('useMobileNativeChatDrafts', () => {
})
expect(state?.pending).toHaveLength(1)
// Claude echoes a captioned image send as two turns: the source marker and
// the caption prefixed with `[Image #1] ` — the pending must still match.
// Claude echoes a captioned image send as a source turn plus a caption
// carrying `[Image #1]`; the pending must still match.
await act(async () =>
renderer?.update(
createElement(Harness, {
@@ -334,6 +334,37 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] })
})
it('reconciles a captioned image echo with a trailing [Image #N] marker', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
)
)
const origin = state?.captureSendOrigin('look at this')
act(() => {
if (origin) {
state?.acceptSend(origin, 'look at this', ['file:///a.jpg'])
}
})
expect(state?.pending).toHaveLength(1)
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [
assistantTextMessage('a1', 'hi'),
userTextMessage('u1', '[Image: source: /tmp/a.png]'),
userTextMessage('u2', 'look at this[Image #1]')
]
})
)
)
expect(state?.pending).toEqual([])
expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] })
})
it('hands a marker-only image preview to the authoritative user bubble', async () => {
await mount('a')
const origin = state?.captureSendOrigin('')
@@ -95,6 +95,17 @@ describe('prunePendingSends', () => {
expect(next).toEqual([])
})
it('drops an attachment pending send once a trailing-marker prompt advances', () => {
const pending = [
{ ...pendingOf('p1', 'what do you see'), imagePaths: ['/Users/me/Downloads/3d.png'] }
]
const next = prunePendingSends(pending, [
userMessage('m1', 'what do you see[Image #1]'),
assistantMessage('m2', 'an image')
])
expect(next).toEqual([])
})
it('drops an attachment-only pending send once its image turn advances', () => {
const pending = [{ ...pendingOf('p1', ''), imagePaths: ['/tmp/first.png', '/tmp/second.png'] }]
const transcript = [
@@ -74,6 +74,29 @@ describe('preassembled native-chat live sessions', () => {
expect(out[0]).toMatchObject({ id: 'image-prompt', source: 'transcript' })
})
it('keeps legacy parity for trailing image prompt markers', () => {
const transcript: NativeChatMessage[] = [
message('image-source', {
role: 'user',
blocks: [{ type: 'text', text: '[Image: source: /tmp/a.png]' }],
timestamp: 1
}),
message('image-prompt', {
role: 'user',
blocks: [{ type: 'text', text: 'describe this[Image #1]' }],
timestamp: 2
})
]
const out = expectLegacyMessageParity(transcript)
expect(out).toHaveLength(1)
expect(out[0]?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'text', text: 'describe this' }
])
})
it('re-dedupes surfaced skill envelopes that collide across sources', () => {
const skill = (id: string, plugin: string, source: 'transcript' | 'scrape') =>
message(id, {
@@ -112,6 +112,34 @@ describe('assembleNativeChatSession', () => {
])
})
it('merges Claude image source markers into a trailing-marker prompt', () => {
const imageSource = msg({
id: 'u-image-source',
role: 'user',
timestamp: 100,
blocks: [{ type: 'text', text: '[Image: source: /Users/me/Downloads/3d.png]' }]
})
const prompt = msg({
id: 'u-prompt',
role: 'user',
timestamp: 101,
blocks: [{ type: 'text', text: 'what do you see[Image #1]' }]
})
const session = assembleNativeChatSession({
sources: { transcript: [imageSource, prompt] },
sessionId: 's1',
agent: 'claude'
})
expect(session.messages).toHaveLength(1)
expect(session.messages[0]).toMatchObject({ id: 'u-prompt', role: 'user' })
expect(session.messages[0].blocks).toEqual([
{ type: 'image-ref', path: '/Users/me/Downloads/3d.png' },
{ type: 'text', text: 'what do you see' }
])
})
it('drops a scrape duplicate even when scrape is processed first by id', () => {
const scrape = msg({
id: 'shared-id',
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from './native-chat-types'
import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers'
import {
normalizeImageTranscriptMessages,
stripImagePromptMarker
} from './native-chat-image-transcript-markers'
function userText(id: string, text: string): NativeChatMessage {
return {
@@ -25,6 +28,29 @@ describe('normalizeImageTranscriptMessages', () => {
])
})
it('merges a source turn into a prompt with a trailing image marker', () => {
const out = normalizeImageTranscriptMessages([
userText('a', '[Image: source: /tmp/orca-paste-1-2.png]'),
userText('b', 'describe this[Image #1]')
])
expect(out).toHaveLength(1)
expect(out[0]!.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/orca-paste-1-2.png' },
{ type: 'text', text: 'describe this' }
])
})
it.each([
['[Image #1] describe this', 'describe this'],
['describe this [Image #1]', 'describe this'],
['describe [Image #1] this', 'describe this'],
['com[Image #1]pare this', 'compare this'],
['[Image #1] [Image #2]', '']
])('strips image prompt markers anywhere in text', (text, expected) => {
expect(stripImagePromptMarker(text)).toBe(expected)
})
it('converts a lone [Image: source] turn (no prompt) into an image-ref instead of raw text', () => {
const out = normalizeImageTranscriptMessages([
userText('a', '[Image: source: /Users/me/Pictures/hero-image-2.png]')
@@ -1,7 +1,8 @@
import { isTextBlock, type NativeChatBlock, type NativeChatMessage } from './native-chat-types'
const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/
const IMAGE_PROMPT_MARKERS = /^(?:\[Image #\d+\]\s*)+/
const IMAGE_PROMPT_MARKER = /\[Image #\d+\]/
const IMAGE_PROMPT_MARKER_RUN = /[^\S\r\n]*(?:\[Image #\d+\][^\S\r\n]*)+/g
function soleText(message: NativeChatMessage): string | null {
return message.blocks.length === 1 && isTextBlock(message.blocks[0])
@@ -14,7 +15,12 @@ export function imageSourcePathFromText(text: string): string | null {
}
export function stripImagePromptMarker(text: string): string {
return text.replace(IMAGE_PROMPT_MARKERS, '')
return text.replace(IMAGE_PROMPT_MARKER_RUN, (run, offset: number, source: string) => {
const hasTextBefore = offset > 0
const hasTextAfter = offset + run.length < source.length
const hadSurroundingWhitespace = run.replace(/\[Image #\d+\]/g, '').length > 0
return hasTextBefore && hasTextAfter && hadSurroundingWhitespace ? ' ' : ''
})
}
function stripImagePromptMarkersFromFirstText(
@@ -40,13 +46,13 @@ function stripImagePromptMarkersFromFirstText(
return next
}
function imagePromptMarkerStartsMessage(message: NativeChatMessage): boolean {
function imagePromptMarkerAppearsInMessage(message: NativeChatMessage): boolean {
const firstText = message.blocks.find(isTextBlock)
return firstText ? IMAGE_PROMPT_MARKERS.test(firstText.text) : false
return firstText ? IMAGE_PROMPT_MARKER.test(firstText.text) : false
}
/** Claude records image paths as source turns followed by one marker-prefixed
* prompt. Merge the whole run back into one native user turn. */
/** 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(
messages: readonly NativeChatMessage[]
): NativeChatMessage[] {
@@ -75,7 +81,7 @@ export function normalizeImageTranscriptMessages(
if (
prompt?.role === 'user' &&
prompt.source === message.source &&
imagePromptMarkerStartsMessage(prompt)
imagePromptMarkerAppearsInMessage(prompt)
) {
normalized.push({
...prompt,