diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index b43a7c79f3b4..fa8adb90eadb 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -24,12 +24,27 @@ type Tick = { streamingText?: string streamLive?: boolean identity?: string + /** The load-earlier affordance, which may over-report on a window filled to + * exactly the limit. Deliberately NOT what the fold reads. */ + hasMore?: boolean + /** The host's own paging answer. Only this may decide whether the oldest + * visible row is a window head that lost its image-source run — an inferred + * true here would erase an `[Image #n]` the user typed. */ + earlierHistoryConfirmed?: boolean + /** The row the host answered about, while it is still on screen. */ + windowHeadMessageId?: string } function overlayElement(tick: Tick): ReturnType { const controller = { showNativeChat: tick.show ?? true, - nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, + nativeChatSession: { + messages: tick.messages ?? [], + status: 'ready', + hasMore: tick.hasMore ?? false, + earlierHistoryConfirmed: tick.earlierHistoryConfirmed ?? false, + windowHeadMessageId: tick.windowHeadMessageId + }, nativeChatAgent: 'claude', nativeChatAgentWorking: tick.streamLive ?? false, nativeChatStreamingText: tick.streamingText, @@ -172,3 +187,83 @@ describe('MobileNativeChatOverlay streaming gate', () => { expect(streaming()).toBeNull() }) }) + +// STA-4363, mobile half. The window-head rule strips a head turn's `[Image #n]` +// run because the read window may have trimmed the source turns that vouch for +// it — true only while older history exists. Desktop derives the head from its +// read list, mobile from the merged list, so pin mobile's own derivation: a +// pure-function test of the fold cannot see this call site stop guarding it. +describe('MobileNativeChatOverlay window-head marker rule', () => { + function markerTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + } + + /** Mounts the overlay, reads the rows it handed the chat view, unmounts. Each + * case owns its renderer, so nothing leaks between them. Inferred rather than + * annotated: `react-test-renderer` ships no usable types here, so naming the + * handle in a union with null yields a redundant `any` constituent. */ + async function foldedFor(tick: Tick): Promise { + const mounted = create(overlayElement(tick)) + await act(async () => {}) + const folded = mounted.root.findAll((node) => node.type === 'ChatView')[0].props + .folded as NativeChatMessage[] + mounted.unmount() + return folded + } + + async function foldedById(earlierHistoryConfirmed: boolean): Promise> { + const folded = await foldedFor({ + earlierHistoryConfirmed, + windowHeadMessageId: earlierHistoryConfirmed ? 'u-head' : undefined, + messages: [ + markerTurn('u-head', '[Image #1] hello', 1), + markerTurn('u-tail', '[Image #2] bye', 2) + ] + }) + return new Map(folded.map((message) => [message.id, message.blocks])) + } + + it('keeps the head turn’s literal markers when the window holds the whole conversation', async () => { + expect((await foldedById(false)).get('u-head')).toEqual([ + { type: 'text', text: '[Image #1] hello' } + ]) + }) + + it('strips the head turn’s markers only while older history is still pageable', async () => { + expect((await foldedById(true)).get('u-head')).toEqual([{ type: 'text', text: 'hello' }]) + }) + + // `hasMore` also reports true for a window filled to exactly the limit, where + // nothing older exists. Riding it here would erase a marker the user typed at + // the true start of the conversation — the ticket's own defect. Mobile's window + // is 40, so that exact fill is reachable. + it('does not strip on an inferred paging answer the host never confirmed', async () => { + const folded = await foldedFor({ + hasMore: true, + earlierHistoryConfirmed: false, + messages: [markerTurn('u-head', '[Image #1] hello', 1)] + }) + expect(folded[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1] hello' }]) + }) + + // A live append past the window trims the front, so the row that slides up was + // already on screen rendered literally. Re-reading it as a window head would + // rewrite a message the user had seen — the ticket's own defect, on mobile's + // 40-turn window, during any active agent run. + it('does not strip a row that only became the head after a live trim', async () => { + const folded = await foldedFor({ + earlierHistoryConfirmed: true, + // The recorded head has been trimmed away; nothing on screen is known to + // sit mid-run any more. + windowHeadMessageId: 'u-trimmed-away', + messages: [markerTurn('u-head', '[Image #1] hello', 1)] + }) + expect(folded[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1] hello' }]) + }) + + it('leaves a turn below the head literal even while older history exists', async () => { + expect((await foldedById(true)).get('u-tail')).toEqual([ + { type: 'text', text: '[Image #2] bye' } + ]) + }) +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 699f8a1614b9..386d0ed4728d 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -46,7 +46,10 @@ export function MobileNativeChatOverlay({ keyboardInset }: Props): React.JSX.Element | null { const session = controller.nativeChatSession - const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages]) + const folded = useMemo( + () => foldMobileNativeChatMessages(session.messages, session.windowHeadMessageId), + [session.messages, session.windowHeadMessageId] + ) const streaming = useMobileNativeChatStreamingBubble( folded, controller.nativeChatStreamingText, 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 b7453e7a67d5..3d7b9d30477b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { + countUserTextOccurrences, findLandedImagePreviewEchoes, findLandedUnconfirmedSends, migrateImagePreviewMessageIds, + userTextOccurrenceCounts, type PendingImagePreviewEcho, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' @@ -23,6 +25,73 @@ function pending(id: string, images: string[], expectedOccurrence = 1): PendingI } describe('mobile native chat image preview reconciliation', () => { + // Some hosts echo an image send as a user turn they kept no content for. The + // preview-binding path already accepts that shape and shows the photo, so the + // send must reconcile against it too — otherwise the send stays held and the + // 20s deadline warns "delivery unknown" about a message that plainly landed. + // Keyed under '' either way, so only a captionless send can reach it. + it('reconciles an image-only send against an echo the host left empty', () => { + const messages = [ + { + id: 'base', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'ready' }], + timestamp: null, + source: 'transcript' as const + }, + { + id: 'echo', + role: 'user' as const, + blocks: [], + timestamp: null, + source: 'transcript' as const + } + ] + const unconfirmed: UnconfirmedSend = { + draftKey: 'draft', + pendingKey: 'pending-key', + text: '', + normalizedText: '', + imageCount: 1, + baselineTailMessageId: 'base', + deadline: null + } + + expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([unconfirmed]) + }) + + // The empty turn says nothing about a caption, so a send that carried one must + // not claim it — that would retire a bubble whose text never landed. + it('does not let an empty echo claim a captioned image send', () => { + const messages = [ + { + id: 'base', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'ready' }], + timestamp: null, + source: 'transcript' as const + }, + { + id: 'echo', + role: 'user' as const, + blocks: [], + timestamp: null, + source: 'transcript' as const + } + ] + const unconfirmed: UnconfirmedSend = { + draftKey: 'draft', + pendingKey: 'pending-key', + text: 'look at this', + normalizedText: 'look at this', + imageCount: 1, + baselineTailMessageId: 'base', + deadline: null + } + + expect(findLandedUnconfirmedSends(messages, [unconfirmed])).toEqual([]) + }) + it('reconciles a trailing-marker echo and hands its preview to that echo', () => { const messages = [ userText('source', '[Image: source: /tmp/a.png]'), @@ -37,6 +106,7 @@ describe('mobile native chat image preview reconciliation', () => { pendingKey: 'pending-key', text: 'look at this', normalizedText: 'look at this', + imageCount: 1, baselineTailMessageId: null, deadline: null } @@ -58,6 +128,7 @@ describe('mobile native chat image preview reconciliation', () => { pendingKey: 'pending-key', text: 'look here', normalizedText: 'look here', + imageCount: 1, baselineTailMessageId: null, deadline: null } @@ -68,6 +139,54 @@ describe('mobile native chat image preview reconciliation', () => { ]) }) + it('does not treat markerless text as an unknown-ack literal marker echo', () => { + const unconfirmed: UnconfirmedSend = { + draftKey: 'draft', + pendingKey: 'pending-key', + text: 'keep [Image #1] literal', + normalizedText: 'keep [Image #1] literal', + imageCount: 0, + baselineTailMessageId: null, + deadline: null + } + + expect(findLandedUnconfirmedSends([userText('wrong', 'keep literal')], [unconfirmed])).toEqual( + [] + ) + expect( + findLandedUnconfirmedSends([userText('right', 'keep [Image #1] literal')], [unconfirmed]) + ).toEqual([unconfirmed]) + }) + + it('does not treat literal marker text as a markerless unknown-ack echo', () => { + const unconfirmed: UnconfirmedSend = { + draftKey: 'draft', + pendingKey: 'pending-key', + text: 'keep literal', + normalizedText: 'keep literal', + imageCount: 0, + baselineTailMessageId: null, + deadline: null + } + + expect( + findLandedUnconfirmedSends([userText('wrong', 'keep [Image #1] literal')], [unconfirmed]) + ).toEqual([]) + expect(findLandedUnconfirmedSends([userText('right', 'keep literal')], [unconfirmed])).toEqual([ + unconfirmed + ]) + }) + + it('keeps no-image occurrence baselines separate from attached-image turns', () => { + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', 'keep [Image #1] literal') + ] + + expect(countUserTextOccurrences(messages, 'keep literal')).toBe(0) + expect(countUserTextOccurrences(messages, 'keep literal', 1)).toBe(1) + }) + it('reconciles multiple transcript text blocks with desktop separators', () => { const prompt: NativeChatMessage = { ...userText('prompt', 'unused'), @@ -84,6 +203,24 @@ describe('mobile native chat image preview reconciliation', () => { ]) }) + it('hands a local preview to a marker-only transcript echo', () => { + expect( + findLandedImagePreviewEchoes( + [userText('prompt', '[Image #1]')], + [pending('pending', ['file:///a.jpg'])] + ) + ).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }]) + }) + + it('hands a captioned preview to a marker-only compatibility echo', () => { + expect( + findLandedImagePreviewEchoes( + [userText('prompt', 'look [Image #1] here')], + [{ ...pending('pending', ['file:///a.jpg']), text: 'look here' }] + ) + ).toEqual([{ pendingId: 'pending', messageId: 'prompt', images: ['file:///a.jpg'] }]) + }) + it('keeps separate adjacent image-only sends independently reconcilable', () => { const landed = findLandedImagePreviewEchoes( [ @@ -165,3 +302,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 a68fe5bcf324..afd8062afeed 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,12 +1,14 @@ import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { + countImagePromptMarkers, hasImagePromptMarker, isImageSourceUserTurn, + nativeChatUserMessageImageEvidenceCount, + nativeChatUserMessageMatchText, + nativeChatUserTextMatchText, normalizeImageTranscriptMessages, - normalizeNativeChatUserText, normalizedNativeChatUserMessageText } from './mobile-native-chat-image-transcript-markers' -export { normalizeNativeChatUserText as normalizeReconcileText } from './mobile-native-chat-image-transcript-markers' /** An ack-lost ('unknown' outcome) send held until its transcript echo lands or * the deadline surfaces the uncertainty. */ @@ -15,6 +17,7 @@ export type UnconfirmedSend = { pendingKey: string | null text: string normalizedText: string + imageCount: number baselineTailMessageId: string | null deadline: ReturnType | null } @@ -25,17 +28,45 @@ export function normalizedUserText(message: NativeChatMessage): string | null { export function countUserTextOccurrences( messages: readonly NativeChatMessage[], - text: string + text: string, + imageCount = 0 ): number { let count = 0 - for (const message of messages) { - if (normalizedUserText(message) === text) { + for (const message of normalizeImageTranscriptMessages(messages)) { + const hasImageRefs = message.blocks.some(isImageRefBlock) + const matchText = + imageCount > 0 + ? normalizedNativeChatUserMessageText(message) + : nativeChatUserMessageMatchText(message) + if ( + matchText === text && + (imageCount > 0 || !hasImageRefs) && + nativeChatUserMessageImageEvidenceCount(message) >= imageCount + ) { count++ } } 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 @@ -160,16 +191,27 @@ export function findLandedImagePreviewEchoes( if (!entry.images?.length) { continue } - const targetText = normalizeNativeChatUserText(entry.text) + const targetText = nativeChatUserTextMatchText(entry.text, true) const candidates = normalized.filter((message) => { if (message.role !== 'user') { return false } + const imageCount = nativeChatUserMessageImageEvidenceCount(message) if (targetText) { - return normalizedUserText(message) === targetText + return ( + normalizedNativeChatUserMessageText(message) === targetText && + imageCount >= entry.images!.length + ) } - const imageCount = message.blocks.filter(isImageRefBlock).length - return message.blocks.length === 0 || imageCount >= entry.images!.length + const imageRefCount = message.blocks.filter(isImageRefBlock).length + // Why: a marker-only echo proves delivery per marker, so a partially + // rendered turn must not claim (and rebind) previews it cannot account for. + return ( + message.blocks.length === 0 || + imageRefCount >= entry.images!.length || + (countImagePromptMarkers(message) >= entry.images!.length && + normalizedUserText(message) === null) + ) }) const tailIndex = entry.baselineTailMessageId ? messageIndexById.get(entry.baselineTailMessageId) @@ -202,17 +244,52 @@ export function findLandedUnconfirmedSends( // captured tail prove new echoes. User turns are keyed by text; an image echo // (`[Image: source: …]` or no text) keys under '' so an empty-text send can // claim it. - const messageIndexById = new Map() - const userMessagesByText = new Map>() - for (const [index, message] of messages.entries()) { - messageIndexById.set(message.id, index) + const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) + type EchoCandidate = { + id: string + index: number + imageCount: number + /** The host kept no content for this turn, so it testifies to no particular + * number of images — it can stand for a send of any size. */ + countUnknown?: boolean + } + const literalMessagesByText = new Map() + const imageMessagesByText = new Map() + for (const message of normalizeImageTranscriptMessages(messages)) { + const index = messageIndexById.get(message.id) + if (index === undefined) { + continue + } if (message.role !== 'user') { continue } - const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '') - const current = userMessagesByText.get(key) ?? [] - current.push({ id: message.id, index }) - userMessagesByText.set(key, current) + const isImageSource = isImageSourceUserTurn(message) + const imageCount = nativeChatUserMessageImageEvidenceCount(message) + // A turn the host echoed with nothing at all is the compatibility shape for + // an image send it kept no text for. Its evidence count is 0, so requiring + // the count to cover the send would hold that send open until the deadline + // fired a false "delivery unknown" — while the sibling preview-binding path + // accepts exactly this shape and binds the photo. Keys under '' either way, + // so only a captionless send can reach it. + const countUnknown = message.blocks.length === 0 + const candidate: EchoCandidate = { + id: message.id, + index, + imageCount, + ...(countUnknown ? { countUnknown } : {}) + } + if (!message.blocks.some(isImageRefBlock)) { + const literalKey = isImageSource ? '' : (nativeChatUserMessageMatchText(message) ?? '') + const current = literalMessagesByText.get(literalKey) ?? [] + current.push(candidate) + literalMessagesByText.set(literalKey, current) + } + if (imageCount > 0 || countUnknown) { + const imageKey = isImageSource ? '' : (normalizedUserText(message) ?? '') + const current = imageMessagesByText.get(imageKey) ?? [] + current.push(candidate) + imageMessagesByText.set(imageKey, current) + } } const claimedMessageIds = new Set() @@ -224,9 +301,15 @@ export function findLandedUnconfirmedSends( if (tailIndex === undefined) { continue } - const echo = userMessagesByText + const messagesByText = entry.imageCount > 0 ? imageMessagesByText : literalMessagesByText + const echo = messagesByText .get(entry.normalizedText) - ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id)) + ?.find( + (message) => + message.index > tailIndex && + (message.countUnknown === true || message.imageCount >= entry.imageCount) && + !claimedMessageIds.has(message.id) + ) if (echo) { claimedMessageIds.add(echo.id) landed.push(entry) diff --git a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts index 3c3998349ee8..02510fc601d6 100644 --- a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts +++ b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts @@ -3,11 +3,16 @@ // on the caption turn), and both render and echo reconciliation must // agree with desktop on how those marker turns are interpreted. export { + countImagePromptMarkers, imageSourcePathFromText, hasImagePromptMarker, isImageSourceUserTurn, + nativeChatUserMessageImageEvidenceCount, + nativeChatUserMessageMatchText, + nativeChatUserTextMatchText, normalizeImageTranscriptMessages, normalizeNativeChatUserText, normalizedNativeChatUserMessageText, - stripImagePromptMarker + stripImagePromptMarker, + stripImagePromptMarkersFromTextBlocks } from '../../../src/shared/native-chat-image-transcript-markers' diff --git a/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts new file mode 100644 index 000000000000..f7d0cc06d74a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts @@ -0,0 +1,271 @@ +// Outcome-level guard for STA-4363 on mobile: what the chat list ends up +// showing after a send whose text is nothing but `[Image #n]` markers, and what +// an incomplete multi-image echo is allowed to do to the local previews. + +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { + isImageRefBlock, + isTextBlock, + type NativeChatMessage +} from '../../../src/shared/native-chat-types' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages +} from './mobile-native-chat-render-data' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' + +type DraftState = ReturnType + +function userTextMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +describe('mobile literal image-marker turns', () => { + let updateRenderer: (messages: NativeChatMessage[]) => void = () => {} + let unmountRenderer = (): void => {} + let state: DraftState | null = null + + afterEach(() => { + act(unmountRenderer) + updateRenderer = () => {} + unmountRenderer = () => {} + state = null + }) + + function Harness({ messages = [] }: { messages?: NativeChatMessage[] }): null { + state = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'worktree', + tabId: 'a', + sessionId: 'session-a', + messages, + // These cases all model a settled read: the sends below own real + // boundaries, so retirement may judge transcript rows against them. + transcriptSettled: true + }) + return null + } + + async function mount(): Promise { + await act(async () => { + const renderer = create(createElement(Harness, {})) + updateRenderer = (messages) => renderer.update(createElement(Harness, { messages })) + unmountRenderer = () => renderer.unmount() + }) + } + + async function transcript(messages: NativeChatMessage[]): Promise { + await act(async () => updateRenderer(messages)) + } + + function send(text: string, images?: string[]): void { + const origin = state?.captureSendOrigin(text, images) + act(() => { + if (origin) { + state?.acceptSend(origin, text, images) + } + }) + } + + /** The rows the chat list renders: folded transcript (with any rebound local + * previews) followed by the still-unreconciled optimistic echoes. */ + function rows(messages: NativeChatMessage[]): NativeChatMessage[] { + return buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages(messages), + streaming: null, + pending: state?.pending ?? [], + imagePreviewsByMessageId: state?.imagePreviewsByMessageId + }).data + } + + function rowText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join('') + } + + function rowImages(message: NativeChatMessage): string[] { + return message.blocks + .filter(isImageRefBlock) + .map((block) => block.url ?? block.path ?? '') + .filter(Boolean) + } + + it('shows a standalone marker-only send exactly once', async () => { + await mount() + send('[Image #1]') + + const messages = [userTextMessage('u1', '[Image #1]')] + await transcript(messages) + + expect(rows(messages).map(rowText)).toEqual(['[Image #1]']) + }) + + it('shows a marker-only send with surrounding prose exactly once', async () => { + await mount() + send('keep [Image #1] literal') + + const messages = [userTextMessage('u1', 'keep [Image #1] literal')] + await transcript(messages) + + expect(rows(messages).map(rowText)).toEqual(['keep [Image #1] literal']) + }) + + it('does not let a markerless row retire marker-bearing literal text', async () => { + await mount() + send('keep [Image #1] literal') + + const markerless = [userTextMessage('wrong', 'keep literal')] + await transcript(markerless) + expect(rows(markerless).map(rowText)).toEqual(['keep literal', 'keep [Image #1] literal']) + + const exact = [userTextMessage('right', 'keep [Image #1] literal')] + await transcript(exact) + expect(rows(exact).map(rowText)).toEqual(['keep [Image #1] literal']) + }) + + it('does not let an attached-image send shift a markerless send ordinal', async () => { + await mount() + send('keep [Image #1] literal', ['file:///a.jpg']) + send('keep literal') + + const messages = [userTextMessage('plain', 'keep literal')] + await transcript(messages) + + expect(rows(messages).map(rowText)).toEqual(['keep literal', 'keep [Image #1] literal']) + expect(rows(messages).map(rowImages)).toEqual([[], ['file:///a.jpg']]) + }) + + it('holds every preview on the optimistic echo until the marker echo covers them all', async () => { + await mount() + send('', ['file:///a.jpg', 'file:///b.jpg']) + + const partial = [userTextMessage('prompt', '[Image #1]')] + await transcript(partial) + + const partialRows = rows(partial) + expect(partialRows.map(rowImages)).toEqual([[], ['file:///a.jpg', 'file:///b.jpg']]) + expect(state?.imagePreviewsByMessageId).toEqual({}) + + const complete = [userTextMessage('prompt', '[Image #1] [Image #2]')] + await transcript(complete) + + expect(state?.imagePreviewsByMessageId).toEqual({ + prompt: ['file:///a.jpg', 'file:///b.jpg'] + }) + expect(rows(complete).map(rowImages)).toEqual([['file:///a.jpg', 'file:///b.jpg']]) + }) + + it('still binds a single-image preview to its marker-only echo', async () => { + await mount() + send('', ['file:///a.jpg']) + + const messages = [userTextMessage('prompt', '[Image #1]')] + await transcript(messages) + + expect(state?.imagePreviewsByMessageId).toEqual({ prompt: ['file:///a.jpg'] }) + expect(rows(messages).map(rowImages)).toEqual([['file:///a.jpg']]) + }) + + it('still strips a real marker that follows an attached image', async () => { + await mount() + send('look', ['file:///a.jpg']) + + const messages = [ + userTextMessage('src', '[Image: source: /tmp/a.png]'), + userTextMessage('prompt', 'look [Image #1]') + ] + await transcript(messages) + + const rendered = rows(messages) + expect(rendered.map(rowText)).toEqual(['look']) + expect(rendered.map(rowImages)).toEqual([['file:///a.jpg']]) + }) + + it('keeps a captioned multi-image echo until the transcript accounts for every image', async () => { + await mount() + send('compare these', ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg']) + + const partial = [ + userTextMessage('source-a', '[Image: source: /tmp/a.png]'), + userTextMessage('prompt', 'compare these [Image #1]') + ] + await transcript(partial) + + expect(rows(partial).map(rowImages)).toEqual([ + ['/tmp/a.png'], + ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + ]) + expect(rows(partial).map(rowText)).toEqual(['compare these', 'compare these']) + expect(state?.imagePreviewsByMessageId).toEqual({}) + + const complete = [ + userTextMessage('source-a', '[Image: source: /tmp/a.png]'), + userTextMessage('source-b', '[Image: source: /tmp/b.png]'), + userTextMessage('source-c', '[Image: source: /tmp/c.png]'), + userTextMessage('complete-prompt', 'compare these [Image #1] [Image #2] [Image #3]') + ] + await transcript(complete) + + expect(state?.imagePreviewsByMessageId).toEqual({ + 'complete-prompt': ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + }) + const completeRows = rows(complete) + expect(completeRows).toHaveLength(1) + expect(completeRows.map(rowText)).toEqual(['compare these']) + expect(completeRows.map(rowImages)).toEqual([ + ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + ]) + }) + + it('reconciles rapid equal captions in separate image-count namespaces', async () => { + await mount() + send('compare', ['file:///a.jpg']) + send('compare', ['file:///b.jpg', 'file:///c.jpg', 'file:///d.jpg']) + + const messages = [ + userTextMessage('source-a', '[Image: source: /tmp/a.png]'), + userTextMessage('prompt-a', 'compare [Image #1]'), + userTextMessage('source-b', '[Image: source: /tmp/b.png]'), + userTextMessage('source-c', '[Image: source: /tmp/c.png]'), + userTextMessage('source-d', '[Image: source: /tmp/d.png]'), + userTextMessage('prompt-b', 'compare [Image #1] [Image #2] [Image #3]') + ] + await transcript(messages) + + const rendered = rows(messages) + expect(rendered).toHaveLength(2) + expect(rendered.map(rowImages)).toEqual([ + ['file:///a.jpg'], + ['file:///b.jpg', 'file:///c.jpg', 'file:///d.jpg'] + ]) + }) + + it('reconciles rapid captionless sends in separate image-count namespaces', async () => { + await mount() + send('', ['file:///a.jpg']) + send('', ['file:///b.jpg', 'file:///c.jpg', 'file:///d.jpg']) + + const messages = [ + userTextMessage('prompt-a', '[Image #1]'), + userTextMessage('prompt-b', '[Image #1] [Image #2] [Image #3]') + ] + await transcript(messages) + + const rendered = rows(messages) + expect(rendered).toHaveLength(2) + expect(rendered.map(rowImages)).toEqual([ + ['file:///a.jpg'], + ['file:///b.jpg', 'file:///c.jpg', 'file:///d.jpg'] + ]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts index 1abdc714e849..3bf2384a2c01 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.test.ts @@ -161,4 +161,16 @@ describe('rebaseMobileNativeChatPendingBaselines', () => { expect(rebased[0]?.baselineTailMessageId).toBe(null) expect(rebased[0]?.baselineResolved).toBe(true) }) + + // A send that carried no image owns its `[Image #n]` run as literal text, so it + // is a text send like any other and needs the boundary. Keying it through the + // marker-stripping rule would empty it, read it as an image echo, and strand it + // as a permanent glue barrier for its neighbours (STA-4363). + it('gives a marker-only send with no attachment a real boundary', () => { + const rebased = rebaseMobileNativeChatPendingBaselines(history, [ + unresolved('p1', '[Image #1]') + ]) + expect(rebased[0]?.baselineTailMessageId).toBe('m2') + expect(rebased[0]?.baselineResolved).toBe(true) + }) }) diff --git a/mobile/src/session/mobile-native-chat-pending-baseline.ts b/mobile/src/session/mobile-native-chat-pending-baseline.ts index 903bf9d8f324..61ab89401f0b 100644 --- a/mobile/src/session/mobile-native-chat-pending-baseline.ts +++ b/mobile/src/session/mobile-native-chat-pending-baseline.ts @@ -1,5 +1,5 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizeReconcileText } from './mobile-native-chat-draft-reconcile' +import { nativeChatUserTextMatchText } from './mobile-native-chat-image-transcript-markers' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' /** @@ -45,7 +45,7 @@ export function rebaseMobileNativeChatPendingBaselines( return item } const reconcilesAgainstItsOwnTail = - Boolean(item.images?.length) || normalizeReconcileText(item.text) === '' + Boolean(item.images?.length) || nativeChatUserTextMatchText(item.text, false) === '' return item.baselineTailMessageId !== null || reconcilesAgainstItsOwnTail ? { ...item, baselineResolved: true } : { ...item, baselineResolved: true, baselineTailMessageId } diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 7466eae905f7..0aa9f287b3cc 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -1,3 +1,8 @@ +import { + nativeChatUserTextMatchText, + normalizeNativeChatUserText +} from './mobile-native-chat-image-transcript-markers' + export type MobileNativeChatPendingMessage = { id: string text: string @@ -16,6 +21,7 @@ export type MobileNativeChatSendOrigin = { draftKey: string pendingKey: string | null normalizedText: string + imageCount: number baselineOccurrences: number baselineTailMessageId: string | null baselineResolved: boolean @@ -23,6 +29,12 @@ export type MobileNativeChatSendOrigin = { type PendingByKey = Record +/** Markers are the agent's only on a send that carried images; otherwise they + * are literal text, so `captureSendOrigin` keeps them and so must this. */ +function pendingMatchText(pending: MobileNativeChatPendingMessage): string { + return nativeChatUserTextMatchText(pending.text, Boolean(pending.images?.length)) +} + export function combineMobileNativeChatPending( session: MobileNativeChatPendingMessage[], waiting: readonly MobileNativeChatPendingMessage[] @@ -45,11 +57,21 @@ export function appendMobileNativeChatPending( const current = previous[key] ?? [] const earlierOutstanding = current.filter( (pending) => - pending.text.trim() === origin.normalizedText && + pendingMatchText(pending) === origin.normalizedText && + (origin.imageCount > 0 + ? (pending.images?.length ?? 0) >= origin.imageCount + : !pending.images?.length) && pending.expectedOccurrence > origin.baselineOccurrences ).length + // An image send whose caption is only markers still reconciles by image + // ordinal, matching how `captureSendOrigin` normalized it. const expectedImageEchoOrdinal = - current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1 + current.filter( + (pending) => + origin.imageCount > 0 && + (pending.images?.length ?? 0) >= origin.imageCount && + normalizeNativeChatUserText(pending.text) === '' + ).length + 1 return { ...previous, [key]: [ diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts index 1c865e2853c5..2fedb83ebb96 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.test.ts @@ -237,6 +237,27 @@ describe('selectGluedPendingIds', () => { expect(retiredIds(messages, [pendingSend('p1', 'one', 'm1')])).toEqual([]) }) + // An image row is the echo of an image send, and image sends never glue. Keying + // it by its caption would let a photo turn — whose caption happens to spell the + // run — retire typed sends it has nothing to do with, erasing their bubbles. + it('never lets a photo turn’s caption glue unrelated typed sends', () => { + const messages = [ + assistantTurn('m1', 'ready', 1000), + { + id: 'm2', + role: 'user' as const, + blocks: [ + { type: 'image-ref' as const, path: '/tmp/a.png' }, + { type: 'text' as const, text: 'one two' } + ], + timestamp: 5000, + source: 'transcript' as const + } + ] + const pending = [pendingSend('p1', 'one', 'm1'), pendingSend('p2', 'two', 'm1')] + expect(retiredIds(messages, pending)).toEqual([]) + }) + // The cursor slides past a head that cannot match, so a turn may try several // start positions — but one inspection budget covers the whole slide, keeping // the work linear in the run length rather than quadratic. The attempt already diff --git a/mobile/src/session/mobile-native-chat-pending-retirement.ts b/mobile/src/session/mobile-native-chat-pending-retirement.ts index ffdde70ddd4d..476035ef937d 100644 --- a/mobile/src/session/mobile-native-chat-pending-retirement.ts +++ b/mobile/src/session/mobile-native-chat-pending-retirement.ts @@ -1,11 +1,20 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, - normalizeReconcileText, - normalizedUserText + userTextOccurrenceCounts } from './mobile-native-chat-draft-reconcile' +import { + nativeChatUserMessageMatchText, + nativeChatUserTextMatchText +} from './mobile-native-chat-image-transcript-markers' import type { MobileNativeChatPendingMessage } from './mobile-native-chat-pending-echo' +/** A send that carried no image owns its `[Image #n]` run as literal text, so it + * keys verbatim; only an image send lets the marker stand in for a photo. */ +function pendingMatchText(item: MobileNativeChatPendingMessage): string { + return nativeChatUserTextMatchText(item.text, Boolean(item.images?.length)) +} + const SPACE = ' ' const NO_PENDING_IDS: ReadonlySet = new Set() // Slack the cursor slide may spend re-trying later start positions, on top of @@ -33,13 +42,17 @@ export function selectGluedPendingIds( const turns: UserTurn[] = [] for (const [index, message] of messages.entries()) { messageIndexById.set(message.id, index) - const text = normalizedUserText(message) + // An image row can only be the echo of an image send, and those never glue; + // keying it as text would let a photo turn claim unrelated typed sends. + const text = message.blocks.some(isImageRefBlock) + ? null + : nativeChatUserMessageMatchText(message) if (text) { turns.push({ index, text }) } } const segments: GlueSegment[] = pending.map((item) => { - const text = normalizeReconcileText(item.text) + const text = pendingMatchText(item) const tail = item.baselineTailMessageId === null ? -1 @@ -138,13 +151,9 @@ export function retireLandedMobileNativeChatPending( current: MobileNativeChatPendingMessage[], landedImagePendingIds: ReadonlySet ): MobileNativeChatPendingMessage[] { - const landedCounts = new Map() - for (const message of messages) { - const text = normalizedUserText(message) - if (text) { - landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1) - } - } + // Image-bearing rows are excluded: they echo image sends, which retire through + // their local-preview binding, not through a text count. + const landedCounts = userTextOccurrenceCounts(messages) const landedPendingIds = new Set() for (const item of current) { if (landedImagePendingIds.has(item.id)) { @@ -161,7 +170,7 @@ export function retireLandedMobileNativeChatPending( item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) >= item.expectedOccurrence - : (landedCounts.get(normalizeReconcileText(item.text)) ?? 0) >= item.expectedOccurrence + : (landedCounts.get(pendingMatchText(item)) ?? 0) >= item.expectedOccurrence if (landed) { landedPendingIds.add(item.id) } diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 1a1811f808d4..e85effc1850a 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -125,6 +125,30 @@ describe('buildMobileNativeChatTransientData', () => { ]) }) + it('renders literal image-marker text in a standalone user turn', () => { + const data = build([user('u1', 'Please preserve [Image #1] literally')], null, []) + + expect(data[0]?.blocks).toEqual([ + { type: 'text', text: 'Please preserve [Image #1] literally' } + ]) + }) + + it('keeps a marker beyond the folded run’s image count as literal text', () => { + const data = build( + [ + user('u1', '[Image: source: /tmp/a.png]'), + user('u2', '[Image #1] compare with the [Image #2] I mentioned earlier') + ], + null, + [] + ) + + expect(data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'compare with the [Image #2] I mentioned earlier' } + ]) + }) + 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' }]) @@ -149,6 +173,9 @@ describe('buildMobileNativeChatTransientData', () => { ]) }) + // A marker-only turn is the documented normal echo shape for some hosts, and a + // bound preview is proof the markers stand in for the photo now beside them — + // captioning the user's own image with a literal `[Image #1]` is never right. it('restores the local preview onto a marker-only transcript turn', () => { const result = buildMobileNativeChatTransientData({ folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), @@ -160,6 +187,81 @@ describe('buildMobileNativeChatTransientData', () => { expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) }) + // Why: the bound preview only vouches for as many markers as it shows images. + it('keeps surplus marker text the previews cannot vouch for', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1] next to [Image #2]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([ + { type: 'text', text: 'next to [Image #2]' }, + { type: 'image-ref', url: 'file:///phone-photo.jpg' } + ]) + }) + + // Why: a block that already has a path came from a real source turn, so the + // fold spent that marker and deliberately kept the surplus one as the user's + // own words. Charging the preview for it again re-breaks STA-4363 at render. + it('does not re-strip a surplus marker the fold deliberately kept', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([ + user('src', '[Image: source: /tmp/a.png]'), + user('prompt', '[Image #1] compare with the [Image #2] I mentioned') + ]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' }, + { type: 'text', text: 'compare with the [Image #2] I mentioned' } + ]) + }) + + // Why: some agents emit an inline image as a url with NO path, so "path-less" + // does not mean "a preview we just appended". Charging the budget for a block + // the turn already carried destroys a marker the user actually typed. + it('does not charge the budget for a path-less image the turn already carried', () => { + const result = buildMobileNativeChatTransientData({ + folded: [ + { + id: 'prompt', + role: 'user', + blocks: [ + { type: 'image-ref', url: 'https://host/a.png' }, + { type: 'text', text: 'look [Image #2] typed' } + ], + timestamp: 1, + source: 'transcript' + } + ], + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///local/b.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', url: 'file:///local/b.jpg' }, + { type: 'text', text: 'look [Image #2] typed' } + ]) + }) + + // Why: no preview binding means no evidence, so the ticket's rule still holds. + it('leaves marker text alone when no preview is bound', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: {} + }) + + expect(result.data[0]?.blocks).toEqual([{ type: 'text', text: '[Image #1]' }]) + }) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { // Whether text streams at all is the gate's call // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 73a45db6962a..36a0eaa79ef1 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -6,7 +6,10 @@ import { import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' +import { + normalizeImageTranscriptMessages, + stripImagePromptMarkersFromTextBlocks +} from './mobile-native-chat-image-transcript-markers' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** The centered empty-state copy for a chat with no messages, mirroring the @@ -43,10 +46,15 @@ export type MobileNativeChatPendingItem = { images?: string[] } -export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { +export function foldMobileNativeChatMessages( + messages: NativeChatMessage[], + windowHeadMessageId?: string +): NativeChatMessage[] { // Normalize first (desktop assembler parity): image marker turns fold into // image-ref blocks instead of rendering as raw `[Image: …]` text. - return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages))) + return foldToolMessages( + stripNoiseMessages(normalizeImageTranscriptMessages(messages, { windowHeadMessageId })) + ) } /** Assemble the list data the chat renders: the folded transcript, then a @@ -78,11 +86,25 @@ export function buildMobileNativeChatTransientData({ previewIndex += 1 return url ? { ...block, url } : block }) + // Previews left over once every image block the turn already had has taken + // one. Only these add an image the turn was not already showing. + const appendedPreviews = Math.max(0, previews.length - previewIndex) while (previewIndex < previews.length) { blocks.push({ type: 'image-ref', url: previews[previewIndex] }) previewIndex += 1 } - return { ...message, blocks } + // Why: a bound preview is positive proof this turn echoes an image sent from + // this device (`findLandedImagePreviewEchoes` binds it to nothing else), so + // its markers are placeholders — no visible source run needed to vouch for + // them. Without this a marker-only host echo captions the user's own photo + // with a literal `[Image #1]`. + // + // Budget is what was APPENDED, not what looks path-less. A block the turn + // already carried has already had its marker spent — by the fold for a + // `[Image: source: …]` run, or by the host for an inline image, which some + // agents emit as a url with no path. Charging for those would re-strip a + // marker the user actually typed. + return { ...message, blocks: stripImagePromptMarkersFromTextBlocks(blocks, appendedPreviews) } }) const data: NativeChatMessage[] = [ ...renderedFolded, diff --git a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts index 0c8d3bf674a2..154dc6135ceb 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts @@ -126,6 +126,40 @@ describe('useMobileNativeChatDrafts launch draft', () => { expect(state?.composerText).toBe('') }) + it('declines a launch draft when the only user turn is a literal image marker', async () => { + await mount('a') + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('m1', 'keep [Image #1] literal')], + launchDraft: 'issue link' + }) + ) + ) + + expect(state?.composerText).toBe('') + }) + + it('retires an adopted launch draft when a literal marker turn lands', async () => { + await mount('a') + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' })) + ) + expect(state?.composerText).toBe('issue link') + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('m1', 'keep [Image #1] literal')], + launchDraft: 'issue link' + }) + ) + ) + expect(state?.composerText).toBe('') + }) + it('clears an untouched prefill once a user turn lands, keeping user edits', async () => { await mount('a') await act(async () => diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 2a5ff2d54c61..d01112064bec 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -222,7 +222,7 @@ describe('useMobileNativeChatDrafts', () => { createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) ) ) - const origin = state?.captureSendOrigin('') + const origin = state?.captureSendOrigin('', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, '', ['file:///a.jpg']) @@ -268,7 +268,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) const textOrigin = state?.captureSendOrigin('ping') - const imageOrigin = state?.captureSendOrigin('') + const imageOrigin = state?.captureSendOrigin('', ['file:///a.jpg']) act(() => { if (textOrigin && imageOrigin) { state?.acceptSend(textOrigin, 'ping') @@ -311,7 +311,7 @@ describe('useMobileNativeChatDrafts', () => { createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) ) ) - const origin = state?.captureSendOrigin('look at this') + const origin = state?.captureSendOrigin('look at this', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, 'look at this', ['file:///a.jpg']) @@ -344,7 +344,7 @@ describe('useMobileNativeChatDrafts', () => { createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) ) ) - const origin = state?.captureSendOrigin('look at this') + const origin = state?.captureSendOrigin('look at this', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, 'look at this', ['file:///a.jpg']) @@ -370,7 +370,7 @@ describe('useMobileNativeChatDrafts', () => { it('hands a marker-only image preview to the authoritative user bubble', async () => { await mount('a') - const origin = state?.captureSendOrigin('') + const origin = state?.captureSendOrigin('', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, '', ['file:///a.jpg']) @@ -478,7 +478,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) // Image-only send: empty text, so it can only reconcile against a new user turn. - const origin = state?.captureSendOrigin('') + const origin = state?.captureSendOrigin('', ['file:///a.jpg']) const onUnconfirmed = vi.fn() act(() => { if (origin) { @@ -495,7 +495,7 @@ describe('useMobileNativeChatDrafts', () => { }) ) ) - // ...but the user's own turn landing does, so the deadline never warns. + // ...but the user's own image turn landing does, so the deadline never warns. await act(async () => renderer?.update( createElement(Harness, { @@ -503,7 +503,7 @@ describe('useMobileNativeChatDrafts', () => { messages: [ assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'ok'), - userTextMessage('u1', '') + userTextMessage('u1', '[Image #1]') ] }) ) @@ -522,7 +522,7 @@ describe('useMobileNativeChatDrafts', () => { createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] }) ) ) - const origin = state?.captureSendOrigin('') + const origin = state?.captureSendOrigin('', ['file:///a.jpg']) act(() => { if (origin) { state?.acceptSend(origin, '', ['file:///a.jpg']) @@ -771,7 +771,7 @@ describe('useMobileNativeChatDrafts', () => { const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] act(() => state?.setComposerText('look')) - const origin = state?.captureSendOrigin('look') + const origin = state?.captureSendOrigin('look', images) expect(origin).toMatchObject({ pendingKey: null }) act(() => { if (origin) { diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 784f025847bf..6849e40c74c5 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -6,7 +6,6 @@ import { findLandedUnconfirmedSends, mergeLandedImagePreviewEchoes, migrateImagePreviewMessageIds, - normalizeReconcileText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' import { rebaseMobileNativeChatPendingBaselines } from './mobile-native-chat-pending-baseline' @@ -20,6 +19,7 @@ import { type MobileNativeChatSendOrigin } from './mobile-native-chat-pending-echo' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' +import { nativeChatUserTextMatchText } from './mobile-native-chat-image-transcript-markers' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' @@ -58,7 +58,7 @@ export function useMobileNativeChatDrafts(args: { /** Phone-local previews rebound to the transcript message that replaced the * optimistic echo, keyed by authoritative message id. */ imagePreviewsByMessageId: Record - captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + captureSendOrigin: (text: string, images?: readonly string[]) => MobileNativeChatSendOrigin | null /** Launch-context text still believed to be parked on the agent's TUI input * line, or null once it has been declined or retired. Send paths size their * pre-clear from it, since one Ctrl+U clears only one logical line. */ @@ -133,16 +133,24 @@ export function useMobileNativeChatDrafts(args: { ) const captureSendOrigin = useCallback( - (text: string) => { + (text: string, images?: readonly string[]) => { if (!draftKey) { return null } - const normalizedText = normalizeReconcileText(text) + // Without an attached image an `[Image #n]` run is the user's own text, so + // it has to stay in the key the transcript echo will be matched against. + const imageCount = images?.length ?? 0 + const normalizedText = nativeChatUserTextMatchText(text, imageCount > 0) return { draftKey, pendingKey, normalizedText, - baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), + imageCount, + baselineOccurrences: countUserTextOccurrences( + messagesRef.current, + normalizedText, + imageCount + ), baselineTailMessageId: messagesRef.current.at(-1)?.id ?? null, // Only a settled read makes this a boundary. Anything else — hydrating, // or a read that failed — hands back an empty list that reads as "the @@ -211,6 +219,7 @@ export function useMobileNativeChatDrafts(args: { pendingKey: origin.pendingKey, text, normalizedText: origin.normalizedText, + imageCount: origin.imageCount, baselineTailMessageId: origin.baselineTailMessageId, deadline: null } diff --git a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts index b75dbf127678..8dbe3280c7a0 100644 --- a/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts +++ b/mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { normalizedUserText } from './mobile-native-chat-draft-reconcile' +import { nativeChatUserMessageMatchText } from './mobile-native-chat-image-transcript-markers' export type MobileNativeChatLaunchDraftSeed = { text: string @@ -63,7 +63,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { } // A user turn already in the transcript means the TUI prefill was submitted // or deliberately cleared; decline instead of resurrecting it. - if (messages.some((message) => normalizedUserText(message) !== null)) { + if (messages.some((message) => nativeChatUserMessageMatchText(message) !== null)) { seededLaunchDraftByKeyRef.current.set(draftKey, null) return } @@ -98,7 +98,7 @@ export function useMobileNativeChatLaunchDraftSeed(args: { if (!seeded) { return } - const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null) + const hasUserTurn = messages.some((message) => nativeChatUserMessageMatchText(message) !== null) if (!hasUserTurn && launchDraft?.trim()) { return } diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index e22c601df315..096525174ebd 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -38,7 +38,12 @@ describe('useMobileNativeChatMessageSend', () => { let renderer: ReactTestRenderer | null = null let api: Send | null = null const acceptSend = vi.fn() - const captureSendOrigin = vi.fn(() => ({ draftKey: 'k', pendingKey: 'p' }) as never) + // Declares the real parameters so `mock.calls[0][0]` is typed; a no-arg mock + // gives `calls` an empty tuple, which mobile `tsc` never surfaces (it excludes + // test files) but a full type pass does. + const captureSendOrigin = vi.fn( + (_text: string, _images?: readonly string[]) => ({ draftKey: 'k', pendingKey: 'p' }) as never + ) const clearDraftForSend = vi.fn() const restoreRejectedDraft = vi.fn() const holdUnconfirmedSend = vi.fn() @@ -200,6 +205,16 @@ describe('useMobileNativeChatMessageSend', () => { expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 }) }) + // Without the image list the origin keys as a markerless text send, so an + // `[Image #n]` caption stays literal and the echo can never match its own turn. + it('hands the attached images to the send origin', async () => { + mount(() => null) + await act(async () => { + await api!.send('[Image #1] look', ['file:///a.png']) + }) + expect(captureSendOrigin.mock.calls[0]![1]).toEqual(['file:///a.png']) + }) + it('does not resolve a composer seed from a question-card answer', async () => { mount(() => ({ text: DRAFT, createdAt: 1 })) await act(async () => { @@ -400,7 +415,8 @@ describe('useMobileNativeChatMessageSend', () => { await api!.send(' run the tests \n') }) expect(sentArgs().text).toBe(' run the tests') - expect(captureSendOrigin).toHaveBeenCalledWith(' run the tests') + // First arg only: a text-only send also passes an undefined image list. + expect(captureSendOrigin.mock.calls[0]![0]).toBe(' run the tests') expect(acceptSend.mock.calls[0]![1]).toBe(' run the tests') }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 53b914657a33..b312c66ac8d4 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -53,7 +53,7 @@ export function useMobileNativeChatMessageSend(args: { /** Captured when a control send starts so a later tab switch cannot record its * session-option effects against the newly active tab. */ commandSendRef: MutableRefObject<(command: string) => void> - captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null + captureSendOrigin: (text: string, images?: readonly string[]) => MobileNativeChatSendOrigin | null /** Launch-context text Orca parked on the agent's TUI input line, or null. Read * at send time so the pre-clear can be sized to every line it occupies. */ readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null @@ -97,7 +97,7 @@ export function useMobileNativeChatMessageSend(args: { // rejected send has to put back exactly that (#14819). const text = draftText.trimEnd() const handle = handleRef.current - const origin = captureSendOrigin(text) + const origin = captureSendOrigin(text, images) const agent = agentRef.current const recordCommand = commandSendRef.current // Why: the lease collapses one render after `connState`, so a question-card diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 2ff97a3370c5..550b50abf284 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' import { + reportedHasMore, useMobileNativeChatSession, type MobileNativeChatSession } from './use-mobile-native-chat-session' @@ -381,6 +382,32 @@ describe('useMobileNativeChatSession', () => { expect(state?.messages.map((entry) => entry.id)).toEqual(['early-b', 'base-c']) }) + // The window head is what licenses stripping a row's `[Image #n]` markers, so + // it has to name the row the HOST answered about. Live appends trim the front + // locally (the window is 40), and the row that slides up was already on screen + // rendered literally — naming it would rewrite a message the user had read, + // which is this ticket's own defect. + it('stops naming a window head once a live append trims the one the host answered about', async () => { + const sendRequest = vi.fn() + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + const base = Array.from({ length: 40 }, (_unused, n) => message(`base-${n}`)) + await act(async () => + emit({ type: 'snapshot', messages: base, hasMore: true, beforeOffset: 1 }) + ) + + expect(state?.windowHeadMessageId).toBe('base-0') + + // One ordinary append past the window drops `base-0` off the front. + await act(async () => emit({ type: 'appended', messages: [message('live-0')] })) + + expect(state?.messages.some((entry) => entry.id === 'base-0')).toBe(false) + expect(state?.windowHeadMessageId).toBeUndefined() + }) + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { let resolveCursorPage: (response: unknown) => void = () => {} const sendRequest = vi @@ -634,3 +661,23 @@ describe('useMobileNativeChatSession transcriptLoading', () => { }) }) }) + +// Both mobile hops cast the wire payload without validating per-field types, so +// the host's paging answer has to be type-checked here or a non-boolean decides +// it. That matters beyond the scroll affordance: the marker fold reads this to +// decide whether a window-head `[Image #n]` is the user's text or a placeholder. +describe('reportedHasMore', () => { + it('takes the host answer only when it is a real boolean', () => { + expect(reportedHasMore(true)).toBe(true) + expect(reportedHasMore(false)).toBe(false) + }) + + it('reads anything else as unanswered rather than a paging verdict', () => { + // 'no' and 0 are the dangerous ones: truthiness would invert or force them. + expect(reportedHasMore('no')).toBeUndefined() + expect(reportedHasMore(0)).toBeUndefined() + expect(reportedHasMore(1)).toBeUndefined() + expect(reportedHasMore(null)).toBeUndefined() + expect(reportedHasMore(undefined)).toBeUndefined() + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index a79b5648dd7f..2bbb3a580371 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -26,6 +26,22 @@ export type MobileNativeChatSession = { error?: string /** True when an older page may exist (the last read filled the window). */ hasMore: boolean + /** True when older history is KNOWN to exist, rather than guessed from a full + * window. `hasMore` falls back to a count inference that reports true for a + * window filled to exactly the limit; that is fine for the "load earlier" + * affordance but not for the marker fold, which changes what a message says — + * a false positive there erases an `[Image #n]` the user typed, and the limit + * here is 40, so an exact fill is far likelier than on desktop. + * + * Usually that knowledge is the host's own answer. It is not always: a replay + * that invalidates the cursor synthesizes it, because a bounded window that + * dropped its oldest row really does have history behind it. Both are facts + * about the transcript; neither is a count standing in for one. */ + earlierHistoryConfirmed: boolean + /** The oldest row of the window the host answered about, while it is still on + * screen. Only that row may have lost the `[Image: source: …]` turns that + * vouch for its markers; undefined means no row may lose any. */ + windowHeadMessageId: string | undefined /** Whether an older-history page is currently loading. */ loadingEarlier: boolean /** Grow the window to page in older history. */ @@ -34,6 +50,14 @@ export type MobileNativeChatSession = { // Small first page for a fast first paint; grows by a page as the user scrolls. const INITIAL_LIMIT = 40 + +/** The host's paging answer, or undefined when it did not give one. Both mobile + * hops cast the wire payload without validating per-field types, so a + * non-boolean must read as "unanswered" rather than force a paging verdict — + * the same guard the desktop runtime parser enforces. */ +export function reportedHasMore(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} const PAGE = 60 const MAX_MESSAGES = 2000 @@ -88,6 +112,12 @@ export function useMobileNativeChatSession(args: { const status = settled ? settled.status : initialStatus const [error, setError] = useState(undefined) const [hasMore, setHasMore] = useState(false) + const [earlierHistoryConfirmed, setEarlierHistoryConfirmed] = useState(false) + // The oldest row of the window the host answered about. Recorded from reads + // only: a live append trims the front locally, and the row that slides up was + // already on screen rendered literally, so re-reading it as a window head + // would rewrite a message the user had already seen. + const [windowHeadMessageId, setWindowHeadMessageId] = useState(undefined) const [loadingEarlier, setLoadingEarlier] = useState(false) const loadingEarlierRef = useRef(false) const beforeOffsetRef = useRef(null) @@ -130,6 +160,8 @@ export function useMobileNativeChatSession(args: { setList([]) setError(undefined) setHasMore(false) + setEarlierHistoryConfirmed(false) + setWindowHeadMessageId(undefined) beforeOffsetRef.current = null if (!client || !agent) { return @@ -181,11 +213,15 @@ export function useMobileNativeChatSession(args: { // overlapping reconnect replay keeps the paged-in history and limit. limitRef.current = INITIAL_LIMIT beforeOffsetRef.current = applied.beforeOffset ?? null - setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT) + setHasMore(reportedHasMore(applied.hasMore) ?? applied.messages.length >= INITIAL_LIMIT) + setEarlierHistoryConfirmed(reportedHasMore(applied.hasMore) === true) + setWindowHeadMessageId(applied.messages[0]?.id) } setMessages(applied.messages) - if (!applied.windowReplaced && applied.hasMore != null) { - setHasMore(applied.hasMore) + const appliedHasMore = reportedHasMore(applied.hasMore) + if (!applied.windowReplaced && appliedHasMore !== undefined) { + setHasMore(appliedHasMore) + setEarlierHistoryConfirmed(appliedHasMore) } if (!applied.windowReplaced && applied.beforeOffset != null) { beforeOffsetRef.current = applied.beforeOffset @@ -220,6 +256,8 @@ export function useMobileNativeChatSession(args: { const pageLimit = nextLimit - limitRef.current if (pageLimit <= 0) { setHasMore(false) + setEarlierHistoryConfirmed(false) + setWindowHeadMessageId(undefined) return } const beforeOffset = beforeOffsetRef.current @@ -253,12 +291,17 @@ export function useMobileNativeChatSession(args: { beforeOffsetRef.current = result.beforeOffset setList([...result.messages, ...mergerRef.current.list]) setHasMore( - nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit) + nextLimit < MAX_MESSAGES && + (reportedHasMore(result.hasMore) ?? result.messages.length >= pageLimit) ) + setEarlierHistoryConfirmed(reportedHasMore(result.hasMore) === true) + setWindowHeadMessageId(result.messages[0]?.id) } else { // Older runtimes ignore the cursor and return the growing tail. setList(result.messages) setHasMore(result.messages.length >= nextLimit) + setEarlierHistoryConfirmed(false) + setWindowHeadMessageId(undefined) } } finally { // A late page from a prior tab must not unlock the current tab's request. @@ -289,6 +332,13 @@ export function useMobileNativeChatSession(args: { transcriptLoading: status === 'loading', error, hasMore, + earlierHistoryConfirmed, + // Undefined once a live trim drops the recorded head: nothing on screen is + // then known to sit mid-image-run, so no row may lose its markers. + windowHeadMessageId: + earlierHistoryConfirmed && visibleMessages.some((m) => m.id === windowHeadMessageId) + ? windowHeadMessageId + : undefined, loadingEarlier, loadEarlier } diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 1c90227cfd21..5a29a284162a 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -277,6 +277,8 @@ describe('nativeChat subscribe lifecycle', () => { type: 'snapshot', messages: [], hasMore: false, + // This host computes the answer, so the snapshot always marks it reported. + hasMoreReported: false, error: 'Transcript unavailable' } }) @@ -295,7 +297,8 @@ describe('nativeChat subscribe lifecycle', () => { frame: { type: 'snapshot', messages: [], - hasMore: false + hasMore: false, + hasMoreReported: false } }) }) @@ -315,6 +318,7 @@ describe('nativeChat subscribe lifecycle', () => { type: 'snapshot', messages: [], hasMore: false, + hasMoreReported: false, lifecycle } }) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 4d0e1765a44a..53baf39b25ef 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -164,6 +164,53 @@ describe('nativeChat:readSession handler', () => { } }) + // The renderer decides from this flag whether a head turn's `[Image #n]` run is + // the user's own words (STA-4363); counting returned rows cannot tell an exactly + // full window from a trimmed one. The reader answers exactly, so the handler has + // to carry it — nothing type-links this result to the preload shape that reads it. + it('reports the host’s exact paging answer, including for an exactly full window', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-hasmore-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'sess-hasmore.jsonl'), + jsonLines( + [1, 2, 3].map((n) => ({ + type: 'user', + uuid: `u-${n}`, + timestamp: `2026-06-01T10:00:0${n}.000Z`, + message: { role: 'user', content: `m${n}` } + })) + ) + ) + + const previousHome = process.env.HOME + process.env.HOME = root + try { + const trimmed = (await invokeReadSession({ + agent: 'claude', + sessionId: 'sess-hasmore', + limit: 2 + })) as { hasMore?: boolean } + expect(trimmed.hasMore).toBe(true) + + const exactlyFull = (await invokeReadSession({ + agent: 'claude', + sessionId: 'sess-hasmore', + limit: 3 + })) as { messages: unknown[]; hasMore?: boolean } + expect(exactlyFull.messages).toHaveLength(3) + expect(exactlyFull.hasMore).toBe(false) + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + it('emits snapshot and appended frames and tears down on destroy', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-')) tempRoots.push(root) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 4ef0f48b8726..c04b7d1cfcd7 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -5,7 +5,6 @@ import type { NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { clearNativeChatTranscriptCache } from '../native-chat/transcript-read-cache' -import type { ReadTranscriptResult } from '../native-chat/transcript-reader' import { subscribeNativeChatTranscript, readNativeChatTranscriptTail, @@ -32,7 +31,13 @@ export type NativeChatReadSessionArgs = { // either the main process or the message list. Pagination raises this limit. const DESKTOP_READ_WINDOW = 300 -async function readSession(args: NativeChatReadSessionArgs): Promise { +// Why: the annotation was `ReadTranscriptResult`, which has no `hasMore` — the +// field survived only because a returned call result skips excess-property +// checking. The renderer now decides from it whether a head turn's `[Image #n]` +// run is the user's own words, so let inference carry the reader's real shape. +async function readSession( + args: NativeChatReadSessionArgs +): Promise>> { const { agent, sessionId } = args // Clamp to a positive window; default to the desktop window for the first page. const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW @@ -62,6 +67,10 @@ export type NativeChatAppendedPayload = { type: 'snapshot' messages: NativeChatMessage[] hasMore: boolean + /** This host computed the answer, so it is always reported here. Remote + * adapters fold a count inference into `hasMore` instead, and callers + * that change what a message says have to tell the two apart. */ + hasMoreReported?: boolean error?: string lifecycle?: NativeChatTurnLifecycle } @@ -69,6 +78,8 @@ export type NativeChatAppendedPayload = { type: 'replacement' messages: NativeChatMessage[] hasMore: boolean + /** See the snapshot variant. */ + hasMoreReported?: boolean lifecycle?: NativeChatTurnLifecycle } | { @@ -193,6 +204,11 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg type: 'snapshot', messages, hasMore, + // Why: this host computed the answer (it reads one turn past the limit), + // so mark it as reported. Remote adapters fold a count inference into + // `hasMore`, and callers that change what a message says need to tell + // the two apart. + hasMoreReported: hasMore, ...(error ? { error } : {}), ...(lifecycle ? { lifecycle } : {}) } @@ -209,6 +225,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg type: 'replacement', messages, hasMore, + hasMoreReported: hasMore, ...(lifecycle ? { lifecycle } : {}) } } satisfies NativeChatAppendedPayload) diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts index 01eac44bde6d..b3b6b758ca00 100644 --- a/src/preload/api/native-chat-api.ts +++ b/src/preload/api/native-chat-api.ts @@ -9,6 +9,10 @@ export type NativeChatReadSessionResult = | { messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle + /** Whether the transcript really holds turns older than this window. The + * host knows exactly (it reads one past the limit); absent from an older + * remote host, which leaves the caller to infer it from the count. */ + hasMore?: boolean } | { error: string; notFound?: true } @@ -20,6 +24,11 @@ export type NativeChatSubscriptionFrame = type: 'snapshot' messages: NativeChatMessage[] hasMore: boolean + /** Set only when the HOST answered. `hasMore` above may be a count + * inference filled in by a remote adapter, which reports true for a + * window filled to exactly the limit; that is fine for the load-earlier + * affordance but not for anything that changes what a message says. */ + hasMoreReported?: boolean error?: string lifecycle?: NativeChatTurnLifecycle } @@ -27,6 +36,8 @@ export type NativeChatSubscriptionFrame = type: 'replacement' messages: NativeChatMessage[] hasMore: boolean + /** See the snapshot variant: set only when the HOST answered. */ + hasMoreReported?: boolean lifecycle?: NativeChatTurnLifecycle } | { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 354732ba0d81..ebb84acf9a7d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -179,6 +179,9 @@ function MessageRow({ className="text-sm" onLinkClick={onLinkClick} allowFileUriLinks={allowFileUriLinks} + // A user turn is prose, so `[Image #1]: /tmp/a.png` must render + // as typed rather than parse away as a link definition. + disableLinkDefinitions /> ) : ( diff --git a/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts new file mode 100644 index 000000000000..c36aabd2dfd9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts @@ -0,0 +1,194 @@ +// Outcome-level guard for STA-4363: the render path and the pending-echo key +// path must agree on which `[Image #n]` runs are literal text, or the optimistic +// echo never reconciles and the turn renders twice. + +import { describe, expect, it } from 'vitest' +import { + isImageRefBlock, + isTextBlock, + type NativeChatMessage +} from '../../../../shared/native-chat-types' +import { pendingSendsAsMessages, prunePendingSends } from './native-chat-pending' +import type { NativeChatPendingSend } from './native-chat-pending' +import { assembleNativeChatSession } from './native-chat-session-assembler' + +function userMessage(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' } +} + +function assistantMessage(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 2, + source: 'transcript' + } +} + +function assembled(transcript: NativeChatMessage[]): NativeChatMessage[] { + return assembleNativeChatSession({ sources: { transcript }, sessionId: 's1', agent: 'claude' }) + .messages +} + +/** The user rows NativeChatView actually renders: assembled transcript first, + * then whichever optimistic echoes are still unrepresented. */ +function renderedUserRows( + transcript: NativeChatMessage[], + pending: NativeChatPendingSend[] +): NativeChatMessage[] { + const messages = assembled(transcript) + return [...messages, ...pendingSendsAsMessages(pending, messages)].filter( + (message) => message.role === 'user' + ) +} + +function rowText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join('') +} + +function rowImagePaths(message: NativeChatMessage): string[] { + return message.blocks + .filter(isImageRefBlock) + .map((block) => block.path ?? block.url ?? '') + .filter(Boolean) +} + +describe('literal image-marker turns in native chat', () => { + it('shows a standalone marker-only send exactly once', () => { + const pending: NativeChatPendingSend[] = [{ id: 'p1', text: '[Image #1]', sentAt: 100 }] + const transcript = [userMessage('m1', '[Image #1]')] + + const rows = renderedUserRows(transcript, pending) + + expect(rows.map(rowText)).toEqual(['[Image #1]']) + }) + + it('shows a marker-only send with surrounding prose exactly once', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: 'keep [Image #1] literal', sentAt: 100 } + ] + const transcript = [userMessage('m1', 'keep [Image #1] literal')] + + expect(renderedUserRows(transcript, pending).map(rowText)).toEqual(['keep [Image #1] literal']) + }) + + it('does not let a markerless row hide marker-bearing literal text', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: 'keep [Image #1] literal', sentAt: 100 } + ] + const transcript = [userMessage('m1', 'keep literal')] + + expect(renderedUserRows(transcript, pending).map(rowText)).toEqual([ + 'keep literal', + 'keep [Image #1] literal' + ]) + }) + + it('does not let literal or attached-image marker rows hide markerless text', () => { + const pending: NativeChatPendingSend[] = [{ id: 'p1', text: 'keep literal', sentAt: 100 }] + const literalTranscript = [userMessage('literal', 'keep [Image #1] literal')] + const imageTranscript = [ + userMessage('source', '[Image: source: /tmp/a.png]'), + userMessage('prompt', 'keep[Image #1] literal') + ] + + expect(renderedUserRows(literalTranscript, pending).map(rowText)).toEqual([ + 'keep [Image #1] literal', + 'keep literal' + ]) + expect(renderedUserRows(imageTranscript, pending).map(rowText)).toEqual([ + 'keep literal', + 'keep literal' + ]) + expect( + prunePendingSends(pending, assembled([...imageTranscript, assistantMessage('a1', 'done')])) + ).toEqual(pending) + }) + + it('prunes the marker-only echo once the assistant answers it', () => { + const pending: NativeChatPendingSend[] = [{ id: 'p1', text: '[Image #1]', sentAt: 100 }] + const transcript = [userMessage('m1', '[Image #1]'), assistantMessage('m2', 'preserved')] + + expect(prunePendingSends(pending, assembled(transcript))).toEqual([]) + }) + + it('shows two rapid marker-only sends exactly twice', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: '[Image #1]', sentAt: 100, matchingOccurrence: 1 }, + { id: 'p2', text: '[Image #1]', sentAt: 101, matchingOccurrence: 2 } + ] + const transcript = [userMessage('m1', '[Image #1]'), userMessage('m2', '[Image #1]')] + + expect(renderedUserRows(transcript, pending).map(rowText)).toEqual(['[Image #1]', '[Image #1]']) + }) + + it('still strips a real marker that follows an attached image, and echoes once', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: 'look', imagePaths: ['/tmp/a.png'], sentAt: 100 } + ] + const transcript = [ + userMessage('src', '[Image: source: /tmp/a.png]'), + userMessage('prompt', 'look [Image #1]') + ] + + const rows = renderedUserRows(transcript, pending) + + expect(rows).toHaveLength(1) + expect(rowText(rows[0]!)).toBe('look') + expect(rowImagePaths(rows[0]!)).toEqual(['/tmp/a.png']) + }) + + it('still reconciles a caption-less image send whose echo is marker-only', () => { + const pending: NativeChatPendingSend[] = [ + { id: 'p1', text: '', imagePaths: ['/tmp/a.png'], sentAt: 100 } + ] + const transcript = [ + userMessage('src', '[Image: source: /tmp/a.png]'), + userMessage('prompt', '[Image #1]') + ] + + const rows = renderedUserRows(transcript, pending) + + expect(rows).toHaveLength(1) + 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']) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts index 3744c70d570f..00886437fcb9 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts @@ -6,11 +6,12 @@ import { assembleNativeChatSession } from './native-chat-session-assembler' export function prepareNativeChatLiveMessages( messages: NativeChatMessage[], - agent: AgentType + agent: AgentType, + windowHeadMessageId?: string ): NativeChatMessage[] { const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name)) const surfaced = surfaceSkillInvocationUserTurns(messages, commandNames) - const normalized = normalizeImageTranscriptMessages(surfaced) + const normalized = normalizeImageTranscriptMessages(surfaced, { windowHeadMessageId }) if (!hasMixedSources(normalized)) { return normalized } @@ -18,7 +19,8 @@ export function prepareNativeChatLiveMessages( return assembleNativeChatSession({ sources: { transcript: surfaced }, sessionId: null, - agent + agent, + windowHeadMessageId }).messages } diff --git a/src/renderer/src/components/native-chat/native-chat-pagination.test.ts b/src/renderer/src/components/native-chat/native-chat-pagination.test.ts index 49f119e0aec8..51def754314c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pagination.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pagination.test.ts @@ -27,4 +27,27 @@ describe('hasMoreNativeChatHistory', () => { expect(hasMoreNativeChatHistory(120, 300)).toBe(false) expect(hasMoreNativeChatHistory(0, 300)).toBe(false) }) + + // The count inference cannot tell "the window is exactly full" from "there is + // more behind it", so a reported `false` ends pagination without the wasted + // read the count rule alone costs. + it('takes the host’s reported answer when it ends pagination', () => { + expect(hasMoreNativeChatHistory(300, 300, false)).toBe(false) + }) + + // A reported `true` says history exists, not that the next read can reach it. + // The runtime RPC host clamps the window to 2000 turns and then answers `true` + // forever while returning the same capped tail; trusting it alone left a + // "Load earlier" button that re-read the whole window and never grew it. + it('still ends pagination when the read came back short of the window', () => { + expect(hasMoreNativeChatHistory(120, 300, true)).toBe(false) + expect(hasMoreNativeChatHistory(2000, 2100, true)).toBe(false) + // The window is still growing, so keep paging. + expect(hasMoreNativeChatHistory(300, 300, true)).toBe(true) + }) + + it('falls back to the count when an older host reports nothing', () => { + expect(hasMoreNativeChatHistory(300, 300, undefined)).toBe(true) + expect(hasMoreNativeChatHistory(120, 300, undefined)).toBe(false) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pagination.ts b/src/renderer/src/components/native-chat/native-chat-pagination.ts index 9d42907d7780..a9dc8af4a210 100644 --- a/src/renderer/src/components/native-chat/native-chat-pagination.ts +++ b/src/renderer/src/components/native-chat/native-chat-pagination.ts @@ -13,9 +13,30 @@ export function nextNativeChatLimit(currentLimit: number): number { return currentLimit + NATIVE_CHAT_PAGE } -/** Whether an older page may still exist: the last read filled the window, so - * there could be more behind it. If the read returned fewer than requested we - * reached the head of the transcript and there is nothing older to load. */ -export function hasMoreNativeChatHistory(returnedCount: number, requestedLimit: number): boolean { - return returnedCount >= requestedLimit +/** + * Whether an older page may still exist. + * + * This drives the load-earlier affordance only. Whether older history exists is + * necessary but not sufficient: the next read has to be able to REACH it, so a + * short read still ends pagination. The runtime RPC host clamps the window to + * 2000 turns, and past that it keeps answering `hasMore: true` while returning + * the same capped tail — taking its word alone leaves a "Load earlier" button + * that re-reads the whole window on every scroll and never loads anything. + * + * `reported` is the host's own answer, which is exact — it reads one turn past + * the limit to decide — so a reported `false` ends pagination immediately, + * sparing the wasted read the count rule alone costs on an exactly-full window. + * + * The count inference is deliberately conservative and NOT exact: a transcript + * whose length is exactly the requested limit fills the window without anything + * behind it, and still reports true. That is safe here (one wasted read) but not + * for anything that changes what a message says — those callers take the host's + * reported value directly rather than going through this function. + */ +export function hasMoreNativeChatHistory( + returnedCount: number, + requestedLimit: number, + reported?: boolean +): boolean { + return reported === false ? false : returnedCount >= requestedLimit } diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 1184affb3690..a05754bdfe2c 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -1,4 +1,8 @@ import { + countImagePromptMarkers, + nativeChatUserMessageImageEvidenceCount, + nativeChatUserMessageMatchText, + nativeChatUserTextMatchText, normalizeNativeChatUserText, normalizedNativeChatUserMessageText } from '../../../../shared/native-chat-image-transcript-markers' @@ -18,40 +22,101 @@ export function normalizeNativeChatPendingText(text: string): string { return normalizeNativeChatUserText(text) } +/** Message-side rule (`nativeChatUserMessageMatchText`) applied to a send: only + * a send that actually carried images can own an `[Image #n]` marker, so on + * every other send the marker is literal text and has to key verbatim. */ +function nativeChatPendingMatchText( + pending: Pick +): string { + 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 ): string { - const text = normalizeNativeChatPendingText(pending.text) + const text = nativeChatPendingMatchText(pending) + const imagePaths = pending.imagePaths?.filter(Boolean) ?? [] if (text) { - return `text:${text}` + return nativeChatTextContentKey(text, imagePaths.length) } - const imagePaths = pending.imagePaths?.filter(Boolean) ?? [] return imagePaths.length > 0 ? `images:${JSON.stringify(imagePaths)}` : 'empty' } -function nativeChatUserMessageContentKey(message: NativeChatMessage): string | null { +/** + * The literal content keys some pending send is waiting on. A send that carried + * no image owns its `[Image #n]` run as text, so its key is the literal reading + * of a row — the only thing that can tell that reading apart from the + * marker-as-placeholder one, which is byte-identical in the row itself. + * + * Derived from the whole pending list rather than the row: it says which readings + * are spoken for, which never varies with the per-send boundary each count is + * sliced at. That invariance is why each count builder can rebuild it — the queue + * is capped at 8 — instead of threading a shared "row is spent" state through. + */ +export function literalContentKeysClaimedByPendings( + pending: readonly Pick[] +): ReadonlySet { + const claimed = new Set() + for (const entry of pending) { + if (!entry.imagePaths?.some(Boolean)) { + claimed.add(nativeChatPendingContentKey(entry)) + } + } + return claimed +} + +function nativeChatUserMessageContentKeys( + message: NativeChatMessage, + claimedLiteralKeys?: ReadonlySet +): readonly string[] { if (message.role !== 'user') { - return null + return [] } - const text = normalizedNativeChatUserMessageText(message) ?? '' - if (text) { - return `text:${text}` + const keys = new Set() + const matchText = nativeChatUserMessageMatchText(message) ?? '' + const hasImageRefs = message.blocks.some(isImageRefBlock) + if (matchText && !hasImageRefs) { + const literalKey = nativeChatTextContentKey(matchText, 0) + keys.add(literalKey) + // Why: one turn is ONE send, but this row can be read two ways — the user's + // own `[Image #n]` text, or a host echoing an image send as that marker. + // Minting both let a single row retire two pendings, so a row showing no + // photo could retire a send that carried one and delete the user's preview + // for good. When a send is actually waiting on the literal reading, that + // reading wins and the row stops there; otherwise the marker reading stands, + // which is what keeps a marker-only host echo matching. + if (claimedLiteralKeys?.has(literalKey)) { + return [...keys] + } + } + 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)}`) + } } - 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 + return [...keys] } export function matchingNativeChatUserContentCounts( - messages: readonly NativeChatMessage[] + messages: readonly NativeChatMessage[], + pending?: readonly Pick[] ): Map { + const claimedLiteralKeys = pending && literalContentKeysClaimedByPendings(pending) const counts = new Map() for (const message of messages) { - const key = nativeChatUserMessageContentKey(message) - if (key) { + for (const key of nativeChatUserMessageContentKeys(message, claimedLiteralKeys)) { counts.set(key, (counts.get(key) ?? 0) + 1) } } @@ -59,14 +124,15 @@ export function matchingNativeChatUserContentCounts( } export function advancedNativeChatUserContentCounts( - messages: readonly NativeChatMessage[] + messages: readonly NativeChatMessage[], + pending?: readonly Pick[] ): Map { + const claimedLiteralKeys = pending && literalContentKeysClaimedByPendings(pending) const advanced = new Map() const waiting = new Map() for (const message of messages) { if (message.role === 'user') { - const key = nativeChatUserMessageContentKey(message) - if (key) { + for (const key of nativeChatUserMessageContentKeys(message, claimedLiteralKeys)) { waiting.set(key, (waiting.get(key) ?? 0) + 1) } continue @@ -79,17 +145,51 @@ export function advancedNativeChatUserContentCounts( return advanced } -/** User texts that already have a later non-user turn (ready to prune echoes). */ +/** One identity a row can be matched on: the text, and how many image-carrying + * sends that reading of the row can account for. */ +export type NativeChatUserTextIdentity = { text: string; imageCount: number } + +/** A transcript user row as the glue matcher sees it. `imageCount` is the budget + * that lets the row own image-carrying pendings — a folded image turn must stay + * glueable, but a text-only row must never consume a send that carried a photo. */ +export type NativeChatUserTextRow = NativeChatUserTextIdentity & { + /** Fallback reading for hosts that echo an image send as bare `[Image #n]` text + * with no `[Image: source: …]` turn. There the markers are the only evidence + * the photo landed, so they vouch for that many sends. Tried only after the + * literal reading fails, so a marker the user actually typed still matches + * verbatim first (STA-4363). */ + markerEcho?: NativeChatUserTextIdentity +} + +function nativeChatUserTextRow(message: NativeChatMessage): NativeChatUserTextRow | null { + const text = nativeChatUserMessageMatchText(message) + if (!text) { + return null + } + // Why: image-ref blocks only, NOT `nativeChatUserMessageImageEvidenceCount` — + // that counts literal `[Image #n]` text as evidence, which is right for content + // keys but would let a row showing no photo retire a send that carried one, + // erasing the user's preview before it lands. Under-counting only costs a + // duplicate bubble; over-counting loses the photo. + const imageCount = message.blocks.filter(isImageRefBlock).length + const markerCount = imageCount === 0 ? countImagePromptMarkers(message) : 0 + const strippedText = markerCount > 0 ? normalizedNativeChatUserMessageText(message) : null + return strippedText + ? { text, imageCount, markerEcho: { text: strippedText, imageCount: markerCount } } + : { text, imageCount } +} + +/** User rows that already have a later non-user turn (ready to prune echoes). */ export function advancedNativeChatUserTexts( messages: readonly NativeChatMessage[] -): readonly string[] { - const advanced: string[] = [] - const waiting: string[] = [] +): readonly NativeChatUserTextRow[] { + const advanced: NativeChatUserTextRow[] = [] + const waiting: NativeChatUserTextRow[] = [] for (const message of messages) { if (message.role === 'user') { - const text = normalizedNativeChatUserMessageText(message) - if (text) { - waiting.push(text) + const row = nativeChatUserTextRow(message) + if (row) { + waiting.push(row) } continue } @@ -99,18 +199,18 @@ export function advancedNativeChatUserTexts( return advanced } -/** All user texts (for hiding optimistic echoes once the turn exists). */ +/** All user rows (for hiding optimistic echoes once the turn exists). */ export function matchingNativeChatUserTexts( messages: readonly NativeChatMessage[] -): readonly string[] { - const texts: string[] = [] +): readonly NativeChatUserTextRow[] { + const rows: NativeChatUserTextRow[] = [] for (const message of messages) { - const text = normalizedNativeChatUserMessageText(message) - if (text) { - texts.push(text) + const row = message.role === 'user' ? nativeChatUserTextRow(message) : null + if (row) { + rows.push(row) } } - return texts + return rows } /** @@ -155,28 +255,62 @@ export function countLeadingPendingTextsGluedToUserText( * matches stay in the content-key/occurrence path so repeated prompts and * send boundaries keep their existing semantics. * - * `userTexts` must already be filtered to rows after the oldest entry's send + * `userRows` must already be filtered to rows after the oldest entry's send * boundary — this matcher has no clock of its own. */ export function selectPendingIndicesRepresentedByUserTexts( pending: readonly NativeChatPendingOccurrence[], - userTexts: readonly string[] + userRows: readonly NativeChatUserTextRow[] ): Set { const represented = new Set() - if (pending.length < 2 || userTexts.length === 0) { + if (pending.length < 2 || userRows.length === 0) { return represented } const remaining = pending.map((entry, index) => ({ index, - text: normalizeNativeChatPendingText(entry.text) + text: nativeChatPendingMatchText(entry), + imageCount: entry.imagePaths?.filter(Boolean).length ?? 0 })) - for (const userText of userTexts) { - const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) - const gluedCount = countLeadingPendingTextsGluedToUserText( - open.map((entry) => entry.text), - userText - ) - // Why: gluedCount === 1 is an exact match — leave it to occurrence counting. + for (const row of userRows) { + // Why: a row can only be the glue of a CONTIGUOUS run of sends, so the scan + // walks `remaining` in send order (retired sends are spliced out below, so it + // is already the open list) and stops where the run breaks. + // + // A send with no match text — an uncaptioned photo, or one whose whole text + // was an `[Image #n]` placeholder — can never appear in a glued row. Ahead of + // the run it separates nothing, so skip it: treating it as a barrier there + // emptied `open` and killed glue for every row, and since such a send can sit + // unretired indefinitely that stranded every later pair for the pane's life. + // Once a run has started it does separate what follows, so it ends the scan — + // otherwise "fix", photo, "bug" would let a row reading "fix bug" retire + // "fix" and "bug" around a send that is still in flight. + const open: typeof remaining = [] + for (const entry of remaining) { + if (entry.text.length === 0) { + if (open.length > 0) { + break + } + continue + } + open.push(entry) + } + const openTexts = open.map((entry) => entry.text) + let gluedCount = 0 + for (const identity of row.markerEcho ? [row, row.markerEcho] : [row]) { + const count = countLeadingPendingTextsGluedToUserText(openTexts, identity.text) + // Why: gluedCount === 1 is an exact match — leave it to occurrence counting. + if (count < 2) { + continue + } + // Why: a folded image turn must stay glueable, but a row can only own as many + // image-carrying sends as it actually shows evidence for. + const gluedImages = open.slice(0, count).reduce((total, entry) => total + entry.imageCount, 0) + if (gluedImages > identity.imageCount) { + continue + } + gluedCount = count + break + } if (gluedCount < 2) { continue } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 29771fd9507b..fdb9319f7ecc 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -95,6 +95,29 @@ describe('prunePendingSends', () => { expect(next).toEqual([]) }) + // The strip is bounded to the run it anchors to, so a folded image row keeps + // surplus markers the user typed. Reading those as photos let this row vouch + // for two sends and retire the two-photo one, which `prunePendingSends` then + // dropped from the bounded cache — the user's photos gone with no trace. + it('does not let one rendered photo retire a send that carried two', () => { + const folded: NativeChatMessage = { + id: 'm1', + role: 'user', + blocks: [ + { type: 'image-ref', path: '/tmp/x.png' }, + { type: 'text', text: 'look [Image #2] [Image #3]' } + ], + timestamp: 1, + source: 'transcript' + } + const pending = [ + { ...pendingOf('p-one', 'look'), imagePaths: ['/tmp/a.png'] }, + { ...pendingOf('p-two', 'look'), imagePaths: ['/tmp/b.png', '/tmp/c.png'] } + ] + const next = prunePendingSends(pending, [folded, assistantMessage('m2', 'ok')]) + expect(next.map((entry) => entry.id)).toEqual(['p-two']) + }) + 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'] } @@ -116,13 +139,50 @@ describe('prunePendingSends', () => { ] } const next = prunePendingSends( - [pendingOf('p1', 'what do you see')], + [{ ...pendingOf('p1', 'what do you see'), imagePaths: ['/tmp/a.png'] }], [prompt, assistantMessage('m2', 'an image')] ) expect(next).toEqual([]) }) + // Why: a row like `[Image #1] fix` reads two ways — the user's own text, or a + // host echoing a 1-image send captioned "fix". Crediting BOTH readings let one + // turn retire two sends, so the photo send was pruned against a row rendering + // no photo and the preview was deleted for good. + it('does not let one row retire both a literal-marker send and a photo send', () => { + const boundary = assistantMessage('m0', 'ok', 1) + const pending = [ + { ...pendingOf('p-literal', '[Image #1] fix'), afterMessageId: 'm0' }, + { ...pendingOf('p-photo', 'fix'), afterMessageId: 'm0', imagePaths: ['/tmp/shot.png'] } + ] + const transcript = [ + boundary, + userMessage('m1', '[Image #1] fix', 2), + assistantMessage('m2', 'done', 3) + ] + + // The literal send is claimed by its own turn; the photo send has not landed. + expect(prunePendingSends(pending, transcript).map((entry) => entry.id)).toEqual(['p-photo']) + }) + + // Why: the marker reading is what retires a send on hosts that echo an image + // send as bare marker text with no source turn. It must survive, and it does + // whenever no other send is waiting on the row's literal reading. + it('still retires a photo send against a marker-only echo row', () => { + const boundary = assistantMessage('m0', 'ok', 1) + const pending = [ + { ...pendingOf('p-photo', 'fix'), afterMessageId: 'm0', imagePaths: ['/tmp/shot.png'] } + ] + const transcript = [ + boundary, + userMessage('m1', '[Image #1] fix', 2), + assistantMessage('m2', 'done', 3) + ] + + expect(prunePendingSends(pending, transcript)).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 = [ @@ -219,6 +279,25 @@ function advancedGlueTranscript(row: string): NativeChatMessage[] { return [...glueTranscript(row), assistantMessage('glue-answer', 'done', 6100)] } +/** The shape `normalizeImageTranscriptMessages` folds an image send into. */ +function foldedImageGlueTranscript(paths: readonly string[], row: string): NativeChatMessage[] { + return [ + userMessage('glue-history', 'hello', 900), + GLUE_BOUNDARY, + { + id: 'glue-row', + role: 'user', + blocks: [ + ...paths.map((path) => ({ type: 'image-ref' as const, path })), + { type: 'text' as const, text: row } + ], + timestamp: 6000, + source: 'transcript' + }, + assistantMessage('glue-answer', 'done', 6100) + ] +} + describe('glued rapid sends', () => { it('retires both echoes when a lost Enter glued the pair into one row', () => { const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')] @@ -248,6 +327,174 @@ describe('glued rapid sends', () => { ).toEqual([]) }) + // Why: the folded image turn is the only transcript evidence of a glued send + // that carried a photo. Ignoring rows with image blocks left both optimistic + // bubbles — one a duplicate of the user's photo — on screen indefinitely. + it('retires a glued pair whose first send carried an image', () => { + const pending = [ + { ...gluePending('p1', 'look at this'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'please') + ] + + expect( + prunePendingSends(pending, foldedImageGlueTranscript(['/tmp/a.png'], 'look at this please')) + ).toEqual([]) + }) + + it('hides a glued image pair as soon as the folded row lands', () => { + const pending = [ + { ...gluePending('p1', 'look at this'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'please') + ] + + expect( + pendingSendsAsMessages( + pending, + foldedImageGlueTranscript(['/tmp/a.png'], 'look at this please') + ) + ).toEqual([]) + }) + + // Why: the image budget. A row carrying no image evidence must never retire a + // send whose photo has not landed, or the preview vanishes before it arrives. + it('does not let a text-only row consume a send that carried an image', () => { + const pending = [ + { ...gluePending('p1', 'look at this'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'please') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('look at this please'))).toEqual( + pending + ) + }) + + // Why: the row's budget counts image blocks, never `[Image #n]` text. Marker + // text is image *evidence* for content keys, but a row showing no photo must + // not retire a send that carried one — that erases the preview before it lands. + it('does not let literal marker text inflate a text-only row image budget', () => { + const pending = [ + { ...gluePending('p1', 'a'), imagePaths: ['/tmp/photo.png'] }, + gluePending('p2', 'b [Image #1]') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b [Image #1]'))).toEqual(pending) + }) + + // Why: a textless send AHEAD of the glued pair separates nothing, so it must not + // block them. Treating it as a barrier there emptied the candidate list and + // killed glue for every row — and because such a send can sit unretired + // indefinitely, it stranded every later pair for the life of the pane. + it('retires a glued pair queued behind a leading uncaptioned image send', () => { + const pending = [ + { ...gluePending('p0', ''), imagePaths: ['/tmp/p.png'] }, + gluePending('p1', 'a'), + gluePending('p2', 'b') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual([pending[0]]) + }) + + it('retires a glued pair queued behind a leading marker-only image send', () => { + const pending = [ + { ...gluePending('p0', '[Image #1]'), imagePaths: ['/tmp/p.png'] }, + gluePending('p1', 'a'), + gluePending('p2', 'b') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual([pending[0]]) + }) + + it('retires a glued pair queued behind a leading whitespace-only send', () => { + const pending = [gluePending('p0', ' '), gluePending('p1', 'a'), gluePending('p2', 'b')] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual([pending[0]]) + }) + + // Why: an uncaptioned photo send has no match text, so it can never appear in a + // glued row — but it still separates the sends either side of it. Skipping it + // would retire those two echoes while their sends are still in flight. + it('does not glue across an uncaptioned image send', () => { + const pending = [ + gluePending('p0', 'fix'), + { ...gluePending('p1', ''), imagePaths: ['/tmp/p.png'] }, + gluePending('p2', 'bug') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('fix bug'))).toEqual(pending) + }) + + // Why: a send whose whole text is the placeholder normalizes to empty too. + it('does not glue across a marker-only image send', () => { + const pending = [ + gluePending('p0', 'fix'), + { ...gluePending('p1', '[Image #1]'), imagePaths: ['/tmp/p.png'] }, + gluePending('p2', 'bug') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('fix bug'))).toEqual(pending) + }) + + it('does not glue across a whitespace-only send', () => { + const pending = [gluePending('p0', 'fix'), gluePending('p1', ' '), gluePending('p2', 'bug')] + + expect(prunePendingSends(pending, advancedGlueTranscript('fix bug'))).toEqual(pending) + }) + + // Why: some hosts echo an image send as bare `[Image #n]` text with no + // `[Image: source: …]` turn, so the markers are the only evidence the photo + // landed. Reading such a row literally left both echoes — one a duplicate + // photo — on screen forever. + it('retires a glued pair against a marker-only host echo row', () => { + const pending = [ + { ...gluePending('p1', 'look'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'here') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('[Image #1] look here'))).toEqual([]) + }) + + it('retires a glued pair when the host echo puts the marker last', () => { + const pending = [ + { ...gluePending('p1', 'look'), imagePaths: ['/tmp/a.png'] }, + gluePending('p2', 'here') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('look here [Image #1]'))).toEqual([]) + }) + + // Why: the literal reading is tried FIRST, so a marker the user actually typed + // still matches verbatim and never falls through to the marker-echo reading. + it('matches a user-typed marker literally rather than as an image echo', () => { + const pending = [gluePending('p1', 'what is [Image #1]'), gluePending('p2', 'about')] + + expect(prunePendingSends(pending, advancedGlueTranscript('what is [Image #1] about'))).toEqual( + [] + ) + }) + + // Why: glue is a CONTIGUOUS leading run. Skipping a send the row cannot afford + // would match a non-contiguous subsequence and retire echoes the row never + // represented — the bubble vanishes though its send is still in flight. + it('does not glue across an image send that landed between two text sends', () => { + const pending = [ + gluePending('p0', 'fix'), + { ...gluePending('p1', 'the'), imagePaths: ['/tmp/p.png'] }, + gluePending('p2', 'bug') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('fix bug'))).toEqual(pending) + }) + + it('does not let a row glue past a leading image send it cannot afford', () => { + const pending = [ + { ...gluePending('p0', 'photo caption'), imagePaths: ['/tmp/p.png'] }, + gluePending('p1', 'a'), + gluePending('p2', 'b') + ] + + expect(prunePendingSends(pending, advancedGlueTranscript('a b'))).toEqual(pending) + }) + it('retires three prompts collapsed into one row with mixed boundaries', () => { const pending = [ gluePending('p1', 'first'), @@ -468,7 +715,7 @@ describe('launchPromptAsMessage', () => { ).toBeNull() }) - it('uses pending-send normalization for large multiline generated prompts', () => { + it('does not strip literal markers from launch-prompt identity', () => { const prompt = [ '[Image #1] Resolve the failing checks:', '', @@ -488,17 +735,17 @@ describe('launchPromptAsMessage', () => { { ...assistantMessage('a1', 'I will fix it'), timestamp: 44 } ] - expect( - shouldPruneLaunchPrompt( - { - tabId: 'tab-1', - agent: 'codex', - text: prompt, - createdAt: 42 - }, - transcript - ) - ).toBe(true) + const entry = { tabId: 'tab-1', agent: 'codex' as const, text: prompt, createdAt: 42 } + + expect(launchPromptAsMessage(entry, transcript)).not.toBeNull() + expect(shouldPruneLaunchPrompt(entry, transcript)).toBe(false) + + const exact = [ + { ...userMessage('u1', prompt), timestamp: 43 }, + { ...assistantMessage('a1', 'I will fix it'), timestamp: 44 } + ] + expect(launchPromptAsMessage(entry, exact)).toBeNull() + expect(shouldPruneLaunchPrompt(entry, exact)).toBe(true) }) it('keeps the launch prompt until the transcript advances past the user turn', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index c322656f1526..bd39b331eb4a 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -162,10 +162,8 @@ export function prunePendingSends( const exactKeep = pending.map((entry) => { const contentKey = nativeChatPendingContentKey(entry) const key = nativeChatPendingMatchKey(entry) - const available = - advancedNativeChatUserContentCounts(messagesAfterPendingBoundary(messages, entry)).get( - contentKey - ) ?? 0 + const inScope = messagesAfterPendingBoundary(messages, entry) + const available = advancedNativeChatUserContentCounts(inScope, pending).get(contentKey) ?? 0 const used = consumed.get(key) ?? 0 const occurrence = nativeChatPendingOccurrence(entry, used) consumed.set(key, Math.max(used, occurrence)) @@ -206,10 +204,8 @@ export function pendingSendsAsMessages( const exactVisible = pending.map((entry) => { const contentKey = nativeChatPendingContentKey(entry) const key = nativeChatPendingMatchKey(entry) - const represented = - matchingNativeChatUserContentCounts( - messagesAfterPendingBoundary(existingMessages, entry) - ).get(contentKey) ?? 0 + const inScope = messagesAfterPendingBoundary(existingMessages, entry) + const represented = matchingNativeChatUserContentCounts(inScope, pending).get(contentKey) ?? 0 const used = consumed.get(key) ?? 0 const occurrence = nativeChatPendingOccurrence(entry, used) consumed.set(key, Math.max(used, occurrence)) diff --git a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts index eb7f2f0d617f..ed8047a734f7 100644 --- a/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-preassembled-session-parity.test.ts @@ -401,3 +401,33 @@ function mulberry32(seed: number): () => number { return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 } } + +// The paging signal is only worth anything if it survives the whole live path. +// A flag that stops at a call site is dead code the pure-function tests can't see. +describe('prepareNativeChatLiveMessages forwards the paging signal to the marker fold', () => { + const trimmedRun = [ + message('u1', { role: 'user', blocks: [{ type: 'text', text: '[Image #1] what do you see' }] }) + ] + + it('strips the named window-head run when older history is still pageable', () => { + const out = prepareNativeChatLiveMessages(trimmedRun, 'claude', 'u1') + expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) + }) + + it('keeps the marker verbatim when the window holds the whole conversation', () => { + const out = prepareNativeChatLiveMessages(trimmedRun, 'claude') + expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) + }) + + // Why: the mixed-source branch re-assembles instead of returning the first + // pass, so it needs the signal handed on independently. + it('forwards the signal through the mixed-source reassembly branch', () => { + const mixed = [ + ...trimmedRun, + message('h1', { source: 'hook', timestamp: 5, blocks: [{ type: 'text', text: 'thinking' }] }) + ] + const out = prepareNativeChatLiveMessages(mixed, 'claude', 'u1') + const user = out.find((m) => m.role === 'user') + expect(user!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.test.ts new file mode 100644 index 000000000000..ec718ddb03f7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { parseRuntimeNativeChatReadSessionResult } from './native-chat-runtime-contract' + +// The paging flag decides whether a head turn's `[Image #n]` run reads as the +// user's own words, so it has to survive the runtime hop rather than be +// re-inferred from the message count on this side. +describe('parseRuntimeNativeChatReadSessionResult', () => { + it('keeps the host’s paging answer when it sends one', () => { + expect(parseRuntimeNativeChatReadSessionResult({ messages: [], hasMore: true })).toEqual({ + messages: [], + hasMore: true + }) + expect(parseRuntimeNativeChatReadSessionResult({ messages: [], hasMore: false })).toEqual({ + messages: [], + hasMore: false + }) + }) + + it('omits it for an older host, leaving the caller to infer from the count', () => { + expect(parseRuntimeNativeChatReadSessionResult({ messages: [] })).toEqual({ messages: [] }) + }) + + it('ignores a non-boolean, so a malformed payload cannot force a paging answer', () => { + expect(parseRuntimeNativeChatReadSessionResult({ messages: [], hasMore: 'yes' })).toEqual({ + messages: [] + }) + }) + + it('passes an error result through untouched', () => { + expect(parseRuntimeNativeChatReadSessionResult({ error: 'boom', notFound: true })).toEqual({ + error: 'boom', + notFound: true + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts index 329c93f852ff..15b1e69458ae 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts @@ -47,7 +47,10 @@ export function parseRuntimeNativeChatReadSessionResult( const lifecycle = parseRuntimeNativeChatTurnLifecycle(record.lifecycle) return { messages: record.messages as NativeChatAppendedMessages, - ...(lifecycle ? { lifecycle } : {}) + ...(lifecycle ? { lifecycle } : {}), + // Kept only when the host actually sent it; an older runtime omits the + // field and the caller falls back to inferring it from the count. + ...(typeof record.hasMore === 'boolean' ? { hasMore: record.hasMore } : {}) } } if (typeof record.error === 'string') { diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts index 78321d615486..34d2d6fae8cc 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts @@ -140,6 +140,24 @@ describe('assembleNativeChatSession', () => { ]) }) + it('renders literal image-marker text in a standalone user turn', () => { + const literal = msg({ + id: 'u-literal', + role: 'user', + blocks: [{ type: 'text', text: 'Please preserve [Image #1] literally' }] + }) + + const session = assembleNativeChatSession({ + sources: { transcript: [literal] }, + sessionId: 's1', + agent: 'claude' + }) + + expect(session.messages[0]?.blocks).toEqual([ + { type: 'text', text: 'Please preserve [Image #1] literally' } + ]) + }) + it('drops a scrape duplicate even when scrape is processed first by id', () => { const scrape = msg({ id: 'shared-id', diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index 2b318554fc7a..568cd25da13e 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -27,6 +27,10 @@ export type AssembleNativeChatSessionInput = { * 'working', or 'error' when out-of-band signals apply. */ status?: NativeChatSessionStatus error?: string + /** Forwarded to the image-marker fold: id of the transcript read window's + * oldest row when older history exists above it, so that row alone can sit + * inside a trimmed image run. */ + windowHeadMessageId?: string } // Why: a turn can surface from several sources with different ids (a hook event @@ -132,15 +136,17 @@ export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): num export function assembleNativeChatSession( input: AssembleNativeChatSessionInput ): NativeChatSession { - const { sources, sessionId, agent, status, error } = input + const { sources, sessionId, agent, status, error, windowHeadMessageId } = input // Process highest priority first so a later, lower-priority duplicate is // dropped rather than overwriting. Within a source, order is preserved. const ordered: NativeChatMessage[] = [ - ...normalizeImageTranscriptMessages(sources.transcript ?? []), + ...normalizeImageTranscriptMessages(sources.transcript ?? [], { windowHeadMessageId }), ...(sources.hook ?? []), // Scrape segments carry the same raw `[Image: source: …]` markers (e.g. from - // scrollback before the transcript loads), so normalize them too. + // scrollback before the transcript loads), so normalize them too. The window + // head is a transcript row, so it can never name a scrape row — scrape is not + // paginated and its markers are never un-anchorable. ...normalizeImageTranscriptMessages(sources.scrape ?? []) ] diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 47832b108974..4c57e2f36941 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -202,8 +202,37 @@ describe('runtime subscribe', () => { expect(onFrame).toHaveBeenNthCalledWith(3, { type: 'replacement', messages: [message('m-replacement')], + hasMore: true, + // The host sent a real boolean here, unlike frames 1 and 2. + hasMoreReported: true + }) + }) + + // Why: the first snapshot folds a count inference into `hasMore` when the host + // omits it, so `hasMore` alone cannot say whether the host answered. Callers + // that change what a message SAYS — the image-marker fold — read the reported + // field instead, and a guessed value must never look like an answer to them. + it('does not report a paging answer the host never gave', async () => { + markRuntimeEnvironmentCompatible(ENV) + const { deliver } = stubSubscribe() + const onFrame = vi.fn() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe( + { subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1', limit: 2 }, + onFrame + ) + await Promise.resolve() + + // Exactly `limit` rows and no hasMore: the inference says true, nobody said so. + deliver({ type: 'snapshot', messages: [message('m-1'), message('m-2')] }) + + expect(onFrame).toHaveBeenNthCalledWith(1, { + type: 'snapshot', + messages: [message('m-1'), message('m-2')], hasMore: true }) + expect(onFrame.mock.calls[0]?.[0]).not.toHaveProperty('hasMoreReported') }) it('validates lifecycle metadata on runtime stream frames', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 0fe22211db0c..4e1653bd856b 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -148,6 +148,12 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'snapshot', messages: frame.messages, hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300), + // Why: the line above folds an inference into `hasMore`. Keep the + // host's own answer separately for callers that change what a + // message says rather than just a scroll affordance. + ...(typeof frame.hasMore === 'boolean' + ? { hasMoreReported: frame.hasMore } + : {}), ...(frame.error ? { error: frame.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -156,6 +162,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'snapshot', messages: frame.messages, hasMore: frame.hasMore ?? false, + ...(typeof frame.hasMore === 'boolean' + ? { hasMoreReported: frame.hasMore } + : {}), ...(frame.error ? { error: frame.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -166,6 +175,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'replacement', messages: frame.messages, hasMore: frame.hasMore ?? false, + ...(typeof frame.hasMore === 'boolean' + ? { hasMoreReported: frame.hasMore } + : {}), ...(lifecycle ? { lifecycle } : {}) } : { diff --git a/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx b/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx new file mode 100644 index 000000000000..a8306e20f1a9 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx @@ -0,0 +1,57 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NativeChatMessageList } from './NativeChatMessageList' +import type { NativeChatLiveSession } from './use-native-chat-live-session' + +function sessionOf(messages: NativeChatMessage[]): NativeChatLiveSession { + return { + messages, + status: 'ready', + sessionId: 's1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + loadEarlier: () => {}, + readPhase: 'ready' + } +} + +function userTurn(text: string): NativeChatMessage { + return { + id: 'u1', + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' + } +} + +function renderedText(text: string): string { + return renderToStaticMarkup( + + ).replace(/<[^>]*>/g, '') +} + +describe('user turn rendering', () => { + // Wiring guard: the row must opt out of link reference definitions, or a + // marker line the transcript rules deliberately preserved still renders as + // an empty bubble. + it.each([ + '[Image #1]: /tmp/a.png', + '- [Image #1]: /tmp/a.png', + '> [Image #1]: /tmp/a.png', + '[Image #1\nstill mine]: /tmp/a.png' + ])('shows a definition-shaped user line on screen', (typed) => { + expect(renderedText(typed)).toContain('[Image #1') + }) + + it('shows a literal marker the user typed without an attachment', () => { + expect(renderedText('keep [Image #1] literal')).toContain('keep [Image #1] literal') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts b/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts index 7ec4beab5247..523af35792e9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-assembled-messages.ts @@ -43,9 +43,13 @@ export function useNativeChatAssembledMessages(args: { sessionId: string | null baseMessages: readonly NativeChatMessage[] appended: NativeChatMessage[] + /** Id of the read window's oldest row while older history is still pageable, + * so that row alone can sit inside an image run whose source turns were + * trimmed. Undefined when the window holds the whole conversation. */ + windowHeadMessageId?: string }): { assembledMessages: NativeChatMessage[]; normalizedMessages: NativeChatMessage[] } { const committedCacheRef = useRef(null) - const { agent, sessionId, baseMessages, appended } = args + const { agent, sessionId, baseMessages, appended, windowHeadMessageId } = args const assembly = useMemo(() => { const committed = committedCacheRef.current @@ -79,8 +83,8 @@ export function useNativeChatAssembledMessages(args: { }, [assembly]) const normalizedMessages = useMemo( - () => prepareNativeChatLiveMessages(assembly.assembledMessages, agent), - [agent, assembly.assembledMessages] + () => prepareNativeChatLiveMessages(assembly.assembledMessages, agent, windowHeadMessageId), + [agent, assembly.assembledMessages, windowHeadMessageId] ) return { assembledMessages: assembly.assembledMessages, normalizedMessages } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 3d3a3206cc56..1cc1bc84600b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -455,6 +455,35 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.status).toBe('ready') }) + // The runtime RPC host clamps the read window to 2000 turns, then keeps + // answering `hasMore: true` while returning the same capped tail. Taking that + // answer as the affordance would leave a "Load earlier" button that re-reads + // the whole window on every scroll near the top and never grows it. + it('stops offering load-earlier once the host stops growing the window', async () => { + const transport = getMockTransport('env-1') + const capped = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, n) => + assistant(`capped-${n}`, 'old') + ) + await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + await act(async () => transport.emit({ type: 'snapshot', messages: capped, hasMore: true })) + expect(latest?.hasMore).toBe(true) + + // The host truthfully has more, but it cannot hand any of it over: the page + // request asked for a wider window and got back the same clamped tail. + transport.readSession.mockImplementationOnce(() => + Promise.resolve({ messages: capped, hasMore: true }) + ) + await act(async () => latest?.loadEarlier()) + + expect(latest?.hasMore).toBe(false) + expect(latest?.messages).toHaveLength(NATIVE_CHAT_INITIAL_LIMIT) + }) + it('does not let an older pagination read rewind a live completion', async () => { useAppStore.setState({ agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } @@ -706,4 +735,162 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { expect(latest?.error).toBeUndefined() expect(latest?.messages.map((m) => m.id)).toContain('a-late') }) + // The marker fold changes what a MESSAGE SAYS, so it rides the host's reported + // paging flag rather than `hasMore`, which falls back to a count inference that + // reports true for a window filled to exactly the limit. A false positive there + // erases an `[Image #n]` the user typed — a regression against a pre-fold base. + function headWindow(): NativeChatMessage[] { + return [ + user('u-head', '[Image #1] what do you see'), + ...Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT - 1 }, (_unused, n) => + assistant(`m-${n}`, 't') + ) + ] + } + + it('keeps a head marker when the host never reported older history', async () => { + const transport = getMockTransport('env-1', { autoSnapshot: false }) + // A host predating the field: the window is exactly full, so the count + // inference says there is more, but nothing confirmed it. + transport.readSession.mockResolvedValueOnce({ messages: headWindow() }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(latest?.hasMore).toBe(true) + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ + { type: 'text', text: '[Image #1] what do you see' } + ]) + }) + + it('strips a head marker once the host confirms older history', async () => { + const transport = getMockTransport('env-1', { autoSnapshot: false }) + transport.readSession.mockResolvedValueOnce({ messages: headWindow(), hasMore: true }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ + { type: 'text', text: 'what do you see' } + ]) + }) + // Why: a remote adapter's FIRST snapshot folds a count inference into + // `hasMore`, so the frame path needs the same guard as the read path — a + // guessed value must not strip a marker the user typed. + it('keeps a head marker when a snapshot frame only inferred older history', async () => { + const transport = getMockTransport('env-1', { autoSnapshot: false }) + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + await act(async () => { + // hasMore true, but no reported field: nobody answered. + transport.emit({ type: 'snapshot', messages: headWindow(), hasMore: true }) + }) + + expect(latest?.hasMore).toBe(true) + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ + { type: 'text', text: '[Image #1] what do you see' } + ]) + }) + + it('strips a head marker when the snapshot frame carries the host answer', async () => { + const transport = getMockTransport('env-1', { autoSnapshot: false }) + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + + await act(async () => { + transport.emit({ + type: 'snapshot', + messages: headWindow(), + hasMore: true, + hasMoreReported: true + }) + }) + + expect(latest?.messages.find((m) => m.id === 'u-head')?.blocks).toEqual([ + { type: 'text', text: 'what do you see' } + ]) + }) +}) + +// STA-4363: the window-head rule strips a head turn's `[Image #n]` run because the +// source turns that would vouch for it may have been trimmed by the read window. +// That only holds while older history actually exists — otherwise the head is the +// start of the conversation and its markers are the user's own words. The host's +// paging answer is what tells the two apart, and a pure-function test cannot see a +// call site that stops forwarding it. +describe('useNativeChatLiveSession — window-head marker rule', () => { + const AGENT = 'claude' as const + const SESSION = 'sess-window-head' + const PANE = 'pane-window-head' + const roots: Root[] = [] + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatLiveSession(props) + return null + } + + function markerTurn(id: string, text: string, timestamp: number): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + } + + /** Renders a two-turn window whose head and tail both carry a literal marker. */ + async function blocksById(earlierHistoryConfirmed: boolean): Promise> { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + const transport = getMockTransport('env-1') + await act(async () => { + root.render( + createElement(Probe, { + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + runtimeEnvironmentId: 'env-1' + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + await act(async () => { + transport.emit({ + type: 'snapshot', + messages: [ + markerTurn('u-head', '[Image #1] hello', 1), + markerTurn('u-tail', '[Image #2] bye', 2) + ], + hasMore: earlierHistoryConfirmed, + // Only the host's own answer may gate the fold; `hasMore` alone folds in + // a count inference that over-reports on an exactly full window. + hasMoreReported: earlierHistoryConfirmed + }) + }) + return new Map((latest?.messages ?? []).map((message) => [message.id, message.blocks])) + } + + beforeEach(() => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + latest = null + vi.clearAllMocks() + resetMockTransports() + }) + + it('keeps a head turn’s literal markers when the window holds the whole conversation', async () => { + expect((await blocksById(false)).get('u-head')).toEqual([ + { type: 'text', text: '[Image #1] hello' } + ]) + }) + + it('strips the head turn’s markers only while older history is still pageable', async () => { + expect((await blocksById(true)).get('u-head')).toEqual([{ type: 'text', text: 'hello' }]) + }) + + it('leaves a turn below the head literal even while older history exists', async () => { + expect((await blocksById(true)).get('u-tail')).toEqual([ + { type: 'text', text: '[Image #2] bye' } + ]) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 6044da306732..bc23035eda0b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -94,6 +94,15 @@ export function useNativeChatLiveSession( ) const [read, setRead] = useState({ phase: 'loading' }) const [hasMore, setHasMore] = useState(false) + // Why: `hasMore` merges the host's answer with a count inference, and the + // inference is not exact — a window filled to exactly the limit reports true + // with nothing behind it. That is fine for the "load earlier" affordance (one + // wasted read) but not for the marker fold, which changes what a message SAYS: + // a false positive there erases an `[Image #n]` the user typed. So the fold + // rides the host's reported value alone. Every transport carries it on the + // initial read; only a host predating the field omits it, and there the fold + // simply stays off, which is what this branch's base did. + const [earlierHistoryConfirmed, setEarlierHistoryConfirmed] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) const [transcriptLifecycle, transcriptLifecycleControl] = useNativeChatTranscriptLifecycle() // The active read window; raised by loadEarlier to page in older history. @@ -140,6 +149,7 @@ export function useNativeChatLiveSession( replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) + setEarlierHistoryConfirmed(false) } return () => undefined } @@ -150,6 +160,7 @@ export function useNativeChatLiveSession( replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) + setEarlierHistoryConfirmed(false) return () => undefined } @@ -168,6 +179,7 @@ export function useNativeChatLiveSession( replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) + setEarlierHistoryConfirmed(false) // Independent initial seed in case subscribe never delivers a snapshot; applied only until an authoritative frame lands so a live snapshot wins. function loadSession(attempt: number): void { @@ -192,7 +204,8 @@ export function useNativeChatLiveSession( const messages = result?.messages ?? [] transcriptLifecycleControl.replace(result?.lifecycle) setRead({ phase: 'ready', messages }) - setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) + setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current, result?.hasMore)) + setEarlierHistoryConfirmed(result?.hasMore === true) }) .catch((err: unknown) => { if (!cancelled && latestEnabled.current && !frameArrived) { @@ -232,6 +245,10 @@ export function useNativeChatLiveSession( setAppended([]) setRead({ phase: 'ready', messages: appendMergerRef.current.list }) setHasMore(frame.hasMore) + // Why: `frame.hasMore` on a remote adapter's FIRST snapshot folds in a + // count inference, so it cannot say whether the host answered. Absence + // of the reported field means unanswered on every path. + setEarlierHistoryConfirmed(frame.hasMoreReported === true) return } transcriptLifecycleControl.append(frame.lifecycle) @@ -281,7 +298,8 @@ export function useNativeChatLiveSession( // Read results are an ordered tail: replace the base list so the older page prepends in order; live appends stay separate. setRead({ phase: 'ready', messages: result.messages }) transcriptLifecycleControl.replaceFromPagination(result.lifecycle, lifecycleRevision) - setHasMore(hasMoreNativeChatHistory(result.messages.length, nextLimit)) + setHasMore(hasMoreNativeChatHistory(result.messages.length, nextLimit, result.hasMore)) + setEarlierHistoryConfirmed(result.hasMore === true) }) .catch(() => { // Swallow a rejected "load more" read: keep the already-loaded transcript intact rather than surface the rejection. @@ -309,7 +327,10 @@ export function useNativeChatLiveSession( agent, sessionId, baseMessages, - appended + appended, + // Why: the oldest row of the paginated read, not a position in the merged + // list — see NormalizeImageTranscriptOptions. + windowHeadMessageId: earlierHistoryConfirmed ? baseMessages[0]?.id : undefined }) return useMemo(() => { diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index 8673baedefad..7d2abee36b3b 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -5,6 +5,7 @@ import remarkBreaks from 'remark-breaks' import rehypeRaw from 'rehype-raw' import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' import { cn } from '@/lib/utils' +import { remarkDisableDefinitions } from './remark-disable-definitions' import { compactCommentMarkdownComponents, createCompactCommentMarkdownComponents, @@ -61,6 +62,9 @@ const commentMarkdownFileUriUrlTransform: UrlTransform = (value, key, node) => { // remark-breaks converts single newlines to
, keeping backward compat // with existing plain-text comments that rely on newline formatting. const remarkPlugins = [remarkGfm, remarkBreaks] +// Every chat row renders one of these, so keep both lists module-level rather +// than allocating a fresh array per instance. +const proseRemarkPlugins = [...remarkPlugins, remarkDisableDefinitions] const GITHUB_REFERENCE_PATTERN = /(?:\b([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))?#([1-9][0-9]*)\b/g @@ -186,6 +190,9 @@ type CommentMarkdownProps = React.ComponentPropsWithoutRef<'div'> & { onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean expandImages?: boolean + /** Set when `content` is prose the user typed, not Markdown they authored, so + * a `[label]: target` line renders instead of silently disappearing. */ + disableLinkDefinitions?: boolean } // Why forwardRef + rest props: Radix's HoverCardTrigger asChild merges a ref @@ -201,6 +208,7 @@ const CommentMarkdown = React.memo( onLinkClick, allowFileUriLinks = false, expandImages = false, + disableLinkDefinitions = false, ...rest }, ref @@ -217,10 +225,10 @@ const CommentMarkdown = React.memo( ? createDocumentCommentMarkdownComponents(onLinkClick) : createCompactCommentMarkdownComponents(onLinkClick, expandImages) }, [expandImages, variant, onLinkClick]) - const activeRemarkPlugins = React.useMemo( - () => (githubRepo ? [...remarkPlugins, remarkGitHubReferences(githubRepo)] : remarkPlugins), - [githubRepo] - ) + const activeRemarkPlugins = React.useMemo(() => { + const base = disableLinkDefinitions ? proseRemarkPlugins : remarkPlugins + return githubRepo ? [...base, remarkGitHubReferences(githubRepo)] : base + }, [disableLinkDefinitions, githubRepo]) return (
+ ) + .replace(/<[^>]*>/g, '') + .trim() +} + +describe('disableLinkDefinitions', () => { + // Each of these renders to nothing at all without the opt-in — the user's + // whole line disappears, which is the defect, not a formatting nicety. + it.each([ + ['at the top level', '[Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + ['inside a bullet', '- [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + ['inside an ordered item', '1. [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + ['inside a blockquote', '> [Image #1]: /tmp/a.png', '[Image #1]: /tmp/a.png'], + // GFM adds a second definition construct that swallows prose the same way. + [ + 'as a footnote definition', + '[^note]: I meant this literally', + '[^note]: I meant this literally' + ], + // A link label may span lines, so the swallow is not a single-line shape. + [ + 'with a wrapped label', + '[Image #1\nstill mine]: /tmp/a.png', + '[Image #1\nstill mine]: /tmp/a.png' + ] + ])('keeps a definition-shaped line %s', (_name, typed, expected) => { + expect(rendered(typed, false)).toBe('') + expect(rendered(typed, true)).toBe(expected) + }) + + it('keeps the sibling content a swallowed definition took with it', () => { + const typed = '- [Image #1]: /tmp/a.png\n- second' + + expect(rendered(typed, false)).toBe('second') + expect(rendered(typed, true)).toBe('[Image #1]: /tmp/a.png\nsecond') + }) + + it.each([ + 'keep [Image #1] literal', + 'plain prose with a colon: like this', + 'see [the docs](https://example.com) here', + 'see [the docs][d] here', + '```\n[Image #1]: /tmp/a.png\n```', + '~~~\ncode\n```\n[Image #1]: /tmp/a.png\n~~~', + '| a | b |\n| - | - |\n| 1 | 2 |' + ])('leaves content the pipeline already rendered untouched', (typed) => { + // Why the second assertion: comparing the two prop values to each other is + // trivially true if the plugin ever stops attaching, so pin that these + // render something before trusting that they render the same thing. + expect(rendered(typed, true)).not.toBe('') + expect(rendered(typed, true)).toBe(rendered(typed, false)) + }) + + // The one intended difference: an authored definition stops being consumed, + // so its reference renders literally rather than as a resolved link. + it('renders an authored definition as the text it is', () => { + const typed = '[d]: https://example.com\n\nsee [the docs][d] here' + + expect(rendered(typed, false)).toBe('see the docs here') + expect(rendered(typed, true)).toBe('[d]: https://example.com\nsee [the docs][d] here') + }) +}) diff --git a/src/renderer/src/components/sidebar/remark-disable-definitions.ts b/src/renderer/src/components/sidebar/remark-disable-definitions.ts new file mode 100644 index 000000000000..b9fae1bf54da --- /dev/null +++ b/src/renderer/src/components/sidebar/remark-disable-definitions.ts @@ -0,0 +1,24 @@ +import type { Processor } from 'unified' + +/** + * Stop the parser from reading `[label]: /some/target` (and its GFM footnote + * sibling `[^label]: text`) as a definition. + * + * A definition renders to nothing at all, so on content that is prose rather + * than authored Markdown — a chat turn the user typed — such a line silently + * deletes itself. Escaping the bracket in the source text only reaches lines at + * the top level; a definition inside a list item or blockquote, or one whose + * label wraps across lines, is still swallowed. Turning the constructs off + * covers every container without rewriting a byte of what the user wrote. + * + * Inline links and the rest of GFM are untouched. Reference links necessarily + * render literally, because they resolve *against* the definitions this removes + * — which is the right reading for prose, where `[d]` is far more likely to be + * a bracket the user typed than a link they meant to define. + */ +export function remarkDisableDefinitions(this: Processor): undefined { + const data = this.data() + const extensions = (data.micromarkExtensions ??= []) + extensions.push({ disable: { null: ['definition', 'gfmFootnoteDefinition'] } }) + return undefined +} diff --git a/src/renderer/src/web/web-preload-api-agent-providers.test.ts b/src/renderer/src/web/web-preload-api-agent-providers.test.ts index 1dc55f984ff6..d17441514ba7 100644 --- a/src/renderer/src/web/web-preload-api-agent-providers.test.ts +++ b/src/renderer/src/web/web-preload-api-agent-providers.test.ts @@ -82,6 +82,9 @@ describe('web native chat preload API', () => { type: 'snapshot', messages: [message], hasMore: false, + // The host answered, so the adapter marks the answer as reported rather + // than leaving `hasMore` indistinguishable from its count inference. + hasMoreReported: false, lifecycle } ]) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 06622bbca93f..644ee2b56f11 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1348,6 +1348,12 @@ function createNativeChatApi(): NativeChatApi { type: 'snapshot', messages: result.messages, hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + // Why: the line above folds an inference into `hasMore`. Keep the + // host's own answer separately for callers that change what a + // message says rather than just a scroll affordance. + ...(typeof result.hasMore === 'boolean' + ? { hasMoreReported: result.hasMore } + : {}), ...(result.error ? { error: result.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -1356,6 +1362,9 @@ function createNativeChatApi(): NativeChatApi { type: 'snapshot', messages: result.messages, hasMore: result.hasMore ?? false, + ...(typeof result.hasMore === 'boolean' + ? { hasMoreReported: result.hasMore } + : {}), ...(result.error ? { error: result.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -1366,6 +1375,9 @@ function createNativeChatApi(): NativeChatApi { type: 'replacement', messages: result.messages, hasMore: result.hasMore ?? false, + ...(typeof result.hasMore === 'boolean' + ? { hasMoreReported: result.hasMore } + : {}), ...(lifecycle ? { lifecycle } : {}) } : { diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index aff2c6f71fe5..e2df1c475909 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from './native-chat-types' import { isImageSourceUserTurn, + nativeChatUserMessageImageEvidenceCount, + nativeChatUserMessageMatchText, + nativeChatUserTextMatchText, normalizeImageTranscriptMessages, normalizeNativeChatUserText, normalizedNativeChatUserMessageText, @@ -104,6 +107,21 @@ describe('normalizeImageTranscriptMessages', () => { expect(normalizedNativeChatUserMessageText(message)).toBe('look here') }) + it('keeps markers in no-image match keys without changing real image keys', () => { + expect(nativeChatUserTextMatchText(' keep [Image #1] literal ', false)).toBe( + 'keep [Image #1] literal' + ) + expect(nativeChatUserTextMatchText(' keep [Image #1] literal ', true)).toBe('keep literal') + + const literal = userText('literal', 'keep [Image #1] literal') + const attached = { + ...literal, + blocks: [{ type: 'image-ref' as const, path: '/tmp/a.png' }, ...literal.blocks] + } + expect(nativeChatUserMessageMatchText(literal)).toBe('keep [Image #1] literal') + expect(nativeChatUserMessageMatchText(attached)).toBe('keep literal') + }) + it('recognizes only sole-text image-source user turns', () => { const source = userText('source', '[Image: source: /tmp/a.png]') expect(isImageSourceUserTurn(source)).toBe(true) @@ -173,7 +191,7 @@ describe('normalizeImageTranscriptMessages', () => { }) it('leaves ordinary user text untouched', () => { - const message = userText('a', 'how about this') + const message = userText('a', 'keep [Image #1] as literal text') const messages = [message] const out = normalizeImageTranscriptMessages(messages) expect(out).toBe(messages) @@ -200,6 +218,121 @@ 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('keeps markers beyond the anchored run’s image count as the user’s own words', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('p1', '[Image #1] compare with the [Image #2] I mentioned earlier') + ]) + + expect(out).toHaveLength(1) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'compare with the [Image #2] I mentioned earlier' } + ]) + }) + + it('strips by document position, not by marker ordinal', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('p1', '[Image #2] came first but [Image #1] came second') + ]) + + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'came first but [Image #1] came second' } + ]) + }) + + it('strips a marker whose ordinal exceeds the run size when it is within the count', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('p1', '[Image #7] what is this?') + ]) + + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'what is this?' } + ]) + }) + + it('spends the image budget across text blocks in document order', () => { + const prompt: NativeChatMessage = { + ...userText('prompt', 'unused'), + blocks: [ + { type: 'text', text: '[Image #1] look at' }, + { type: 'image-ref', path: '/tmp/existing.png' }, + { type: 'text', text: 'not [Image #2] though' } + ] + } + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + prompt + ]) + + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'text', text: 'look at' }, + { type: 'image-ref', path: '/tmp/existing.png' }, + { type: 'text', text: 'not [Image #2] though' } + ]) + }) + + it('keeps the surplus marker on a two-image run that mentions a third', () => { + const out = normalizeImageTranscriptMessages([ + userText('s1', '[Image: source: /tmp/a.png]'), + userText('s2', '[Image: source: /tmp/b.png]'), + userText('p1', '[Image #1] [Image #2] versus the [Image #3] from before') + ]) + + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'text', text: 'versus the [Image #3] from before' } + ]) + }) + it('leaves assistant messages untouched', () => { const assistant: NativeChatMessage = { id: 'a', @@ -211,3 +344,131 @@ describe('normalizeImageTranscriptMessages', () => { expect(normalizeImageTranscriptMessages([assistant])).toEqual([assistant]) }) }) + +// The read window is a hard count slice (`boundNativeChatWindow`), so as a session +// ages past the limit its head cuts straight through `[Image: source: …]` runs. +// Without the paging signal the fold reads a trimmed run as "the user typed these +// markers" and leaks agent-internal markup into a message they already sent. +describe('normalizeImageTranscriptMessages with earlier history above the window', () => { + it('strips markers from a head prompt whose whole source run was trimmed', () => { + const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], { + windowHeadMessageId: 'b' + }) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) + }) + + it('spends an unbounded budget on a run that starts at the window head', () => { + const out = normalizeImageTranscriptMessages( + [ + userText('a', '[Image: source: /tmp/b.png]'), + userText('b', '[Image #1] [Image #2] compare these') + ], + { windowHeadMessageId: 'a' } + ) + expect(out).toHaveLength(1) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'text', text: 'compare these' } + ]) + }) + + // Why: only the head is ambiguous. Anywhere else the source run would be visible + // above the prompt, so its absence really is proof the markers are user text — + // this is the STA-4363 case and it must survive the paging signal. + it('keeps literal markers verbatim when the turn is not at the window head', () => { + const out = normalizeImageTranscriptMessages( + [ + { ...userText('a', 'hello'), role: 'assistant' as const }, + userText('b', 'use [Image #1] as a label') + ], + { windowHeadMessageId: 'a' } + ) + expect(out[1]!.blocks).toEqual([{ type: 'text', text: 'use [Image #1] as a label' }]) + }) + + it('keeps a head prompt verbatim when no older history exists', () => { + const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], {}) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) + }) + + it('still bounds the strip by image count for a run below the head', () => { + const out = normalizeImageTranscriptMessages( + [ + { ...userText('z', 'earlier'), role: 'assistant' as const }, + userText('a', '[Image: source: /tmp/b.png]'), + userText('b', '[Image #1] compare with the [Image #2] I mentioned') + ], + { windowHeadMessageId: 'z' } + ) + expect(out[1]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'text', text: 'compare with the [Image #2] I mentioned' } + ]) + }) + + // Why: the head is named by id, never by position. Callers pass lists that were + // merged across sources and re-sorted, so index 0 is whichever row sorts first: + // an unpaginated scrape/hook row, or — for a transcript with no timestamps — + // the lexicographically smallest id. Stripping either would delete user text. + it('ignores a row that merely sorts first but is not the named head', () => { + const out = normalizeImageTranscriptMessages( + [ + { ...userText('aaa-scrape', 'typed [Image #1] literally'), source: 'scrape' as const }, + userText('zzz-head', '[Image #1] what do you see') + ], + { windowHeadMessageId: 'zzz-head' } + ) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'typed [Image #1] literally' }]) + expect(out[1]!.blocks).toEqual([{ type: 'text', text: 'what do you see' }]) + }) + + // Why: the remote read casts host frames without validating per-message fields, + // so a row can reach the fold with no id. Comparing ids alone let that row match + // an absent option and strip a marker the user typed, with no window head at all. + it('never treats an id-less row as the head when no head is named', () => { + // Cast because the type says `id: string`; only the unvalidated remote read + // can produce this, which is exactly why the guard has to exist at runtime. + const idLess = { + ...userText('x', 'what does [Image #1] mean?'), + id: undefined + } as unknown as NativeChatMessage + const out = normalizeImageTranscriptMessages([idLess], {}) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'what does [Image #1] mean?' }]) + }) + + it('does nothing when the named head is not in the list', () => { + const out = normalizeImageTranscriptMessages([userText('b', '[Image #1] what do you see')], { + windowHeadMessageId: 'some-other-row' + }) + expect(out[0]!.blocks).toEqual([{ type: 'text', text: '[Image #1] what do you see' }]) + }) +}) + +// The strip is bounded to the size of the run it anchors to, so a folded image +// row can now KEEP markers the user typed. Counting those as photos would let a +// row rendering one image vouch for two sends and retire the wrong one. +describe('nativeChatUserMessageImageEvidenceCount', () => { + it('counts the photos a row shows, not the markers it kept', () => { + const [row] = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/x.png]'), + userText('b', '[Image #1] look [Image #2] [Image #3]') + ]) + // The fold spent its one-image budget and left the two surplus markers. + expect(row!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/x.png' }, + { type: 'text', text: 'look [Image #2] [Image #3]' } + ]) + expect(nativeChatUserMessageImageEvidenceCount(row!)).toBe(1) + }) + + it('still reads markers as evidence when the turn shows no image at all', () => { + // A host that echoes an image send as bare `[Image #n]` with no source turn + // leaves the markers as the only proof the photos landed. + expect(nativeChatUserMessageImageEvidenceCount(userText('c', '[Image #1] [Image #2]'))).toBe(2) + }) + + it('ignores an assistant turn', () => { + const assistantTurn = { ...userText('d', '[Image #1]'), role: 'assistant' as const } + expect(nativeChatUserMessageImageEvidenceCount(assistantTurn)).toBe(0) + }) +}) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index df52ec8c12ce..73bba2867f8f 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -1,4 +1,9 @@ -import { isTextBlock, type NativeChatBlock, type NativeChatMessage } from './native-chat-types' +import { + isImageRefBlock, + isTextBlock, + type NativeChatBlock, + type NativeChatMessage +} from './native-chat-types' const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/ const IMAGE_PROMPT_MARKER = /\[Image #\d+\]/ @@ -22,41 +27,86 @@ export function isImageSourceUserTurn(message: NativeChatMessage): boolean { return message.role === 'user' && imageSourcePathFromText(soleText(message) ?? '') !== null } -export function stripImagePromptMarker(text: string): string { - const stripped = text.replace(IMAGE_PROMPT_MARKERS, '') +function countMarkers(text: string): number { + return text.match(IMAGE_PROMPT_MARKERS)?.length ?? 0 +} + +/** Strips the first `limit` markers in document order; the rest are the user's + * own words, so they stay verbatim. */ +function stripImagePromptMarkersUpTo(text: string, limit: number): string { + if (limit <= 0) { + return text + } + let used = 0 + const stripped = text.replace(IMAGE_PROMPT_MARKERS, (match) => { + if (used >= limit) { + return match + } + used += 1 + return '' + }) if (stripped === text) { return text } let result = IMAGE_PROMPT_MARKER_AT_START.test(text) ? stripped.replace(HORIZONTAL_WHITESPACE_START, '') : stripped - if (IMAGE_PROMPT_MARKER_AT_END.test(text)) { + // Why: a trailing marker left in place still owns the whitespace before it. + if (used === countMarkers(text) && IMAGE_PROMPT_MARKER_AT_END.test(text)) { result = result.replace(HORIZONTAL_WHITESPACE_END, '') } return result } +export function stripImagePromptMarker(text: string): string { + return stripImagePromptMarkersUpTo(text, Number.POSITIVE_INFINITY) +} + export function normalizeNativeChatUserText(text: string): string { return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ') } +function normalizeLiteralNativeChatUserText(text: string): string { + return text.trim().replace(/\s+/g, ' ') +} + +export function nativeChatUserTextMatchText(text: string, hasImages: boolean): string { + return hasImages ? normalizeNativeChatUserText(text) : normalizeLiteralNativeChatUserText(text) +} + +function joinedUserText(message: NativeChatMessage): string { + return message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join(' ') +} + export function normalizedNativeChatUserMessageText(message: NativeChatMessage): string | null { if (message.role !== 'user') { return null } - const normalized = normalizeNativeChatUserText( - message.blocks - .filter(isTextBlock) - .map((block) => block.text) - .join(' ') - ) - return normalized || null + return normalizeNativeChatUserText(joinedUserText(message)) || null } -function stripImagePromptMarkersFromTextBlocks( - blocks: readonly NativeChatBlock[] +/** The text an optimistic echo matches a landed turn on. Keeps literal marker + * text keyable so the pending path and the render path agree about which + * `[Image #n]` runs are the user's own words. */ +export function nativeChatUserMessageMatchText(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const joined = joinedUserText(message) + return nativeChatUserTextMatchText(joined, message.blocks.some(isImageRefBlock)) || null +} + +/** `limit` is the number of images the run actually carried. Only that many + * markers can be placeholders; the rest are text the user typed. */ +export function stripImagePromptMarkersFromTextBlocks( + blocks: readonly NativeChatBlock[], + limit: number ): NativeChatBlock[] { let sawText = false + let remaining = limit let next: NativeChatBlock[] | null = null for (let index = 0; index < blocks.length; index += 1) { const block = blocks[index]! @@ -66,7 +116,8 @@ function stripImagePromptMarkersFromTextBlocks( } const isFirstText = !sawText sawText = true - const text = stripImagePromptMarker(block.text) + const text = stripImagePromptMarkersUpTo(block.text, remaining) + remaining -= Math.min(remaining, countMarkers(block.text)) if (!text.trim() && (text !== block.text || isFirstText)) { next ??= blocks.slice(0, index) continue @@ -81,15 +132,87 @@ function stripImagePromptMarkersFromTextBlocks( return next ?? (blocks as NativeChatBlock[]) } +function removeEmptyFirstTextBlock(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { + const index = blocks.findIndex(isTextBlock) + const block = blocks[index] + if (index === -1 || !block || !isTextBlock(block) || block.text.trim()) { + return blocks as NativeChatBlock[] + } + return [...blocks.slice(0, index), ...blocks.slice(index + 1)] +} + export function hasImagePromptMarker(message: NativeChatMessage): boolean { return message.blocks.some((block) => isTextBlock(block) && IMAGE_PROMPT_MARKER.test(block.text)) } +/** Markers carried by a turn. One marker vouches for one image, so a send of N + * images is only echoed by a turn bearing N of them. */ +export function countImagePromptMarkers(message: NativeChatMessage): number { + return message.blocks.reduce( + (count, block) => + count + (isTextBlock(block) ? (block.text.match(IMAGE_PROMPT_MARKERS)?.length ?? 0) : 0), + 0 + ) +} + +/** + * How many image-carrying sends a row can vouch for. + * + * Structure wins wherever it exists; markers are the fallback for a host that + * echoes an image send as bare `[Image #n]` with no `[Image: source: …]` turn, + * never a top-up on a row already showing its photos. Taking the larger of the + * two used to be equivalent, because every marker was stripped from every turn — + * but the strip is now bounded to the size of the run it anchors to, so a row can + * legitimately keep markers the user typed. Counting those as photos let a row + * rendering one image retire a send that carried two, and `prunePendingSends` + * then dropped that send from the bounded cache with the user's photos in it. + * + * Under-counting only costs a duplicate bubble; over-counting loses the photo. + * That is the same trade `nativeChatUserTextRow` already makes on the glue arm. + */ +export function nativeChatUserMessageImageEvidenceCount(message: NativeChatMessage): number { + if (message.role !== 'user') { + return 0 + } + const imageRefCount = message.blocks.filter(isImageRefBlock).length + return imageRefCount > 0 ? imageRefCount : countImagePromptMarkers(message) +} + +export type NormalizeImageTranscriptOptions = { + /** + * Id of the oldest row of the paginated transcript window, and only when older + * history exists above it. That row — alone — can sit *inside* an image run + * whose `[Image: source: …]` turns were trimmed away by the hard count slice. + * Its markers are then un-anchorable evidence rather than proof the user typed + * them, and stripping is the safer read: leaving them shows agent-internal + * markup in a message the user already sent, and the run's real image count is + * no longer knowable, so the count bound cannot apply either. + * + * Nowhere else is ambiguous — a marker turn further down would have its source + * run visible above it, so its absence there is real evidence. + * + * Deliberately an id, not a positional flag. Callers hand this function lists + * that have already been merged across sources and re-sorted, where index 0 is + * whichever row sorts first — a scrape or hook row that was never paginated, or + * for a transcript that carries no timestamps (some agents), the row with the + * lexicographically smallest id. Neither is the window head. + */ + windowHeadMessageId?: string +} + /** 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[] + messages: readonly NativeChatMessage[], + options?: NormalizeImageTranscriptOptions ): NativeChatMessage[] { + const windowHeadMessageId = options?.windowHeadMessageId + // Why: absent option means there is no window head at all, so the rule must not + // fire. Comparing ids alone would let a row whose own id is missing — the remote + // read casts host frames without validating per-message fields — satisfy + // `message.id === undefined` and strip a marker the user typed. + const isWindowHead = (candidate: NativeChatMessage): boolean => + windowHeadMessageId !== undefined && candidate.id === windowHeadMessageId let normalized: NativeChatMessage[] | null = null for (let index = 0; index < messages.length; index += 1) { const message = messages[index]! @@ -117,11 +240,14 @@ export function normalizeImageTranscriptMessages( prompt.source === message.source && hasImagePromptMarker(prompt) ) { + // Why: a run touching the window head may have lost earlier source turns, + // so the visible image count is a floor, not the run's real size. + const limit = isWindowHead(message) ? Number.POSITIVE_INFINITY : imagePaths.length normalized.push({ ...prompt, blocks: [ ...imagePaths.map((path) => ({ type: 'image-ref' as const, path })), - ...stripImagePromptMarkersFromTextBlocks(prompt.blocks) + ...stripImagePromptMarkersFromTextBlocks(prompt.blocks, limit) ] }) index = nextIndex @@ -133,7 +259,12 @@ export function normalizeImageTranscriptMessages( }) continue } - const blocks = stripImagePromptMarkersFromTextBlocks(message.blocks) + // Why: the whole source run above the head can be trimmed away, leaving the + // prompt alone at index 0 with markers no anchor can vouch for. + const blocks = + isWindowHead(message) && hasImagePromptMarker(message) + ? stripImagePromptMarkersFromTextBlocks(message.blocks, Number.POSITIVE_INFINITY) + : removeEmptyFirstTextBlock(message.blocks) if (blocks === message.blocks) { normalized?.push(message) } else {