diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts index 25255d5743c..b18b6b5393d 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -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) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index a49a98eb1ec..6512d9c8861 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -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 { + const counts = new Map() + 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 diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 859aaf6ab43..477d17d54ae 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -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',