Skip to content

fix(native-chat): preserve literal image markers - #14581

Closed
brennanb2025 wants to merge 17 commits into
mainfrom
brennanb2025/sta-4363-preserve-literal-image-markers
Closed

brennanb2025 wants to merge 17 commits into
mainfrom
brennanb2025/sta-4363-preserve-literal-image-markers

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 19 +1555 −35 +1520
Prod 24 +718 −280 +438

ELI5

Native chat no longer mistakes literal text such as [Image #1] for hidden image metadata unless the transcript proves it belongs to a real image upload — and the optimistic "queued" bubble now agrees with what gets rendered, so a marker-only message shows up once instead of twice.

What Changed

Original fix (fab320e42)

  • Restrict positional marker stripping to the prompt immediately following a contiguous, same-source image-source run.
  • Preserve standalone marker-looking user text on desktop and mobile.
  • Keep mobile image-preview reconciliation working for marker-only image echoes without using preview metadata as display-time proof.
  • Add outcome-based desktop and mobile regression coverage for literal text, while retaining real image-marker folding coverage.

Follow-up P1 fixes (7803de9c1b)

The first commit made the render path provenance-aware but left the pending-key path stripping markers unconditionally, so the two disagreed. Two P1 regressions followed:

  • Duplicate optimistic echoes. For a standalone marker-only turn the render path preserved [Image #1] while nativeChatPendingContentKey / nativeChatUserMessageContentKey normalized it to empty, producing a null key. The optimistic echo could never match the landed turn, so the row duplicated on desktop and mobile. Both sides now share one rule (nativeChatUserMessageMatchText): a marker run that stripping would erase, on a turn carrying no image, is the user's own text and keys verbatim. The send side mirrors it — captureSendOrigin keeps literal markers unless the send actually carried images.
  • One marker vouching for N images. findLandedImagePreviewEchoes accepted hasImagePromptMarker as proof for any number of pending images, so an incomplete multi-image echo could clear the optimistic bubble and rebind every local preview onto a turn that only accounted for one. It now requires countImagePromptMarkers(message) >= entry.images.length.

Not changed: the STA-4363 render behaviour itself. The pending path was moved onto the render path's rule, not the reverse.

Why

The prior unanchored normalization removed [Image #N] from ordinary user turns even when no image-source record preceded them. Transcript structure is the authoritative proof for stripping; local preview state must not change the displayed user text.

Once rendering became provenance-aware, key computation had to move with it — a render path and a reconciliation path that disagree about what counts as a marker cannot reconcile at all.

Pre-fix RED (original):

FAIL ... > renders literal image-marker text in a standalone user turn
- Expected
+ Received
- "text": "Please preserve [Image #1] literally",
+ "text": "Please preserve  literally",

Pre-fix RED (P1 #1 — duplicate echo). New outcome tests run against fab320e42; both assert what the transcript actually shows, not what a key function returns:

FAIL  src/renderer/.../native-chat-literal-marker-echo.test.ts > shows a standalone marker-only send exactly once
AssertionError: expected [ '[Image #1]', '[Image #1]' ] to deeply equal [ '[Image #1]' ]
  [
    "[Image #1]",
+   "[Image #1]",
  ]

FAIL  src/renderer/.../native-chat-literal-marker-echo.test.ts > prunes the marker-only echo once the assistant answers it
AssertionError: expected [ { id: 'p1', …(2) } ] to deeply equal []
+   { "id": "p1", "sentAt": 100, "text": "[Image #1]" }

FAIL  mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts > shows a standalone marker-only send exactly once
AssertionError: expected [ '[Image #1]', '[Image #1]' ] to deeply equal [ '[Image #1]' ]

Pre-fix RED (P1 #2 — one marker rebinding every preview). Two images sent, one marker echoed: the optimistic bubble was cleared and both previews rebound onto the incomplete turn.

FAIL  mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts
      > holds every preview on the optimistic echo until the marker echo covers them all
AssertionError: expected [ Array(1) ] to deeply equal [ [], [ 'file:///a.jpg', …(1) ] ]
  [
-   [],
    [ "file:///a.jpg", "file:///b.jpg" ],
  ]

Linked Issue

STA-4363 — native chat strips literal [Image #N] from user messages.

Visual Proof

Before: the RED assertions above capture the user-visible text loss and the duplicated row. After: Electron CDP validation renders the literal marker intact:

STA-4363 Electron after

Mobile iOS QA also passed against the current Metro development bundle: standalone [Image #1] text remained visible while genuine image-source runs continued to fold correctly.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Automated (full suites, follow-up commit included):

  • pnpm vitest run --config config/vitest.config.ts — 4870 files / 52304 tests passed. The only failures are 13 pre-existing local environment reds in src/main/ipc/pty.test.ts and src/relay/pty-handler.test.ts (WSLENV / ZDOTDIR shell-env), confirmed identical on fab320e42 with these changes stashed.
  • pnpm --dir mobile exec vitest run --config vitest.config.ts — 445 files / 3499 tests passed, 0 failures.
  • pnpm typecheck — passed. pnpm --dir mobile exec tsc --noEmit — passed.
  • Root and mobile oxlint — passed (exit 0). audit:code-quality:native, audit:code-quality:type-aware, check:max-lines-ratchet — passed; no max-lines suppression added or bumped.
  • oxfmt --write on all touched files; pre-commit oxlint + react-doctor + oxfmt hooks passed.
  • New outcome coverage asserts rendered rows and preview binding end to end (assembler + pending merge on desktop; drafts hook + render data on mobile), not key-function return values.

Manual:

  • macOS desktop: Electron UI validated through Playwright CDP (original commit).
  • iOS mobile: current development bundle validated through orca emulator (original commit).
  • The follow-up P1 commit was not re-validated live; its coverage is the outcome-level automated tests above, each verified RED against fab320e42 first.
  • Linux, Windows, and SSH were not manually exercised. The change is pure transcript normalization and renderer-local echo bookkeeping — no platform APIs, shortcuts, paths, wire changes, local-worktree assumptions, or folder-workspace assumptions.

AI Disclosure

Review

Reviewed for security, performance, cross-platform behavior, SSH/remote use, folder workspaces, mobile parity, and backward compatibility. The change is display-only and leaves stored transcripts and remote wire data unchanged.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after evidence attached for the UI behavior
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • Focused lint, typecheck, and tests pass locally; CI will cover the full repository test/build matrix

Author

  • X / Twitter: @BrennanKB5

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b48e9a25-a907-4863-bc73-9d8edd1cacf2

📥 Commits

Reviewing files that changed from the base of the PR and between bf74570 and 48f175a.

📒 Files selected for processing (6)
  • mobile/src/session/mobile-native-chat-draft-reconcile.test.ts
  • mobile/src/session/mobile-native-chat-image-transcript-markers.ts
  • mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts
  • src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts
  • src/shared/native-chat-image-transcript-markers.test.ts
  • src/shared/native-chat-image-transcript-markers.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • mobile/src/session/mobile-native-chat-image-transcript-markers.ts
  • mobile/src/session/mobile-native-chat-draft-reconcile.test.ts
  • mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts
  • src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts
  • src/shared/native-chat-image-transcript-markers.ts

📝 Walkthrough

Walkthrough

The change preserves literal [Image #1] text in text-only user messages. Image-bearing messages use marker-aware matching and image-evidence counts. Pending-send tracking records image counts and supports marker-only transcript echoes. Rendering retains marker text and restores pending image previews. Tests cover reconciliation, rendering, launch-draft handling, pending retirement, and rapid sends.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: preserving literal image markers in native chat.
Description check ✅ Passed The description covers the required sections, linked issue, visual proof, testing, review considerations, and checklist with detailed implementation context.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ff0df98d-20cb-4c8a-a4c3-4b15613b651c

📥 Commits

Reviewing files that changed from the base of the PR and between fab320e and 7803de9.

📒 Files selected for processing (9)
  • mobile/src/session/mobile-native-chat-draft-reconcile.ts
  • mobile/src/session/mobile-native-chat-image-transcript-markers.ts
  • mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts
  • mobile/src/session/mobile-native-chat-pending-echo.ts
  • mobile/src/session/use-mobile-native-chat-drafts.ts
  • mobile/src/session/use-mobile-native-chat-message-send.ts
  • src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts
  • src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts
  • src/shared/native-chat-image-transcript-markers.ts

Comment thread src/shared/native-chat-image-transcript-markers.ts Outdated
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Live send-and-reconcile validation — head 794e07c8dc

Independent re-validation of the exact PR head (794e07c8dc) with real rendered surfaces: Electron desktop via Playwright CDP (dev instance OrcaDev/Electron 43, worktree sta-4363-full-review at head, dedicated profile) and native iOS mobile via orca emulator (iPhone 17 Pro Simulator, Metro dev build, paired to the dev instance). No code was edited.

1. Literal [Image #1] renders exactly once — PASS (both platforms)

Desktop: sent marker-only [Image #1]; the transcript shows exactly one row during the pending window and after the assistant answered (DOM count = 1 paragraph; no duplicate optimistic echo):

desktop-01-literal-composer.png
desktop-02-literal-once.png

Mobile: sent marker-only [Image #1] from the native composer; single echo row while "Agent is working", reconciled and pruned after the answer — exactly once (two rows in the shot are the earlier desktop send and its reply; the new send adds exactly one):

mobile-07-after-send.png
mobile-08-literal-echo.png

Final desktop transcript (two marker-only turns, one row each — y=86 and y=186 — no duplicates):

desktop-07-final-transcript.png

2. Real attachment send-and-reconcile — PASS (mobile, native photo library)

Attached a real photo from the Simulator photo library (photo library permission granted) and sent it. The optimistic echo carried the image chip; the agent received the actual file (orca-paste-*.png, 64×64) and analyzed its real pixels — transcript confirms the input_image + [Image #1] marker turn and the assistant's byte-level answer. Then a second send with two photos: one echo row holding both images, landed turn with [Image #1] [Image #2] and the agent confirming both images by their real content (blue #2878C8 + red #FF0000). One marker per image, no duplicate rows, previews reconciled.

mobile-24-two-images-pending.png
mobile-26-two-images-landed.png
mobile-23-chat-final.png

Observations (not blockers)

  • For Codex image sends the transcript records <image name=[Image #1] path=…> + input_image (Codex's own wire form, not the [Image: source: …] run the fold recognizes), so the landed turn renders the [Image #1] marker text literally alongside the image chip. This is the intended consequence of provenance-anchored stripping; the PR's folding tests cover the [Image: source: …] (Claude) shape. Pre-existing format difference, worth a follow-up if Codex image turns should fold too.
  • One desktop image send made earlier never reached the agent (absent from the raw transcript — the Codex session was busy/stale input line), leaving its optimistic chip rebound onto later landed turns in the desktop view. Environment artifact of that session, not marker logic.

Housekeeping

  • mobile/pnpm-lock.yaml untouched and clean vs HEAD.
  • No files committed; evidence uploaded via gh image only.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts (1)

6-6: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Use a supported React 19 test renderer for this new test.

react-test-renderer is deprecated in React 19. Migrate this new harness to the repository-supported React testing package before this API becomes unsupported. Verify the mobile test renderer that the repository standardizes on.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5238cd44-1308-4e21-9127-3aa152341db6

📥 Commits

Reviewing files that changed from the base of the PR and between 5464525 and bf74570.

📒 Files selected for processing (20)
  • mobile/src/session/mobile-native-chat-draft-reconcile.test.ts
  • mobile/src/session/mobile-native-chat-draft-reconcile.ts
  • mobile/src/session/mobile-native-chat-image-transcript-markers.ts
  • mobile/src/session/mobile-native-chat-literal-marker-echo.test.ts
  • mobile/src/session/mobile-native-chat-pending-echo.ts
  • mobile/src/session/mobile-native-chat-pending-retirement.test.ts
  • mobile/src/session/mobile-native-chat-pending-retirement.ts
  • mobile/src/session/mobile-native-chat-render-data.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts.test.ts
  • mobile/src/session/use-mobile-native-chat-drafts.ts
  • mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts
  • mobile/src/session/use-mobile-native-chat-message-send.test.ts
  • mobile/src/session/use-mobile-native-chat-message-send.ts
  • src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts
  • src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts
  • src/renderer/src/components/native-chat/native-chat-pending.test.ts
  • src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts
  • src/shared/native-chat-image-transcript-markers.test.ts
  • src/shared/native-chat-image-transcript-markers.ts
🚧 Files skipped from review as they are similar to previous changes (12)
  • src/renderer/src/components/native-chat/native-chat-session-assembler.test.ts
  • mobile/src/session/use-mobile-native-chat-launch-draft-seed.ts
  • mobile/src/session/use-mobile-native-chat-message-send.ts
  • mobile/src/session/mobile-native-chat-render-data.test.ts
  • mobile/src/session/mobile-native-chat-pending-echo.ts
  • src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts
  • mobile/src/session/use-mobile-native-chat-drafts.ts
  • mobile/src/session/mobile-native-chat-image-transcript-markers.ts
  • mobile/src/session/use-mobile-native-chat-drafts-launch-draft.test.ts
  • src/renderer/src/components/native-chat/native-chat-literal-marker-echo.test.ts
  • src/shared/native-chat-image-transcript-markers.ts
  • mobile/src/session/mobile-native-chat-draft-reconcile.ts

Comment on lines +304 to +307
retireLandedMobileNativeChatPending(attachedImage, plainPending, NO_IMAGE_ECHOES).map(
(item) => item.id
)
).toEqual(['plain'])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Cover the attached-image marker case.

The final assertion uses plainPending again. It does not verify that an attached-image transcript turn keeps markerPending. Add an assertion with attachedImage and markerPending.

Proposed test
     expect(
       retireLandedMobileNativeChatPending(attachedImage, plainPending, NO_IMAGE_ECHOES).map(
         (item) => item.id
       )
     ).toEqual(['plain'])
+    expect(
+      retireLandedMobileNativeChatPending(attachedImage, markerPending, NO_IMAGE_ECHOES).map(
+        (item) => item.id
+      )
+    ).toEqual(['marker'])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
retireLandedMobileNativeChatPending(attachedImage, plainPending, NO_IMAGE_ECHOES).map(
(item) => item.id
)
).toEqual(['plain'])
retireLandedMobileNativeChatPending(attachedImage, plainPending, NO_IMAGE_ECHOES).map(
(item) => item.id
)
).toEqual(['plain'])
expect(
retireLandedMobileNativeChatPending(attachedImage, markerPending, NO_IMAGE_ECHOES).map(
(item) => item.id
)
).toEqual(['marker'])

@brennanb2025

Copy link
Copy Markdown
Contributor Author

QA evidence at head bf74570db2 — exercised through a real Claude Code session in the native chat UI (dev build of this worktree), plus an iOS Simulator load check of the mobile app.

1. Literal [Image #1] typed with no image — PASS
Sent [Image #1] (no attachment). The conversation shows it exactly as typed, exactly once: verbatim text, not stripped, no duplicate echo row.
qa-02-flow1-literal-marker.png

2. Attached image whose marker refers to it — NOT satisfied in the live flow
Pasted an image (clipboard → Cmd+V → attachment chip) and sent it with a caption. The landed user row renders the raw marker text ([Image #2] describe this image) — the marker is not stripped and no image chip appears on that row. In this live environment the transcript records the marker-bearing prompt turn before the [Image: source: …] turn (observed in the session JSONL on all three sends), while the merge-and-strip path only handles source-before-prompt, so the fix's merge never fires. Also: a send whose typed caption contained [Image #1] left its optimistic echo permanently unreconciled — a visible duplicate row at the end of the conversation.

3. Three attached images — partial-echo scenario not live-observable
Sent three pasted images with "compare these". Claude received all three (tool output lists all paths) and the full 3-marker echo retired the optimistic echo cleanly (no duplicate). A landed turn carrying fewer markers than attached images cannot occur with a real agent — the marker set arrives atomically with the prompt turn — so the "one marker is not proof of all three" scenario is only verifiable at unit level, not in the live UI.

qa-03-flows-1-2-3-final.png

Mobile (Orca Mobile dev build, iOS Simulator) — blocked
The app loads (DESKTOPS host list renders) but genuinely exercising the image attach/echo flow needs this dev build paired as a mobile host plus simulator photo-library/camera injection, which this QA run could not set up. Mobile consumes the same transcript records, so it would see the same prompt-before-source ordering.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

OpenCode revalidation — exact head 48f175aa7a (dev build of this worktree, real Claude Code session in native chat, Electron CDP; no code edited, nothing committed)

1. Literal [Image #1] with no image — PASS. Sent marker-only; the transcript shows it verbatim, exactly once, through the pending window and after the assistant answered (DOM count = 1; no duplicate optimistic echo).

desktop-1-literal-final

2. Pasted-image send (real transcript is prompt-before-source) — blocked in live UI. The assistant genuinely received and answered the image, the optimistic echo retired without a duplicate (single row), but the landed row renders the raw marker [Image #1] describe this image — marker not stripped, no image chip. I instrumented the renderer's own transcript feed (nativeChat subscribe) plus decoded the session JSONL: Claude writes the [Image: source: …] record with isMeta: true, and transcript-line-decoders-claude.ts drops meta user turns before normalizeImageTranscriptMessages runs, so the new prompt-first fold never receives the source record to pair with the marker. The fold is unit-proven (tests pass) but the live pipeline strips its input first; the visible symptom at this head is unchanged from the QA finding.

3. Three-image send — full-evidence retirement PASS; partial echo unit-test-only. Sent three pasted images; the echo retired only when the full 3-marker turn landed (one row, [Image #3] [Image #4] [Image #5] compare these, no duplicate; agent confirmed all three pixel colors). A one-marker partial echo cannot be produced honestly in live UI — a real agent emits the marker set atomically with the prompt turn — so that scenario is covered by the new unit tests only, as documented.

desktop-2-final-top
desktop-2-final-bottom

Mobile (iOS) — blocked. orca emulator reports no running emulator; exercising the attach/echo flow requires this dev build paired as a mobile host plus simulator photo-library/camera injection, which this QA run cannot set up. No substitute interaction method used.

Housekeeping. mobile/pnpm-lock.yaml is restored (byte-identical to main) and absent from the branch diff. Evidence uploaded via gh image only; no files committed, no PR metadata changed.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

OpenCode final evidence QA — exact head 824649228d (824649228de599bac672ed217090e65af8bd9065, dev build of this worktree, real Claude Code session in native chat, Electron CDP; no code edited, nothing committed/pushed)

1. Literal [Image #1] with no image — PASS

Sent marker-only [Image #1] (no attachment). The transcript renders it verbatim, exactly once — through the pending window and after the assistant answered. DOM check: exactly one top-level user row whose text is [Image #1] (bubble + nested text divs are the same row, not duplicates).

01-chat-live.png
02-criterion1-literal-once.png

2. Real pasted image — PASS (all sub-criteria)

Pasted a real PNG (clipboard → Cmd+V → "Pasted image" chip), sent with caption "describe this image". Decoded the live session JSONL:

  • Prompt-before-source ordering: the marker-bearing prompt turn (user, [Image #1] describe this image + base64 image block) is row before the image-source sidecar; the sidecar is a separate user record with isMeta: true containing [Image: source: …/orca-paste-…png].
  • Placeholder stripped: landed row shows the caption without the [Image #1] marker (marker count in transcript stays at the criterion-1 literal only).
  • Image chip preserved: landed row keeps the "Pasted image" chip (icon + file-path title).
  • No duplicate echo: exactly one landed row for the send (caption count = 1), echo retired cleanly.

The assistant genuinely received and analyzed the 64×64 red image ("solid red square… roughly #DD2222").

03-criterion2-image-send.png

3. Three attached images — retirement PASS; one-marker partial unit-backed

Pasted three images (red/green/blue) and sent "compare these three colors". The full 3-marker prompt turn ([Image #2] [Image #3] [Image #4] compare these three colors + 3 image blocks, source sidecar isMeta: true with all three [Image: source: …] records) landed and the optimistic echo retired cleanly: exactly one row for the send, no duplicate.

  • One-marker partial case: cannot be produced honestly in live UI — a real agent emits the marker set atomically with the prompt turn — so it is explicitly backed by the passing unit regression: desktop keeps a captioned multi-image echo until the transcript accounts for every image and mobile holds every preview on the optimistic echo until the marker echo covers them all / keeps a captioned multi-image echo until the transcript accounts for every image. Ran both suites at this head: desktop literal-marker-echo 10/10, mobile literal-marker-echo + draft-reconcile 25/25, decoder/reader/lifecycle 26/26 — all green.

Live finding (honest gap, not fixed — evidence-only): at this head the landed multi-image row renders the raw markers [Image #2] [Image #3] [Image #4] compare these three colors with no image chip. Claude wrote all three [Image: source: …] texts as one isMeta: true record containing three text blocks, and the decoder's isImageSourceRecord only retains single-block source records, so the multi-image sidecar is still dropped before normalizeImageTranscriptMessages folds — the fold never fires for 3-image sends. The single-image case (criterion 2) folds correctly at this head. Criterion 3's retirement logic (echo holds until full marker evidence) is verified live and by the unit regression; the render-fold for multi-image sends remains blocked live.

04-criterion3-three-images.png
05-final-transcript.png

Mobile

Blocked per worker constraints. The only Metro (port 8081) serving the iOS Simulator dev client runs from worktree mobile-agent-view-4256, not this worktree (sta-4363-resume), so no existing emulator state from this exact worktree can exercise the image flow. Per constraints: no auxiliary terminal created, no Metro started, no dependencies installed, no substitute interaction. Existing orca emulator list state confirmed (iPhone 17 Pro Simulator, Orca Mobile dev client loaded, paired hosts listed) — but the bundle in that Simulator is not this worktree's.

Housekeeping

  • mobile/pnpm-lock.yaml was touched by the mobile test run; restored via git checkout -- — git status clean, and the file is absent from origin/main...HEAD.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Exact-final-head OpenCode evidence QA — e9b692fe8a67ee850d65839ca8f2cf3fbb45c368

Live revalidation in the real rendered Electron native-chat UI (Playwright CDP, dev build launched from this exact head SHA, temp profile), using a real Claude Code session (claude 2.1.233) in this repo's worktree. Evidence-only run: no product code touched.

Case 1 — literal [Image #1] text, no attachment

Sent [Image #1] literally through the real composer. Rendered exactly once, exactly as typed. Transcript JSONL user record (no isMeta): "[Image #1] literally". DOM proof: [Image #1] literally appears exactly 1× in the rendered message list; no duplicate echo row.

case1-literal-marker.png

Case 2 — one real attached image, prompt-before-source isMeta ordering

Pasted one real PNG into the composer (clipboard → saveClipboardImageAsTempFile → chip) and sent what do you see in this image?. The Claude JSONL shows the prompt record first (user, no isMeta, "[Image #1] what do you see in this image?") followed by the isMeta image-source record ("[Image: source: …/orca-paste-….png]"). Rendered result: the raw [Image #1] marker is stripped, the image chip remains, prompt text is clean, and the row appears exactly once (no duplicate echo).

case2-one-image-marker-stripped.png

Case 3 — three-image send, one isMeta user record with three [Image: source: …] blocks

Sent three real PNGs with describe these three images. The Claude JSONL contains one isMeta user record whose content is exactly three [Image: source: …] text blocks (paths match the three pasted chips), and the prompt record carries [Image #2] [Image #3] [Image #4] describe these three images. Rendered result: all three raw markers stripped from the user row, all three image chips preserved, describe these three images appears exactly once, and the optimistic echo retired on full evidence (no duplicate, no partial-evidence rebind). DOM proof: Pasted image chips = 4 total (1 case-2 + 3 case-3), zero raw [Image #N] marker strings in the message list.

case3-three-images-chips-preserved.png

One-marker partial-evidence protection — exact-head tests

The real agent cannot emit the one-marker-vouching-for-N partial case, so it is confirmed by exact-head test coverage (all passing on e9b692fe8a67ee850d65839ca8f2cf3fbb45c368):

  • Desktop native-chat-literal-marker-echo.test.ts — keeps a captioned multi-image echo until the transcript accounts for every image (P1 Fix Ctrl+Backspace not deleting word in terminal #2 regression) — passed (76/76 in file set, incl. prompt-before-source ordering test).
  • Mobile mobile-native-chat-literal-marker-echo.test.ts — holds every preview on the optimistic echo until the marker echo covers them all — passed (35/35).

Mobile

Mobile QA is blocked for this run: the already-running Orca emulator loads its JS bundle from the existing Metro (port 8081), which serves worktree mobile-agent-view-4256 (branch brennanb2025/mobile-agent-view-4256), and the installed com.stably.orca.mobile binary predates this branch. That state cannot exercise this worktree's flow, and per run constraints no second Metro, dependency install, or lockfile regeneration was performed. Mobile coverage rests on the exact-head mobile unit tests above.

Housekeeping

All QA processes/profiles/screenshots cleaned up; git status clean at exact head; mobile/pnpm-lock.yaml absent from origin/main...HEAD.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Fresh exact-head QA — commit f74297c457b091c219f4607605083a326dee2c3a

Independent QA lane run from the exact-head worktree (sta-4363-resume, SHA f74297c457b091c219f4607605083a326dee2c3a, clean tree) via Playwright CDP on a dev build of this branch. The three user acceptance criteria were validated on render and reconciliation, using both seeded exact-shape transcripts and a real end-to-end Claude Code session (real sends, real pasted images, real .claude/projects transcript).

AC 1 — literal [Image #1] without an image renders verbatim exactly once ✅

  • Seeded transcript with a standalone user turn [Image #1]: rendered verbatim, exactly once, zero image chips (DOM: marker count 1, chip count 0).
  • Reconciliation via the optimistic-echo key path: seeded the tab's launch prompt [Image #1] (createdAt before the transcript turn) — the echo bubble was suppressed and the store entry pruned, leaving exactly one visible row. Pre-fix the pending key normalized the marker to empty, which could never match the landed literal row.
  • Live real sends: sent [Image #1] and keep [Image #1] literal through the actual composer into a real Claude session. Optimistic echo appeared at +400 ms; after the landed turn the echo pruned — final state has each literal marker exactly once (verified: 2 total markers = the 2 literal rows, no duplicates), markers preserved verbatim, 0 chips.

literal marker once (seeded)
literal marker once (live real send)

AC 2 — one attached image with a matching marker strips the placeholder and retains one chip ✅

  • Seeded: meta record [Image: source: /tmp/qa-a.png] + prompt look [Image #1] → DOM shows exactly 1 chip (qa-a.png), marker count 0 (stripped), text look.
  • Live: pasted a real PNG into the composer and sent look at this to the real Claude session. Transcript carried the canonical shape (meta [Image: source: …] record + prompt with text [Image #1] look at this + image block); rendered user row = exactly 1 chip (labeled "Pasted image" — the temp-paste path label) with the marker stripped.

one image one chip (seeded)
one image one chip (live real image)

AC 3 — three attached images with only one marker does not count as evidence for all three ✅

  • Seeded partial: one meta record with 3 source blocks + prompt compare these [Image #1] → the fold is strictly 1:1, so the transcript unfolds to 3 source rows (1 chip each) and the prompt row retains the literal [Image #1] marker (not consumed as evidence for all three). DOM: 3 chips + 1 literal marker, prompt text intact.
  • Control (complete): 3 sources + [Image #1] [Image #2] [Image #3] → single merged row, 3 chips, marker count 0.
  • Reconciliation: nativeChatUserMessageImageEvidenceCount/content keys make a 1-marker landed row carry evidence 1, so a 3-image optimistic echo (text:…\0images:3) cannot bind to it. Covered at exact head by the outcome tests below (a real Claude send always writes one marker per image, so the partial-echo retention cannot be produced by a live send; the live 3-image send validates the complete control path end to end).
  • Live 3-image send: pasted 3 real PNGs and sent compare these. Optimistic echo appeared with all 3 previews; when the landed turn (3 sources + 3 markers) arrived, the echo pruned — compare these renders exactly once, 3 chips, markers stripped.

partial 1-of-3 marker retained literal
live 3-image real send exactly once

Automated exact-head coverage (all passing)

  • Desktop/shared: native-chat-literal-marker-echo.test.ts, native-chat-image-transcript-markers.test.ts, native-chat.test.ts (RPC) — 73 passed.
  • Mobile: mobile-native-chat-literal-marker-echo.test.ts, mobile-native-chat-draft-reconcile.test.ts, mobile-native-chat-pending-retirement.test.ts — 53 passed.
  • These assert the rendered-row/echo outcomes for every AC variant above, including the partial multi-image echo retention and the prune-after-assistant-answer cases.

RPC compatibility projection (released/current mobile/web/remote consumers)

The new projectNativeChatRpcImageSourceMessage runs on the host RPC path for remote/mobile/web clients (not the local desktop read path), so it is validated by exact-head tests + inspection: multi-block image-source messages split into single-block rows with deterministic derived IDs (<id>:image-source:<index>), keeping released single-block clients readable across snapshot, replacement, and append (projects new-host multi-source reads into old-client single-block rows, keeps prompt-first old-client turns compatible across snapshot, replacement, and append); the native multi-block ordering is capability-negotiated (NATIVE_CHAT_IMAGE_SOURCE_RUNTIME_CAPABILITY), consistent with the remote-wire-compatibility contract. All passed at exact head.

Limitations

  • Mobile QA (orca emulator): blocked. The only running Metro/Expo dev server (port 8081, PID 51404) serves the mobile-agent-view-4256 worktree at 7ad37607de (STA-4256) — not this exact head — so the emulator does not target this worktree/bundle state. Per the lane constraints no substitute interaction path was used; mobile evidence is the exact-head automated tests above.
  • AC3-partial (1 marker for 3 images) reconciliation is exercised by the passing outcome tests, not a live send — real Claude Code writes one [Image #N] marker per attached image, so no real session can produce the partial echo state.
  • AC1 reconciliation was additionally exercised via the launch-prompt path (same content-key rules as pending sends) and live real sends; the desktop pending-queue bubble itself was observed live on the image sends (appears at +400 ms, pruned on landing).
  • Screenshots are full-window captures of the native-chat pane in the dev build at f74297c.
  • Not exercised: Windows/Linux/SSH (display-only transcript normalization; no platform APIs in the diff).

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Fresh final exact-head QA — 604748f58bfb0f417c2b1d65d184152340fbd860

Independent final QA run (OpenCode lane) from the exact final head SHA 604748f58bfb0f417c2b1d65d184152340fbd860 — local HEAD, upstream, and remote PR head all match, worktree clean, mobile/pnpm-lock.yaml untouched in origin/main...HEAD. Live validation in the real rendered Electron native-chat UI (Playwright CDP, dev build launched from this exact SHA, temp profile), driven through a real Claude Code 2.1.233 session in this worktree. This comment supersedes #issuecomment-5301775880 (evidence at e9b692fe8a) and #issuecomment-5301953044 (evidence at f74297c457).

Live acceptance criteria (reconciliation, not just rendering) — all verified at this SHA

AC 1 — literal [Image #1] with no image renders exactly once, exactly as typed. Sent [Image #1] literally and keep [Image #1] literal through the real composer into the real session. Transcript records: plain user turns with zero image blocks ("[Image #1] literally", "keep [Image #1] literal", no isMeta). Rendered message list: each row appears exactly once with the marker verbatim (DOM: 1 marker row per literal send, 0 image chips, no duplicate echo row; sampled at t+250ms…t+6s, count never exceeds 1).

02-ac1-literal-once.png

AC 2 — attached-image marker stripped as a placeholder while its image appears. Pasted one real PNG (orca-paste-…png) and sent what do you see in this image?. Transcript: prompt [Image #3] what do you see in this image? + isMeta record with exactly one [Image: source: …] block. Rendered: Pasted image | what do you see in this image? — marker stripped, 1 chip, caption intact, row exactly once. (A second live send with two pasted images also folded cleanly: 2 chips, markers stripped, once.)

04-ac2-single-image-live.png

AC 3 — three attached images + one-marker echo does not retire or claim all three. Real Claude writes one marker per image, so the partial one-marker-vouching-for-N state cannot be produced by a live agent; it is asserted by the exact-head outcome tests (all passing): does not let one marker claim one record containing three sources / does not let one marker prove three image sources (shared), keeps a captioned multi-image echo until the transcript accounts for every image (desktop), holds every preview on the optimistic echo until the marker echo covers them all + does not duplicate previews on a mismatched multi-source record (mobile). The live complete case was exercised end to end: three real pasted PNGs, describe these three images → transcript [Image #4] [Image #5] [Image #6] + one isMeta record with three [Image: source: …] blocks → rendered 3 chips, markers stripped, caption exactly once, echo retired on full evidence only.

05-ac3-three-images-live.png

03-ac2-two-image-live.png

Reconciliation evidence: every live send was sampled immediately after Send and repeatedly after; the probe rows never exceeded one copy, and the optimistic echo keys on the same literal-marker match text as the render path (nativeChatUserMessageMatchText vs nativeChatPendingMatchText), which is what the exact-head outcome tests assert (shows a standalone marker-only send exactly once, prunes the marker-only echo once the assistant answers it, does not let a markerless row hide marker-bearing literal text — desktop and mobile).

Wire negotiation — current/current and both fallback directions (exact-head fixtures, all passing)

  • current/current: preserves literal markers when the host confirms native projection + publishes native ordering only when the client offers the capability + echoes negotiation and preserves native rows across every stream frame (RPC, desktop transport, mobile session).
  • current client ↔ v1.4.183 host (no capability echo): uses v1.4.183 marker fallback for reads from an unnegotiated host + applies the released fallback on unnegotiated snapshot, replacement, and append frames.
  • v1.4.183 client ↔ current host (no offer): omits new-host source sidecars from old-client reads + keeps %s old-client turns atomic across frame types + makes %s reads match one released-host image turn + keeps %s legacy appends atomic across reader batches.
  • Boundaries covered by passing fixtures: pagination (keeps a %s legacy tail and its older-page cursor turn-atomic, keeps paged-in history across an auto-reconnect replay snapshot, drops an older-page response captured before transcript replacement, clears a stale cursor when a replacement omits paging metadata, rejects a cursor page invalidated by live trim and retries with a growing tail), snapshot/replacement (keeps the base snapshot authoritative when a live append arrives first), split-append (keeps %s legacy appends atomic across reader batches, keeps %s old-client turns atomic across frame types).
  • Consumer audit: all four wire consumers (desktop native-chat-session-transport, desktop native-chat-runtime-contract, web web-preload-api, mobile use-mobile-native-chat-session ×2) normalize through the capability-gated normalizeNativeChatImageSourceWireMessages; no unconditional wire normalization remains. The remaining unconditional normalizeImageTranscriptMessages uses (desktop assembler/live-preparation, mobile render/draft-reconcile/pending-retirement) are the marker-aware render fold, which only strips when an image-source record of exactly matching count proves it — literal markers are untouched (verified by leaves ordinary user text untouched, keeps markers in no-image match keys without changing real image keys).

Test suites at this exact SHA

  • Root: pnpm vitest run --config config/vitest.config.ts → 5635 files, 52854 passed, 123 skipped, 0 failures.
  • Mobile: pnpm --dir mobile exec vitest run → 451 files, 3582 passed, 3 skipped, 0 failures (lockfile restored pristine after the run).
  • Perf/resource scan: regexes are linear with no backtracking (returns long marker-free whitespace without regex backtracking passes), normalization scans are run-jumping (no per-message quadratic), pending/command-marker caches LRU-bounded, image-preview maps bounded (32/session 8) — no regression found.

Mobile live QA: blocked

The only running emulator surface loads its JS bundle from the existing Metro (port 8081, PID 51404), which serves worktree mobile-agent-view-4256 — not this exact head — and no non-disruptive exact-head orca emulator surface exists. Per lane constraints no substitute interaction path was used and that surface was not disturbed. Mobile evidence rests on the exact-head mobile suites above (all passing at this SHA).

Housekeeping: QA processes/profiles cleaned up; only my own launched dev instance was terminated; git status clean at 604748f58bfb0f417c2b1d65d184152340fbd860; mobile/pnpm-lock.yaml absent from origin/main...HEAD and pristine in the tree.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Superseded by #14939, which fixes STA-4363 with a focused, anchored change (+950/-101 vs +2273/-315 here).

Why this one is being closed rather than iterated:

  1. Scope. 676 of the 998 production lines here serve a separate Claude image-source-metadata + wire-negotiation feature, not STA-4363. That work is well-built and deserves its own PR and its own cross-version review under docs/reference/remote-wire-compatibility.md — not a ride-along in a P2 display fix.
  2. A commit here reintroduces this ticket's own defect. 48f175aa7a ("fold prompt-first image transcripts") lets a marker-bearing turn absorb an image-source run that follows it, matched only by marker count — so a literal [Image #1] typed before a genuinely separate image send loses its marker and absorbs the wrong image. fix(native-chat): anchor literal image-marker stripping to a real image send (STA-4363) #14939 covers this with a named test.
  3. Mutation testing. 20/25 killed; survivors M10/M19/M21/M23/M24. M24 covers a behavior this PR's body explicitly claims as a fix. fix(native-chat): anchor literal image-marker stripping to a real image send (STA-4363) #14939 kills 5/5 applicable.
  4. 824649228d is foreign — it edits config/scripts/pr-test-loc-summary.test.mjs, a file introduced by ci: post test vs non-test LoC on pull requests #14738, and main has since landed the byte-identical change independently.
  5. It no longer merges. This branch builds on fix(mobile-native-chat): retire pending bubbles glued into one transcript row (mobile half of #14262) #14665, which main reverted in Revert "#14665 fix(mobile-native-chat): retire pending bubbles glued into one transcript row" #14819, producing modify/delete conflicts that would resurrect reverted code.

The sound work here was kept: #14939 cherry-picks the anchoring fix and the echo-key coherence that must accompany it, with -x provenance on each commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant