fix(native-chat): anchor marker stripping to a contiguous image-source run

Re-home the landed literal-text counts onto the draft reconciler after the
#14665 revert removed the module they lived in, and cover the anchoring
boundary: a marker the user typed must not absorb a later send's image.
This commit is contained in:
Brennan Benson
2026-08-16 12:13:52 -07:00
parent a776a86d5c
commit 0f74ea00cc
3 changed files with 80 additions and 0 deletions
@@ -5,6 +5,7 @@ import {
findLandedImagePreviewEchoes,
findLandedUnconfirmedSends,
migrateImagePreviewMessageIds,
userTextOccurrenceCounts,
type PendingImagePreviewEcho,
type UnconfirmedSend
} from './mobile-native-chat-draft-reconcile'
@@ -234,3 +235,22 @@ describe('mobile native chat image preview reconciliation', () => {
).toEqual({ [sessionKey]: { prompt: ['file:///a.jpg'] } })
})
})
describe('userTextOccurrenceCounts', () => {
it('counts a literal marker turn under its verbatim text', () => {
expect(userTextOccurrenceCounts([userText('u1', 'keep [Image #1] literal')])).toEqual(
new Map([['keep [Image #1] literal', 1]])
)
})
// An attached-image turn must not retire a send that carried no images.
it('excludes a folded image turn from the literal landed counts', () => {
const counts = userTextOccurrenceCounts([
userText('s1', '[Image: source: /tmp/a.png]'),
userText('p1', '[Image #1] look')
])
expect(counts.get('look')).toBeUndefined()
expect(counts.size).toBe(0)
})
})
@@ -49,6 +49,24 @@ export function countUserTextOccurrences(
return count
}
/** Landed literal-text turns counted under the same key a markerless send uses.
* Image turns are excluded so they cannot retire a send that carried none. */
export function userTextOccurrenceCounts(
messages: readonly NativeChatMessage[]
): Map<string, number> {
const counts = new Map<string, number>()
for (const message of normalizeImageTranscriptMessages(messages)) {
if (message.blocks.some(isImageRefBlock)) {
continue
}
const text = nativeChatUserMessageMatchText(message)
if (text) {
counts.set(text, (counts.get(text) ?? 0) + 1)
}
}
return counts
}
/** Number of `[Image: source: …]` echo turns strictly after `tailId` (or the
* whole transcript when the tail was paginated out). An image-only send has no
* caption to match, so it reconciles by ordinal against this count — counting
@@ -217,6 +217,48 @@ describe('normalizeImageTranscriptMessages', () => {
expect(out[2]).toBe(after)
})
it('keeps a later literal marker when earlier turns separate it from a real image send', () => {
const out = normalizeImageTranscriptMessages([
userText('s1', '[Image: source: /tmp/a.jpg]'),
userText('p1', '[Image #1] what is this?'),
userText('u1', 'thanks'),
userText('u2', 'in my notes I wrote [Image #2] as plain text')
])
expect(out.at(-1)?.blocks).toEqual([
{ type: 'text', text: 'in my notes I wrote [Image #2] as plain text' }
])
})
it('breaks the anchor when a non-image user turn separates the source run from the prompt', () => {
const out = normalizeImageTranscriptMessages([
userText('s1', '[Image: source: /tmp/a.jpg]'),
userText('u1', 'here is the picture'),
userText('u2', 'please preserve [Image #1] literally')
])
expect(out.map((message) => message.blocks)).toEqual([
[{ type: 'image-ref', path: '/tmp/a.jpg' }],
[{ type: 'text', text: 'here is the picture' }],
[{ type: 'text', text: 'please preserve [Image #1] literally' }]
])
})
// A marker the user typed must not absorb the image of the send that follows it.
it('does not let a literal marker turn claim a later send’s image source', () => {
const out = normalizeImageTranscriptMessages([
userText('u1', 'remember I typed [Image #1] by hand'),
userText('s1', '[Image: source: /tmp/a.jpg]'),
userText('p1', '[Image #1] what is this?')
])
expect(out[0]?.blocks).toEqual([{ type: 'text', text: 'remember I typed [Image #1] by hand' }])
expect(out.at(-1)?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.jpg' },
{ type: 'text', text: 'what is this?' }
])
})
it('leaves assistant messages untouched', () => {
const assistant: NativeChatMessage = {
id: 'a',