Repository navigation
fix(native-chat): anchor literal image-marker stripping to a real image send (STA-4363) - #14939
brennanb2025 wants to merge 45 commits into
Conversation
(cherry picked from commit fab320e)
The render path became provenance-aware for `[Image #n]` markers, but the pending-key path kept stripping them unconditionally, so the two disagreed: a standalone marker-only turn rendered its literal text while its content key computed to empty, leaving the optimistic echo unmatchable and the row duplicated on desktop and mobile. Give both sides the same rule via `nativeChatUserMessageMatchText` — a marker run that stripping would erase, on a turn carrying no image, is the user's own text and keys verbatim. Mobile's preview reconciliation also accepted a single marker as proof for any number of pending images, so an incomplete multi-image echo could clear or rebind every preview; it now requires one marker per image. (cherry picked from commit 7803de9)
(cherry picked from commit 94f1cc6)
(cherry picked from commit c51e8e6)
(cherry picked from commit 794e07c)
(cherry picked from commit 9b9aa6f)
(cherry picked from commit d49d6ad)
(cherry picked from commit bf74570)
…e run Re-home the landed literal-text counts onto the draft reconciler after the #14665 revert removed the module they lived in, and cover the anchoring boundary: a marker the user typed must not absorb a later send's image.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (4)
Included review availability: Your plan includes up to 10 reviews per rolling hour; 0 remain after this review. 📝 WalkthroughWalkthroughNative chat now distinguishes literal 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
⚔️ Resolve merge conflicts 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Anchoring decided which turn gets stripped but not how many markers may go, so a turn attaching one image and mentioning a second marker in prose still lost the user's literal text. Strip at most one marker per image in the anchored source run, in document order across the turn's text blocks; markers past the count stay verbatim. Position, not ordinal, decides which markers are placeholders, so the rule holds whether the producing CLI numbers markers per-turn or per-session.
… mouth A preserved literal marker can still vanish one layer later: CommonMark reads `[Image #1]: /tmp/a.png` as a link reference definition, so a user turn that is only that line renders as an empty bubble. Escape the line-leading bracket on user turns before they reach the Markdown renderer. Assistant prose is untouched, and the escape is output-neutral everywhere else, including inside fenced code.
Escaping a line-leading bracket only reached definitions at the top level. Measured against the live renderer, a definition-shaped marker line still rendered as an empty bubble inside a bullet, an ordered item or a blockquote, and whenever the label wrapped across lines — and the escape injected a visible backslash into a fenced block whose fence characters differed from an inner fence line. Turn the construct off for user rows instead, so every container is covered and the source text is never rewritten. Inline links, reference links, GFM and fenced code all render byte-identically.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
src/shared/native-chat-image-transcript-markers.ts (1)
150-156: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReuse
countMarkersinstead of repeating the match expression.
countImagePromptMarkersrepeats thematch(IMAGE_PROMPT_MARKERS)?.length ?? 0expression already defined ascountMarkersat Line 30. One helper keeps the two counts in step if the regex or the counting rule changes.♻️ Proposed refactor
export function countImagePromptMarkers(message: NativeChatMessage): number { return message.blocks.reduce( - (count, block) => - count + (isTextBlock(block) ? (block.text.match(IMAGE_PROMPT_MARKERS)?.length ?? 0) : 0), + (count, block) => count + (isTextBlock(block) ? countMarkers(block.text) : 0), 0 ) }src/shared/native-chat-image-transcript-markers.test.ts (1)
262-273: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider adding a case for mid-text stripping whitespace.
The suite covers leading, trailing, and surplus markers. It does not cover a marker between two words, where
stripImagePromptMarkersUpToremoves the marker but leaves both surrounding spaces. A case such as'look [Image#1] here'pins the exact rendered spacing and prevents a silent change later.src/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsx (1)
45-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winInterpolate the case into the
it.eachtitle.All four cases share one title, so a failure report does not say which input failed. Vitest substitutes
%swith the case value.♻️ Proposed refactor
- ])('shows a definition-shaped user line on screen', (typed) => { + ])('shows a definition-shaped user line on screen: %j', (typed) => {
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f72447e7-4a8e-48ca-bfa4-1d439a56e861
📒 Files selected for processing (22)
mobile/src/session/mobile-native-chat-draft-reconcile.test.tsmobile/src/session/mobile-native-chat-draft-reconcile.tsmobile/src/session/mobile-native-chat-image-transcript-markers.tsmobile/src/session/mobile-native-chat-literal-marker-echo.test.tsmobile/src/session/mobile-native-chat-pending-echo.tsmobile/src/session/mobile-native-chat-render-data.test.tsmobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.tsmobile/src/session/use-mobile-native-chat-drafts.test.tsmobile/src/session/use-mobile-native-chat-drafts.tsmobile/src/session/use-mobile-native-chat-launch-draft-seed.tsmobile/src/session/use-mobile-native-chat-message-send.tssrc/renderer/src/components/native-chat/NativeChatMessageList.tsxsrc/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.tssrc/renderer/src/components/native-chat/native-chat-pending-occurrence.tssrc/renderer/src/components/native-chat/native-chat-pending.test.tssrc/renderer/src/components/native-chat/native-chat-session-assembler.test.tssrc/renderer/src/components/native-chat/native-chat-user-turn-render.test.tsxsrc/renderer/src/components/sidebar/CommentMarkdown.tsxsrc/renderer/src/components/sidebar/remark-disable-definitions.test.tsxsrc/renderer/src/components/sidebar/remark-disable-definitions.tssrc/shared/native-chat-image-transcript-markers.test.tssrc/shared/native-chat-image-transcript-markers.ts
Included review availability: Your plan includes up to 10 reviews per rolling hour; 1 remains after this review.
Review pass — visual proof + one fix pushedRan on an isolated dev instance (own profile, own CDP + renderer ports, identity re-verified as A literal
|
| user typed | before this PR | with the regex escape | now |
|---|---|---|---|
[Image #1]: /tmp/a.png |
(empty) | renders | renders |
- [Image #1]: /tmp/a.png |
(empty) | (still empty) | renders |
1. [Image #1]: /tmp/a.png |
(empty) | (still empty) | renders |
> [Image #1]: /tmp/a.png |
(empty) | (still empty) | renders |
[Image #1⏎still mine]: /tmp/a.png |
(empty) | (still empty) | renders |
- [Image #1]: /tmp/a.png⏎- second |
second |
second |
both items |
The escape also introduced a defect: for a ~~~-fenced block containing a ``` line, it emitted a
visible \[ into the code block, because the fence tracker flips on any fence marker regardless of type.
Root cause of the gap: escaping a line-leading [ can only see column 0 of the top level. A link reference
definition is a block construct — it is equally valid inside a list item or a blockquote, and its label
may wrap across lines. So 4fa5a62 stops parsing the construct at all for user rows
(disableLinkDefinitions, opt-in on CommentMarkdown, set only by the native-chat user row) and deletes
the string-rewriting pass. Inline links, reference links, GFM tables and fenced code all render
byte-identically; the single intended difference is that an authored [d]: url line in a user turn now
shows as text instead of vanishing, which is the desired behaviour for prose.
Non-vacuity
Reverting production only and keeping every test: 29 tests go RED on behaviour (17 desktop, 12 mobile).
Reported separately, and not counted above: 13 further desktop failures that are missing-export/compile
errors rather than behavioural evidence (12 from a test file whose module was deleted, 1 … is not a function). One desktop timeout in src/main/ipc/native-chat.test.ts was load-induced — that file is not
touched by this PR and passes at HEAD.
The two new test files were mutation-checked: removing disableLinkDefinitions from the user row turns
4 of 5 wiring tests RED.
The deferred nativeChatUserMessageImageEvidenceCount item — re-derived, keep it
Making the count image-derived turns 4 existing tests RED, not the 2 recorded earlier:
- desktop
drops an attachment pending send once a prefixed transcript prompt advances(+ trailing-marker twin) - mobile
reconciles an image-only unconfirmed send against the next user turn (no false warning) - mobile
hands a captioned preview to a marker-only compatibility echo
They encode the paginated-window case: the read window can show the prompt turn while its
[Image: source: …] turn is still unloaded, so markers are the only evidence a real image send landed.
Dropping the marker term trades an unreachable collision for a permanent duplicate bubble. Keep it.
Not proven
- No BEFORE screenshot. The dev instance was SIGTERM'd by something outside this session twice mid-run, and
swapping the render path under a live dev server left the workbench's lazy chunk unloadable. The BEFORE
column in the table above is measured against the live renderer, not screenshotted. - A genuine image send (markers still stripped, no over-correction) and the one-image-plus-second-marker
case are covered by tests, not by a screenshot — Orca's attach path is clipboard paste, which I could not
drive without synthesizing a clipboard event.
…cope Every chat row renders through one of the two lists, so build both once instead of allocating a fresh array per CommentMarkdown instance.
Approach + readiness pass (re-derived)Approach checkRendering user prose through a CommonMark pipeline. Where CommonMark's reading of user text diverges from user intent, the correct repair is a custom remark plugin, not a rewrite of the source string — the same position Disabling the Attachment placeholder markers. The Optimistic echo reconciliation. The materially simpler design is to carry a Readiness checklist
Merged-tree check: Verdict: no P0 or P1. Ready to merge. |
GFM adds a second definition construct. `[^note]: I meant this literally` was still rendering an empty bubble on a user row — the same total content loss, one construct over — so disable `gfmFootnoteDefinition` alongside `definition`. The comment claimed reference links survive. Measured, they do not: they resolve *against* the definitions this removes, so `[d]`, `[d][]` and `![alt][d]` now render literally. That is the right reading for prose, but the comment said the opposite. Also pin that the untouched-content cases render something, since comparing the two prop values to each other is trivially true if the plugin ever stops attaching.
Correcting my earlier verdict: three P1 regressions, verifiedMy previous comment said "no P0 or P1, ready to merge." That was wrong. A later review round surfaced P1 — the read window trimming a source turn leaks raw markers into an already-read message
The user's own already-read message visibly gains agent-internal markup as it scrolls into history. The obvious fix is unsound — I checked. "Spend an unbounded budget when the marker turn is at window P1 — glue matching is now blind to any transcript row carrying an image
Base produced P1 — a marker-only echo with a bound local preview renders a literal
|
…arkers The marker fold inferred "no visible `[Image: source: …]` run before this prompt ⇒ the `[Image #N]` markers are the user's own words". That is right for STA-4363 and wrong wherever the source run exists but cannot be seen, which regressed three paths. Read window. `boundNativeChatWindow` is a hard count slice re-applied on every append, so an aging session's head cuts through image runs and an already-read turn gained a literal `[Image #1]`. Only the head is ambiguous — anywhere else the run would be visible above the prompt — and the paging signal that resolves it already exists as `hasMore`, it was simply never plumbed. The fold now takes `hasEarlierHistory` and, at index 0 only, spends an unbounded marker budget: a partial run's visible image count is a floor, not its real size. Untruncated windows keep the ticket's rule, so a first-message literal marker still stands. Glue. Both user-text matchers returned null for any row carrying an image, so a folded image turn was invisible to the lost-Enter glue path and both optimistic bubbles — one a duplicate photo — persisted until pane close. They now yield the row's text with an image-count budget, which keeps the guard that a text-only row can never retire a send whose photo has not landed. Mobile render. A marker-only host echo captioned the user's own photo with a literal `[Image #1]`. A bound local preview is positive proof the turn echoes an image sent from this device, so its markers are placeholders; stripping is bounded by the images the turn actually shows.
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 832cb300-3f2c-4bf8-976b-d5a05805c991
📒 Files selected for processing (12)
mobile/src/session/MobileNativeChatOverlay.tsxmobile/src/session/mobile-native-chat-image-transcript-markers.tsmobile/src/session/mobile-native-chat-render-data.test.tsmobile/src/session/mobile-native-chat-render-data.tssrc/renderer/src/components/native-chat/native-chat-live-message-preparation.tssrc/renderer/src/components/native-chat/native-chat-pending-occurrence.tssrc/renderer/src/components/native-chat/native-chat-pending.test.tssrc/renderer/src/components/native-chat/native-chat-session-assembler.tssrc/renderer/src/components/native-chat/use-native-chat-assembled-messages.tssrc/renderer/src/components/native-chat/use-native-chat-live-session.tssrc/shared/native-chat-image-transcript-markers.test.tssrc/shared/native-chat-image-transcript-markers.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- mobile/src/session/mobile-native-chat-image-transcript-markers.ts
- mobile/src/session/mobile-native-chat-render-data.test.ts
Included review availability: Your plan includes up to 10 reviews per rolling hour; 0 remain after this review.
…path The fold's own tests pass the option directly, so a plumbing break anywhere between the session hook and the fold would leave them green while the fix did nothing in the app. Covers the mixed-source reassembly branch, which re-assembles instead of returning the first pass and so needs the signal handed on separately.
Review found the image budget was read from nativeChatUserMessageImageEvidenceCount,
which counts literal `[Image #n]` text as image evidence. That is correct for
content keys, but as a glue budget it let a row showing no photo retire a send
that carried one: pendings {'a' + photo} and {'b [Image #1]'} against the
text-only row 'a b [Image #1]' scored a budget of 1 and consumed both, erasing
the user's preview before the photo landed.
The budget now counts image-ref blocks only. Under-counting costs at most a
lingering duplicate bubble; over-counting loses the photo.
…udget Self-review caught the render-time strip re-breaking the very ticket it sits in. The fold already spends one marker per real `[Image: source: …]` turn and deliberately keeps any surplus as the user's own words. Charging the bound preview for those same images spent the budget twice: a turn folded to image-ref + "compare with the [Image #2] I mentioned" rendered as "compare with the I mentioned", deleting text the user typed. The budget now counts only path-less image blocks — the previews appended for a marker-only echo, which no source turn ever vouched for.
Self-review caught a regression in the round-1 glue change. Glue matches a
LEADING run of pendings, so admitting image-carrying sends into the candidate
list put them in that prefix. A photo-less transcript row can never consume an
image send, so an unrelated queued photo stranded every text echo behind it:
pendings {'photo caption'+photo, 'a', 'b'} against the row 'a b' retired
nothing, where before the change it retired both text echoes.
A row now only considers image sends when it has image evidence of its own.
The budget check still bounds how many it can then own.
The three P1 regressions are fixed, plus three more this round found in the fixes themselvesI reproduced all three P1s from the earlier review independently before changing anything, then fixed all Reproduced first (measured at
|
| case | HEAD then | base bf6dc6f |
|---|---|---|
| source turn trimmed out of the window | "[Image #1] what do you see" |
"what do you see" |
| 2-image run, one source trimmed | "[Image #2] compare these" |
"compare these" |
| glue matchers on a folded image row | matching [], advanced [], represented [] |
["look at this please"], both represented |
boundNativeChatWindow confirmed a hard count slice: 302 messages in, 300 out, head at index 2.
P1-1 — window trim. The signal that resolves it already existed
The earlier review concluded the ambiguity "needs out-of-window information which
normalizeImageTranscriptMessages does not receive". That is true of the function but not of the app: the
transcript read already tracks hasMore, it was simply never plumbed down. It is now a hasEarlierHistory
option that defaults to the previous behaviour.
The key narrowing is that only window index 0 is ambiguous. A marker turn anywhere else would have its
source run visible above it, so its absence there really is evidence the user typed the markers. So at index
0 only — and only when older history exists — the fold spends an unbounded marker budget, because a partial
run's visible image count is a floor rather than its real size.
This is strictly better than both sides of the old trade. Untruncated window (the ticket's own scenario, a
short session) keeps markers verbatim, so STA-4363 stays fixed; a long session no longer leaks
[Image #1] into a message the user already sent. The residue is narrow: a literal marker sitting exactly at
the head of a truncated window is still stripped, which is base behaviour, and only while it occupies that
one slot.
Plumbed through use-native-chat-live-session → use-native-chat-assembled-messages →
native-chat-live-message-preparation → native-chat-session-assembler, and on mobile through
MobileNativeChatOverlay → mobile-native-chat-render-data. hasEarlierHistory is only applied to the
transcript source — hasMore describes transcript pagination, not scrollback.
P1-2 — glue
Both user-text matchers returned null for any row carrying image blocks, so a folded image turn was
invisible to the lost-Enter path and both optimistic bubbles — one a duplicate photo — persisted until pane
close. They now return {text, imageCount} rows, and the matcher admits image-carrying sends bounded by the
row's image evidence, so a text-only row still can never retire a send whose photo has not landed.
P1-3 — mobile render. This one is a bug, not a product call
I disagree with the framing that this needs an explicit product decision. findLandedImagePreviewEchoes
binds a local preview only to a turn that provably replaced an optimistic image send — that is precisely
the out-of-band evidence the fold was missing. A bound preview proves the markers are placeholders, so
captioning the user's own photo with a literal [Image #1] is never the right render. The strip is bounded
by the images the turn actually shows.
Then the review loop found three regressions in those fixes
- Marker text inflated the image budget. The row budget read
nativeChatUserMessageImageEvidenceCount, which counts literal[Image #n]text as image evidence —
correct for content keys, wrong as a budget. Pendings{'a' + photo}and{'b [Image #1]'}against the
text-only row'a b [Image #1]'scored a budget of 1 and consumed both, erasing the user's photo
preview before it landed. Budget now counts image-ref blocks only. Under-counting costs a lingering
duplicate; over-counting loses the photo. - The preview strip double-spent the fold's budget. The fold spends one marker per real source turn and
deliberately keeps any surplus as the user's own words. Charging the preview for those same images spent
it twice: a turn folded to image-ref +"compare with the [Image #2] I mentioned"rendered as
"compare with the I mentioned"— deleting text the user typed, i.e. re-breaking this very ticket at the
render layer. Budget now counts path-less blocks only. - Image sends blocked the glue prefix. Glue matches a leading run, so admitting image sends into the
candidate list let an unrelated queued photo strand every text echo behind it:{'photo caption'+photo, 'a', 'b'}against the row'a b'retired nothing where base retired both. Theopenfilter is now
row-aware.
Evidence
Every fix has a test that goes RED when only the production change is reverted (signatures kept, so no
failure is a compile artifact): 9 RED across the six fixes — 2 window-trim, 2 glue, 2 preview-strip,
2 end-to-end plumbing, 1 budget, 1 prefix. A further 5 tests are guards that stay green both ways by design;
they pin that the fixes did not over-strip or over-loosen, and are called out as such rather than counted as
evidence.
The plumbing tests matter on their own: the fold's unit tests pass the option directly, so a break anywhere
between the session hook and the fold would have left them green while the fix did nothing in the app. They
cover the mixed-source reassembly branch, which re-assembles rather than returning the first pass and so
needs the signal handed on separately.
Desktop native-chat 975 pass; full mobile suite 449 files / 3545 pass, 0 failures; both typechecks clean;
oxlint clean and verified live by injecting a violation. None of the ~45 tests encoding prior incidents
turned red at any point. Four failures in the full desktop suite (relay/subprocess, orca-runtime
worktree-scan, two WorktreeCard suites) are pre-existing — I confirmed one still fails on 4cd957976e
with none of this work applied — and none of them import any changed module.
Wire compatibility
No new field, no new opcode, and no change to what the host publishes — hasMore is already negotiated and
this only changes how the client renders data it already receives. Both desktop and mobile normalise it with
a ?? messages.length >= limit fallback, so hasEarlierHistory is a real boolean even against an old host,
degrading to the length-based inference rather than to undefined.
Visual proof — desktop native chat, real dev instances on both commitsTwo isolated Orca dev instances, same synthetic Claude transcript, same steps. The only difference is the commit each instance was launched from:
The fixture. A 311-record Claude JSONL transcript. Chronologically: 10 warm-up turns, then a The fixture also carries a second user turn that literally types Before —
|
A base-vs-head differential over eight glue shapes found three more defects. GAP MATCHING (a regression my own previous commit introduced, and one the PR already had in another form). Skipping a send the row cannot afford let glue match a NON-CONTIGUOUS subsequence: sends 'fix', 'the'+photo, 'bug' against a row reading 'fix bug' retired 'fix' and 'bug' even though a third send landed between them, so a bubble vanished while its send was still in flight. Base never did this. Every unretired send is a candidate again, in send order, and the budget rejects unaffordable groups instead of stepping over them. MARKER-ONLY HOST ECHOES. 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 matched nothing and left both optimistic bubbles — one a duplicate photo — on screen forever. A row now carries a second identity for that case, tried only after the literal reading fails, so a marker the user actually typed still matches verbatim first. Differential vs bf6dc6f now agrees on seven of eight shapes. The eighth is a deliberate improvement: base retired a photo-carrying send against a row showing no photo, erasing the preview before it landed.
Heads up: this PR is CONFLICTING against
|
Both mobile hops cast the wire payload without validating per-field types, so a host sending a non-boolean `hasMore` decided paging: `result.hasMore ?? …` yields a truthy string unchanged, and `applied.hasMore != null` accepts it. The desktop runtime parser already rejects this; mobile is the half that never got the guard. That mattered more once the marker fold started reading this signal, because the fold changes what a message says. The fold itself is already safe — it gates on an explicit `=== true` — so this closes the remaining exposure: the load-earlier affordance and the declared `hasMore: boolean` contract. A non-boolean now reads as "the host did not answer", which falls through to the count inference exactly as an older host does.
…erence Gating the marker fold on the reported answer was defeated on the first snapshot frame. Both remote adapters build that frame as `hasMore: frame.hasMore ?? messages.length >= limit`, so an inference is baked in before the hook sees it and `hasMore === true` cannot say who decided. Only the first snapshot is affected — the later-snapshot and replacement branches use `?? false` — but that is the frame that lands on first paint. The effect was the flip this gate was meant to prevent, in the unsafe direction: against a host predating the field the read seed correctly leaves the fold off, then the snapshot turns it on, and a marker the user typed disappears a beat after the pane renders. Frames now carry the host's answer separately from the value the affordance uses. Every host-derived construction sets it — this process computes it, so it always reports; the adapters pass it through only when the host actually sent a boolean. Absence therefore means "unanswered" on every path, and the fold reads that instead. Additive optional field on an IPC payload and on the runtime frame, so an older host simply omits it and the fold stays off, which is what base did.
…antees The comment claimed the flag is true only when the host said so. A replay that invalidates the cursor synthesizes it too, and that is correct — a bounded window which dropped its oldest row really does have history behind it. What the flag actually excludes is a count standing in for knowledge, which is the distinction the marker fold depends on.
Review loop closed — and it ends on a blocker. Please do not merge yet.The three filed P1s are fixed. The five review rounds that followed found eleven further defects, seven of BLOCKER — one transcript row retires two sends, and the user's photo is lost
The second is exactly the key a photo-carrying send owns. So a row that renders no photo retires a send A sequence a user can actually produce: type
A 65,016-case differential found 108 cases where head loses a photo base showed, and zero the other way. Why I have not fixed it. Both readings are individually legitimate: dropping the marker-derived key Also worth knowing before scoping it: the glue arm loses a photo the same way (row The three filed P1sAll three reproduced independently against Window trim. The earlier review concluded the ambiguity needed information the fold never receives. The Glue. Both matchers returned Mobile render. Reported as possibly a product call; it is a bug. A bound local preview is proof the turn What the loop found in those fixes
EvidenceEvery fix has a test that goes RED when only the production change is reverted, signatures preserved so no Base-vs-head differentials through the real pipeline: 27,720 cases (round 4) and 572,760 (round 5), with the Desktop 1005 pass, mobile 1229 pass, both typechecks clean, lint clean and verified live by planting a Corrections to the recordC3 is narrower than filed and is not being fixed. The fold strips markers by document position and The Follow-ups, scoped, not fixed here
What was not establishedThe screenshots exercise the window-trim fix only — no live agent was attached, so streaming, sends and The differentials are exhaustive over a hand-picked alphabet and transcript/pending runs of length ≤3-4, not |
…s type The local handler was annotated `Promise<ReadTranscriptResult>`, which has no `hasMore`. The field reached the renderer anyway — a returned call result is not a fresh object literal, so excess-property checking never looked at it — but the type said otherwise, and nothing type-links this result to the preload shape that now reads it. Any later refactor that built the result inline would have dropped the flag silently, and the renderer would have fallen back to inferring paging from the row count: the exact defect the flag exists to close. Covered end-to-end from a real on-disk transcript, including the case the count inference cannot express — a window that is exactly full reports `hasMore: false`. Also pins, at the call sites a pure-function test cannot reach: - the window-head marker rule through the live session hook, so a call site that stops forwarding the host's paging answer fails rather than silently reverting to "older history always exists" - the two rules re-homed onto #14936's modules: a marker-only send that carried no attachment still earns a real boundary, and a photo turn whose caption happens to spell a run cannot retire the typed sends around it Mutating each re-homed line turns 3, 3 and 4 tests red respectively, so the merge landed live code rather than dead code.
…aging flag The window-head cases were driving `hasMore`, which the overlay no longer reads. `hasMore` still merges the host's answer with a count inference that reports true for a window filled to exactly the limit; the fold rides `earlierHistoryConfirmed`, which is set only when the host actually answered. Pins that separation at the call site a pure-function test cannot reach: an inferred paging answer the host never confirmed must leave a head marker alone. Riding the inference there erases an `[Image #n]` typed at the true start of the conversation — this ticket's own defect — and mobile's 40-turn window makes the exact fill reachable. Pointing the overlay back at `hasMore` turns 2 tests red.
Three subscribe-lifecycle cases assert the whole snapshot frame with toHaveBeenCalledWith, so adding `hasMoreReported` to it turned them red on the branch head — an exact-shape assertion fails on any added field, including a deliberately optional one. The window-head cases also still drove `hasMore`, which no longer gates the fold; they now emit the reported field the fold actually reads, so the case cannot pass on a value the host never answered.
… image send
Splitting echo lookup into literal and image maps, and requiring the row's
evidence count to cover the send, dropped a shape the base matched: a user turn
the host echoed with no content at all. Its evidence count is 0, so an image-only
send could never claim it.
Measured against the merge base, which used one map keyed by
`normalizedUserText(message) ?? ''` with no count requirement at all:
base imageCount 1 vs empty echo -> landed
HEAD imageCount 1 vs empty echo -> not landed (imageCount 0 still lands)
So this branch introduced it; it is not inherited. The user-visible end is a send
that stays held while the sibling preview-binding path — which accepts exactly
this shape — has already put the photo on screen, until the 20s deadline warns
"delivery unknown" about a message that plainly landed.
An empty turn testifies to no particular number of images, so it now satisfies
any count rather than being read as evidence of zero. It still keys under '',
which is what keeps the reach narrow: only a captionless send can match it, and a
captioned one is still refused because the turn says nothing about a caption.
That is the base's own reach, restored, rather than a wider one.
Correction: the empty-echo case is introduced by this PR, and is now fixedIn my previous comment I filed the mobile ack-lost image-only send as a follow-up and said it was At the merge base, The user-visible end: the send stays held while the sibling preview-binding path — which accepts Because it is a regression this branch introduces rather than something inherited, it belongs here I had earlier rejected a candidate fix on the grounds that it let a whitespace-only turn claim a Also worth recording from the same round, since both were gaps in my own work:
Residual risks recorded, not fixed
|
A row like `[Image #1] fix` reads two ways: the user's own marker text, or a host echoing a one-image send captioned "fix". The key minter returned BOTH readings and each count builder credited each, so a single turn vouched for two different pending sends. The second key is the one a photo-carrying send owns, so a row rendering no photo retired a send that carried one and `prunePendingSends` deleted it from the cache. If that send's own turn never landed the photo and its caption were gone with no trace. The two readings are byte-identical in the row, so nothing about the row alone can separate them — but they do not need to be separated there. A send that carried no image owns the literal reading, so the pending list says which readings are spoken for, and that set does not vary with the per-send boundary each count is sliced at. When a send is waiting on the literal reading, that reading wins and the row stops there; otherwise the marker reading stands, which keeps a marker-only host echo retiring its send. The count builders take the pending list rather than a precomputed set, which keeps both call sites to one line each. The set is small (the queue is capped) and rebuilt per pending, which is cheaper than it looks and much easier to follow. Bounded to the two desktop modules — mobile has its own retirement path and imports neither — and it leaves every boundary function, the occurrence bookkeeping and the glue arm untouched. Known residual, unchanged by this: a text-only send whose text equals a photo send's caption still resolves to one row. Both readings are defensible there and base lost the photo too, so it is not a regression.
Blocker cleared —
|
| keys minted | photo send retired | |
|---|---|---|
| not the window head | literal + marker-derived | yes |
| is the window head | literal only | no |
So the marker-derived key fires only on rows the fold has just declared to be text the user typed. What the
existing tests protect is the marker-only host echo — a host echoing an image send as bare [Image #n]
with no source turn anywhere in the window. That case is real and is preserved; the trimmed-window case it
was described as protecting never reaches this code.
Scope and evidence. Two desktop modules; mobile has its own retirement path and imports neither. Every
boundary function, the occurrence bookkeeping and the glue arm are untouched. Two new tests: one pins that a
row cannot retire both sends, one pins that a marker-only echo row still retires a photo send when nothing
claims the literal reading. Neutering the guard turns the first RED and leaves the second green. Desktop
native-chat 1004 pass; pnpm run typecheck:tsc clean; oxlint clean, including max-lines on
native-chat-pending.ts with no disable added.
Still open, unchanged from the summary above: the glue arm loses a photo the same way in one shape, but
base does too, so it is not a regression and a content-key fix does not cover it. Desktop still has no glue
slide and no per-send glue boundary, both of which mobile solves. Mobile still carries an unfiled instance of
P1-2. All four are follow-ups with evidence in the previous comment.
…load-earlier Taking the host's paging answer for the affordance was too strong in one direction. A reported `false` is exact and rightly ends pagination. A reported `true` says older history exists — not that the next read can reach it. The runtime RPC host clamps the requested window to 2000 turns (`MOBILE_NATIVE_CHAT_MAX_WINDOW`), so past that it keeps answering `hasMore: true` while returning the same capped tail. Riding that answer left a "Load earlier" button that re-read the whole window on every scroll near the top and never grew it — a regression the count rule alone did not have, since a short read ended pagination on its own. So a reported `false` still short-circuits, sparing the wasted read the count rule costs on an exactly-full window, but a reported `true` has to be confirmed by the read actually filling the window it asked for. The fold is unaffected: it never went through this function, and reads the host's answer directly.
…oad frame Two more exact-shape assertions broke on the additive `hasMoreReported` field — the web preload snapshot frame, and (already fixed) the IPC subscribe frames. Both assert the whole frame, so any added field fails them. Third instance of this class on the branch; the lesson is that a narrow suite filter hides it, since neither file sits under a `native-chat` path. Also drops a redundant `any` constituent the changed-code quality gate caught: `react-test-renderer` ships no usable types here, so annotating the handle as `ReactTestRenderer | null` makes the union redundant. Each case now owns its renderer with an inferred type, which needs no annotation at all. And corrects the note on literalContentKeysClaimedByPendings: it is not computed once — each count builder rebuilds it, which is sound because the set does not vary with the boundary each count is sliced at, and the queue is capped at 8.
…live list The overlay derived the head from `session.messages[0]`, which slides. Mobile bounds the merged list to 40 on every live append, so one ordinary append past the window trims the front and the row that slides up becomes the "head" — while `earlierHistoryConfirmed` stays true from the initial snapshot, because the appended path never carries a paging answer. Measured: a row rendering `keep [Image #1] literal` a moment earlier silently became `keep literal`. That is this ticket's own defect, on the oldest visible row, firing on any agent run past 40 turns. The head is now recorded where the host actually answers — snapshot, replacement and the cursor page — and never from an append. When a live trim drops the recorded row, the id matches nothing on screen and no row is treated as a head: nothing visible is then known to sit mid-run, so nothing may lose its markers. Losing the strip for a genuinely trimmed run is the safe side of that trade; the other side rewrites a message the user has already read. Desktop needed no change — it already names the head from its read list, which the append merger copies rather than aliases. The overlay test alone could not catch this: it drives a fabricated session, so it only proves the overlay honours the value. Pointing the hook back at the live head leaves it green and turns the new hook-level case red.
Asserting on `captureSendOrigin.mock.calls[0][0]` against a `vi.fn(() => …)` declared with no parameters types `calls` as the empty tuple, so the index is TS2493. The test passes at runtime and mobile `tsc` excludes test files, so neither CI nor the local typecheck ever saw it; a full type pass does. Introduced here when the assertion moved off `toHaveBeenCalledWith` to stop pinning the arity after `captureSendOrigin` gained an images parameter.
Review loop closed: round 4 CLEAN with no changesFour rounds ran. The loop's stopping rule is a round that comes back clean having changed
CI is green on the branch: 45 checks pass, 0 fail, Two process findings worth keepingEvery fix in this area had a sibling call site, and the sibling is where the next defect was. Adding a field to a shared frame type has an unbounded assertion blast radius. Three CI-red Deliberately not fixed, with reprosA stuck photo bubble, as the price of the one-row-one-send guard. Pendings The subscribe hop does not type-guard a non-boolean paging answer. The read hop does. The fold Considered and rejected: using the run's known floor instead of an unbounded budget when the Recorded, not changed: mobile is protected from the immortal-affordance bug only because Verification
Visual proofNot obtained, and I am not claiming otherwise — this change does have a rendered surface. I built |
Visual proof — rendered chat rows, two real dev instances on the two real commitsTwo isolated Orca dev instances, same synthetic transcript, same steps. The only difference between them is the commit each was launched from.
Each ran on its own The fixture is an 11-record transcript, deliberately shorter than the read window so Before —
|
| what the user's turn actually is | before | after |
|---|---|---|
a source turn for one image, then [Image #1] compare with the [Image #2] I mentioned earlier |
attachment chip + compare with the I mentioned earlier — the second marker is deleted from what the user sees |
attachment chip + compare with the [Image #2] I mentioned earlier — one image, so exactly one marker is absorbed and the surplus stays as the text it is |
[Image #1] hello, typed with no image attached |
hello — the ticket's defect |
[Image #1] hello |
[Image #1]: /Users/brennan/Desktop/dashboard.png |
: /Users/brennan/Desktop/dashboard.png — marker eaten, orphan colon left behind |
[Image #1]: /Users/brennan/Desktop/dashboard.png |
[dashboard]: /Users/brennan/Desktop/dashboard.png |
completely empty bubble (visible in the before frame as the bare grey pill above Noted that one too.) — the Markdown parser reads the line as a link reference definition, which renders to nothing, so the user's message vanishes | [dashboard]: /Users/brennan/Desktop/dashboard.png |
The fourth row is what remark-disable-definitions.ts and the disableLinkDefinitions prop on NativeChatMessageList fix. Note that it is a pre-existing swallow — base loses that message too — but it becomes more reachable once markers stop being stripped, because [Image #1]: /path is itself definition-shaped.
Why the definition-disable plugin is load-bearing
Same after instance, same transcript, single variable: disableLinkDefinitions removed from the user branch of NativeChatMessageList and reloaded through renderer HMR. Both definition-shaped turns collapse to empty bubbles — including the exact [Image #1]: /path shape from the ticket, which the previous frame renders fine. Restoring the prop brought both rows back, so the prop is the cause of the difference and nothing else.
What this does not cover
Stated plainly, because the gaps matter more than the frames.
- The optimistic-echo / glue / content-key reconciliation work has no rendered proof here, including the fix where one transcript row was retiring two pending sends and permanently deleting the user's photo. That path needs a live agent session plus a real composer send carrying an image attachment. I could not reach it without doing something I am not willing to do on this machine: the composer's only attachment entry points are (a) the paste path, which reads the image off the system clipboard via
window.api.ui.saveClipboardImageAsTempFile— reaching it means overwriting whatever the user currently has on their clipboard; (b)window.api.shell.pickAttachment, a native file dialog; and (c)window.api.ui.onFileDrop, a native OS drag. (b) and (c) are OS-level automation, which is barred for Orca UI validation. So: not reached, deliberately, and not partially claimed — a text-only send would exercise the echo plumbing but not the image budget, which is the part that was losing photos, so I did not capture one and pass it off as coverage. That work rests on its unit tests. - Mobile has no desktop-CDP surface at all. None of the mobile changes in this PR are covered by anything above.
- The window-head case (a marker turn whose
[Image: source: …]anchor was trimmed out of the read window, which must still strip) is not in these frames; it was captured in an earlier round on the earlier commits and the fixture here is deliberately short enough that no trimming occurs. - Frames are from the branch, not from a merged tree.
One thing seen and ruled out: both instances threw an unrelated React render error in sidebar dialog (the workspace list) when the synthetic agent-status entry was injected. It appeared identically on be07b43a2b and 13b9f82e37, so it is an artifact of the injection rig, not a regression from this PR. It was dismissed before the frames above were taken and is outside the cropped region.
`nativeChatUserMessageImageEvidenceCount` took `max(imageRefCount, markerCount)`. That was equivalent while every marker was stripped from every turn, so a folded image row could never keep one. This branch bounds the strip to the size of the run it anchors to, so a row legitimately retains the markers the user typed — and the max then reads them as extra photos. Measured, through the real fold: a source turn for one image followed by `[Image #1] look [Image #2] [Image #3]` folds to one image-ref plus two surviving markers, and reports evidence for two photos while rendering one. Its content key is then the key a two-photo send owns, so that send retires against a row showing one image and `prunePendingSends` drops it from the bounded cache with the user's photos in it. At the merge base the same input reports one. Structure now wins wherever it exists; markers stay the fallback for a host that echoes an image send as a bare `[Image #n]` with no source turn, which is the case they were added for and which is untouched (it has no image-ref blocks). Under-counting only costs a duplicate bubble; over-counting loses the photo — the same trade `nativeChatUserTextRow` already makes on the glue arm, which was guarded against exactly this and whose sibling call site this is. Differential over 163,856 base-vs-head cases: cases where head loses a photo the base kept drop from 408 to 288, with no new losses in the other direction and no case of one transcript row retiring two sends.
Visual proof — the rendered surface, before and afterEarlier rounds reported no visual evidence, then partial evidence covering only the
Both instances read the same synthetic transcript through the normal transcript BeforeAfterWhat the pair shows, row by row
The empty bubble in the "before" frame is the one worth looking at twice: it is a The attachment itself still folds correctly in both frames — the What is NOT in these frames, stated plainly
Images are attached to this repository; nothing was uploaded to a third-party host. |
…wards `useMobileNativeChatMessageSend` gained `captureSendOrigin(text, images)` on this branch, and nothing asserted the second argument. The assertion that used to pin the call was narrowed to the first argument when the parameter was added, and every other test in the area calls `captureSendOrigin` directly from the drafts hook, so the wiring between the two was uncovered. Measured: dropping `images` from the call turns exactly ONE test red out of 1240 in the mobile session suite — this one. Without it an image send captures `imageCount: 0`, so its origin keys as a markerless text send, an `[Image #n]` caption stays literal, and the optimistic echo can never match its own landed turn — a photo bubble queued forever.
…led to Mobile's `MAX_MESSAGES` and the runtime RPC host's `MOBILE_NATIVE_CHAT_MAX_WINDOW` are both 2000, and the load-earlier affordance only terminates because they are equal. Nothing said so at either site, so raising one alone would silently restore a "Load earlier" button that re-reads the whole window on every press and never loads anything — the failure this branch already fixed once from the other direction. Comment only; no behaviour change.
Review rounds 6–7 — a P1 found and fixed, and every filed follow-up dispositionedThe previous summary reported round 4 as CLEAN, but a commit landed within a minute of The P1: markers outnumbering the photos on the same row
A one-image run whose prompt is Three-way, same transcript, same pendings:
Structure now wins wherever it exists; markers remain the fallback only for a row Evidence. Reverting only the production line turns exactly the two new tests RED The fix also turns out to be strictly better than the base on the inline-image path some A production line this branch added was covered by nothing
Correction to the record: the marker fallback is not speculativeIt was suggested during review that no agent actually echoes an image send as a bare The filed follow-ups, each dispositioned
Residuals recorded, not fixed
What is still not establishedWhether the window-head strip is ever wrong in practice — it is a documented trade that |
Readiness review — closing pass at
|
Approach pass, re-derivedAn approach pass ran on this PR earlier. This one was derived independently rather than by 1. Rendering prose the user typed as Markdown — diverges, but justifiedThe most conservative design available is stricter than we are: route assistant turns That is the same principle this PR is built on — prose a human typed must not be silently Worth recording as a possible future simplification: if we ever want to stop reasoning 2. Structure versus a count scraped out of text — no external model to borrow; strong internal precedentThere is no established pattern for reconciling a structural count with a count parsed out The precedent that does exist is Orca's own, in two places, and both already chose
The content-key arm was the one place taking the larger of the two, and it is the one place 3. Matching an optimistic bubble to its echo by content — forced by the medium, not a shortcutThis is the conclusion the earlier pass did not reach, and it matters because content Checked against the actual record formats the agent CLIs write, and it is not available. So Orca is not choosing content matching over a cleaner correlation-id design — the VerdictNothing hacky. The Markdown fix is a narrower version of a sound pattern; the |
… is coupled to" This reverts 575ac84. The coupling it documented does not exist, and the comment asserted a mechanism the code does not have. The claim was that load-earlier terminates only because `MAX_MESSAGES` (2000) equals the runtime host's `MOBILE_NATIVE_CHAT_MAX_WINDOW` (2000), so raising the client cap alone would leave a button that re-reads the same capped tail forever. Measured against the real hook, both paths refute it: - the cursor path asks for a PAGE (`pageLimit`, 60) with a `beforeOffset` on every read, never the whole window, so the host's window clamp cannot bite at all; - the growing-tail path terminates through its own count inference — driven against a host clamped to 100 while the client cap stayed 2000, the affordance went false after two presses (limits 100 then 160), where the coupling claim predicts it stays true forever. An unclamped control arm kept it true for all eight presses, so that arm was observing the clamp and not an unrelated stop. Termination comes from `nextLimit < MAX_MESSAGES` and the `pageLimit <= 0` early return, neither of which involves the host. There is no invariant here worth naming, so the comment goes rather than gets reworded. The claim was inherited from an earlier residual note and committed without being checked, which is the error the review loop exists to catch.
Correction: the
|
Review loop — closed at round 10, clean with no changesAn earlier summary reported the loop closed at round 4. It had not: a commit landed
The stopping rule is a round that comes back clean having changed nothing. Round 7 was What changedFour commits, one production line between them:
The comment I got wrong
The claim was carried forward from an earlier summary rather than re-derived, and that is The strongest property establishedThe fold never strips more than the base does. Verified twice by independent Alongside it: for every row that carries an image-ref, the fixed evidence count is exactly Security question raised by the Markdown changeTurning off the One thing round 10 found that is worth keepingThe multi-image echo ordinal is not just safe, it is a fix. Evidence summary
Note on the readiness pass aboveIt is headed Residual risks, unchangedThe bounded duplicate-bubble strand; the reconciliation constant factor; the old-host gap |
|
Closing this stale implementation and carrying forward the remaining fix: typed image-marker text must stay visible when it is not an attachment placeholder. |











ELI5
If you type
[Image #1]as ordinary text, native chat used to delete it from whatyou see. It only looks deleted — the stored transcript is fine — but your own
words disappearing from your own message is jarring. Markers are now stripped only
from the prompt that a real image upload is actually attached to.
What Changed
normalizeImageTranscriptMessagesstripped[Image #N]from every user turn,including turns with no image behind them. Stripping is now anchored: it happens
only on the prompt that immediately follows a contiguous, same-source
[Image: source: …]run. A standalone turn keeps the user's literal text.Once rendering became provenance-aware, the optimistic-echo keys had to move with
it. A marker-only turn previously normalized to the empty string and produced a
nullkey, so its queued bubble could never match the landed turn and the rowduplicated. Desktop and mobile now share one rule (
nativeChatUserMessageMatchText):a marker run on a turn carrying no image is the user's own text and keys
verbatim; a turn carrying images keys with markers stripped, as before.
Anchoring alone was not enough. It decided which turn gets stripped but never
how many markers may go — the strip was a global regex. So a turn that attaches
one image and also mentions a second marker in prose still lost the user's words:
[Image #2]is the user's own text — there is no second image — and it wasdeleted. That is this ticket's own defect, surviving inside the fix for it. The
strip is now bounded by the size of the contiguous image-source run it anchors to:
at most one marker per image, taken in document order across the turn's text
blocks, with the remainder left verbatim. Position rather than ordinal decides
which markers are placeholders, so the rule holds whether the producing CLI
numbers markers per-turn or per-session. When the structural evidence is shorter
than the marker count the failure is now closed — surplus markers render as the
plain text they are — instead of open.
One more layer could still eat the text. Preserved markers render through
CommonMark, where a line of the form
[Image #1]: /tmp/a.pngis a link referencedefinition and produces no output at all; a user turn consisting only of that line
rendered as an empty bubble. Measured against the real renderer, not assumed.
The first attempt escaped the line-leading bracket in the source text. It was
replaced, because escaping only reaches lines at the top level — a definition
inside a list item or a blockquote, or one whose label wraps across lines, is
swallowed anyway. User turns now render with the
definitionandgfmFootnoteDefinitionconstructs switched off at the parser(
remarkDisableDefinitions, opted into per call site throughdisableLinkDefinitions). That covers every container without rewriting a byte ofwhat the user wrote. Assistant prose is untouched and keeps its definitions, and
inline links and the rest of GFM are unaffected either way.
Relationship to #14581
This supersedes #14581, which fixes the same ticket. Do not merge both.
Kept (cherry-picked from #14581, with
-xprovenance on each commit): theanchoring fix itself and the echo-key coherence work that must accompany it —
fab320e42,7803de9c1b,94f1cc66ee,c51e8e6bb,794e07c8d,9b9aa6f97,d49d6ad02,bf74570db. That work is sound and its tests are outcome-based.Discarded, with reasons:
b5ba22e46,e9b692fe8,f74297c45,bc1bebadd,604748f58). These retain[Image: source: …]sidecars the host previously dropped, decode multi-image metadata, and then
necessarily add a
NATIVE_CHAT_IMAGE_SOURCE_RUNTIME_CAPABILITYnegotiation plusa legacy wire projection to keep old clients correct. The negotiation is
well-built, but it is a Rule 3 wire change under
docs/reference/remote-wire-compatibility.mdand it is a separate feature fromSTA-4363 — 676 of fix(native-chat): preserve literal image markers #14581's production lines against 322 for the fix. It deserves
its own PR and its own cross-version review, not a ride-along in a P2 display fix.
48f175aa7a"fold prompt-first image transcripts", specifically. It lets amarker-bearing turn absorb an image-source run that follows it, matched only by
marker count. That reintroduces this very ticket's defect through a new door: a
literal
[Image #1]the user typed, immediately followed by a genuinely separateimage send, loses its marker and absorbs the wrong image. Covered now by
"does not let a literal marker turn claim a later send's image source".
824649228d"fix(ci): satisfy PR LoC type-aware audit". It editsconfig/scripts/pr-test-loc-summary.test.mjs, a file introduced by ci: post test vs non-test LoC on pull requests #14738; it hasno relationship to native chat, and
mainhas since landed the byte-identicalchange independently.
Also note: #14581 no longer merges into
main. It builds on #14665, whichmainreverted in #14819, producing two modify/delete conflicts that would resurrect
reverted code. This branch is cut from current
mainand re-homes the one helper(
userTextOccurrenceCounts) that lived in the reverted module.Known remaining path
A turn with markers but no image ref still counts as evidence of an image — the
conflation this PR otherwise ends — so its content key (
text:<stripped>+images:1) is byte-identical to a genuine one-image send's key. Two collisions inthat area were found and fixed on this branch rather than deferred.
One row minting two keys. A row like
[Image #1] fixproduced both readings —the user's own literal text, and a host echoing an image send as that marker — and
both count builders credited each, so one transcript row retired two pending sends.
A row showing no photo could retire a send that carried one, and
prunePendingSendsthen dropped it from the bounded cache with the user's photo in it.
literalContentKeysClaimedByPendingsresolves this at mint time: a send thatcarried no image owns the literal reading, so when one is waiting on that reading it
wins and the row stops there. Otherwise the marker reading stands, which is what
keeps a marker-only host echo retiring its send.
Markers outnumbering the photos on the same row. The evidence count was
max(imageRefCount, markerCount). That was equivalent while every marker wasstripped from every turn — but this branch bounds the strip to the size of the run
it anchors to, so a row can legitimately keep markers the user typed, and the max
then read them as extra photos. A one-image run whose prompt is
[Image #1] look [Image #2] [Image #3]folds to one image-ref plus two survivingmarkers and reported evidence for two photos while rendering one, which is the key a
two-photo send owns. Structure now wins wherever it exists; markers remain the
fallback only for a row carrying no image ref at all, which is the marker-only host
echo they were added for. Under-counting costs a duplicate bubble; over-counting
loses the photo — the same trade the glue arm already makes.
Correcting the earlier record on what the marker fallback protects. This section
previously said it protected the paginated-window case: the read window showing a
prompt turn while its
[Image: source: …]turn is still unloaded. That is wrong onevery current transport. Pruning runs against post-fold messages, so wherever the
host reports its paging answer the fold has already stripped the window head's
markers and no marker-derived key is minted there at all. What the fallback actually
protects is the marker-only host echo — a host echoing an image send as a bare
[Image #n]with no source turn anywhere in the window. The original descriptionsurvives only against a host predating the reported paging field, where the head
rule stays off entirely.
Giving the key path the same adjacency provenance the render path uses remains a
follow-up: it changes the function's signature across both surfaces and several call
sites, and with both guards in place the remaining exposure is a duplicate bubble
rather than data loss.
Why
Transcript structure is the only authoritative proof that an
[Image #N]belongsto an image. Local preview state must not decide what the user's text says, and a
render path and a reconciliation path that disagree about what a marker means
cannot reconcile at all.
Linked Issue
STA-4363 — native chat strips literal
[Image #N]from user messages. Regression from #14162.Testing
pnpm vitest run --config config/vitest.config.ts src/renderer/src/components/native-chat src/shared/native-chat— 92 files / 1010 tests passed.cd mobile && npx vitest run --config vitest.config.ts src/session/— 134 files / 1240 tests passed.(Use this form, not
pnpm --dir mobile exec …: that rewritesmobile/pnpm-lock.yamland drops
patchedDependencies.)sidebar Markdown plugin, the web preload provider surface, the command envelope) —
5 files / 41 tests passed.
2841 tests passed. Path filters miss these: three CI-reds on this branch hid behind
a
native-chatfilter because the file paths contain neithernative-chatnornativeChat.pnpm typecheckand mobiletsc --noEmit— both exit 0, withnode_modulesconfirmed present (the typecheck script exits 0 without it).
oxlintclean; gate verified live by injecting a violation andconfirming it was reported.
oxfmtapplied. Nomax-linessuppression added.against the branch head: 4 of the 5 new marker tests failed there (the fifth
pins count-over-ordinal semantics, which the old code already satisfied). The
Markdown swallow was likewise measured against the live renderer before the fix
existed —
[Image #1]: /tmp/a.pngrendered to an empty<div>.the suites re-run; every applicable mutation went RED, and none failed on a
missing export — every failure was a behavioural assertion. Across the branch
that is 26 RED. For the two most recent fixes specifically:
RED while 123 others in the same files stay green. Confirmed a second way, by a
reviewer who restored the old implementation through a module mock rather than
editing the source, and a third way by scoring the new tests against four
mutant implementations to establish which each one kills.
imagesargument turns exactlyONE test RED out of 1240 in the mobile suite — the one added for it.
Mobile parity is real, not incidental: mobile re-exports the shared module
through a barrel and its suite goes RED on the shared mutations.
each side run through its OWN fold: cases where this branch loses a photo the
base kept fall from 408 to 288 after the evidence-count fix, with none in the
other direction and no case of one transcript row retiring two sends. An
independent 40,000-case fuzz moves head-only photo loss from 2 to 1, with zero
cases where the fix loses a photo the previous build kept.
normalization and renderer-local echo bookkeeping: no platform APIs, paths,
shortcuts, or wire changes, and no local-worktree or folder-workspace assumptions.
AI Disclosure
Checklist
Author