Skip to content

fix(attachments): stop pasted screenshots going invisible to the agent - #915

Open
wojciechszyjka wants to merge 1 commit into
mainfrom
fix/pasted-screenshot-visibility
Open

wojciechszyjka wants to merge 1 commit into
mainfrom
fix/pasted-screenshot-visibility

Conversation

@wojciechszyjka

Copy link
Copy Markdown
Contributor

The bug

A user pastes a screenshot into a task; the agent answers "I can't see your screenshot." The PNG is on disk, one Read away, and the agent was told about it — just not in a way that suggested opening it would show it the picture.

Cezar hands a pasted attachment over twice: as a base64 image block (viewable) and as an absolute path (operable). The block is the fragile half and goes missing on three separate paths; the path is durable and was under-sold.

What was wrong

1. Both prompt surfaces described the path as move-only. pastedAttachmentsText said "When the task involves saving, uploading, attaching, or transforming the pasted content … operate on these files", and the ## Pasted attachments handoff fragment closed with "the inline image is for viewing only" — which reads as no inline image, no attachment. Neither told an agent that couldn't see the image that reading the file was how to see it.

2. A later workflow step got nothing at all. execute() cleared startAttachments alongside startImages after the first agent step, and runAgentStep appended the path note only inside if (images?.length). So on a two-agent-step workflow — or any post-check retry — step two received neither the blocks nor the paths. The run said "1 screenshot attached to the task" and the step that did the work never heard about it.

3. An untyped clipboard file vanished silently. onPaste screened items on type.startsWith('image/') before screenFiles ever saw them — a narrower rule than the drop path used. A File the OS handed over with type: '' (some Linux file managers and clipboard sources) produced no thumbnail and no toast: indistinguishable from the paste never registering.

The fix

  • Prompts (pastedAttachmentsText, HANDOFF_ONLY_INSTRUCTIONS) now state that reading a path is how you see an attachment, name the three cases where the inline copy is dropped, and say not to report a missing attachment without opening its path first.
  • run.ts keeps startAttachments alive across steps and appends the note ungated by images. The base64 blocks stay deliberately first-step-only — re-sending megabytes to every retry buys nothing the path note doesn't — but the paths now ride the whole workflow.
  • Composer filters clipboard items on kind === 'file', making screenFiles the single screening authority on both paste and drop, and screenFiles infers a media type from the extension when the browser reports none, naming the file in a toast when it can't. Extension inference is restricted to the four formats the backends actually decode (png/jpeg/gif/webp) — guessing image/svg+xml would trade a silent drop for a rejected API request — and uses a Map rather than an object literal, since the key is a user-supplied filename fragment.

Behavior deliberately unchanged: a typed non-image (text/plain, application/pdf) is still ignored in silence — dropping a text file on the composer is not an error.

Coverage

Nine new assertions across pasted-attachments.test.ts, composer-images.test.ts and composer.test.tsx, including an end-to-end two-agent-step run through CEZ_DRY_RUN=1 that asserts step two sees imageCount: 0 and the real on-disk path.

Per AGENTS.md § Prove the regression test fails without the fix: with the four source files stashed and the tests kept, 13 assertions go red; restored, all pass.

Validation

The full ordered gate from .ai/agentic.config.json, run locally:

Command Result
npm run typecheck clean
npm test 6172 passed / 326 files
npm run test:unit 36 passed
npm run build ok — check:pack 475 files
npm run test:package 15 passed

No contract surface touched: BACKWARD_COMPATIBILITY.md §8 freezes the CEZ:* marker vocabulary in handoff.ts, not the surrounding prose, and no marker changed.

🤖 Generated with Claude Code

Cezar hands a pasted screenshot to the agent twice — as a base64 block it can
view, and as an on-disk path it can operate on. The block is the fragile half:
it rides the FIRST agent step only, `textOf()` drops it on the codex and
opencode runners, and a compacted session loses it. The path is the durable
half and was being described as if it were only for moving files around, so an
agent that could not see the image had no reason to believe reading the file
would show it one. The reported symptom is an agent answering "I can't see your
screenshot" with the PNG one Read away.

Three fixes, each independent:

- The prompt notes now say that reading a path is how you SEE an attachment,
  not just how you save or upload it, and that the inline copy is the part that
  goes missing. Both surfaces are updated: `pastedAttachmentsText` (per message)
  and the `## Pasted attachments` handoff fragment (every agent step), whose old
  closing clause — "the inline image is for viewing only" — actively taught the
  opposite.

- `startAttachments` no longer resets alongside `startImages` after the first
  agent step, and `runAgentStep` appends the path note ungated by `images`. The
  blocks stay first-step-only (re-sending them is pure token cost), but a
  workflow's second step and every post-check retry now still learn that the
  screenshot exists and where to read it. Before, they got neither.

- The composer screens clipboard items on `kind === 'file'` instead of
  `type.startsWith('image/')`, making `screenFiles` the single judge on both the
  paste and drop paths, and `screenFiles` infers a media type from the extension
  when the browser reports none — naming the file in a toast when it cannot.
  A screenshot the OS handed over untyped used to vanish with no thumbnail and
  no error, which reads exactly like a paste that never registered.

Every new assertion was confirmed red against the unfixed source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wojciechszyjka wojciechszyjka added bug Something isn't working needs-qa Requires manual QA before merge priority-medium Ordinary bug or feature review Ready for code review risk-medium Ordinary change with tests labels Aug 23, 2026
@wojciechszyjka

Copy link
Copy Markdown
Contributor Author

Labels: bug + review (ready, not draft), priority-medium (ordinary bug fix, no broken CLI or data loss), risk-medium (single-area change in the workflow engine and the composer — not the runner seam, not the .ai/cezar/ state formats, no HTTP surface).

needs-qa rather than skip-qa: the paste path in the cockpit composer is user-facing, so the automated-verification exemption does not apply. Worth exercising by hand under CEZ_DRY_RUN=1:

  1. Paste a screenshot into the thread composer — thumbnail appears, submit rides.
  2. Drag-drop a text file — still ignored in silence (unchanged).
  3. Run a workflow with two agent steps and a task screenshot; the second step's prompt should carry the pasted-*.png path.

The agent-facing half is prompt text, so its effect shows up as behavior rather than a screen: an agent that cannot see an inline image should now open the path instead of reporting the attachment missing.

@wojciechszyjka wojciechszyjka self-assigned this Aug 23, 2026
@github-actions

Copy link
Copy Markdown

📦 npm preview published — 0.10.0-pr915.1333

Try this PR build (exact pinned version — copy-paste as-is):

npx cezar-cli@0.10.0-pr915.1333                                # cockpit at http://localhost:4321
npx cezar-cli@0.10.0-pr915.1333 run "…"                        # headless run
npx cezar-cli@0.10.0-pr915.1333 server-deploy --platform <id>  # roll a server to this exact build

Also tagged: npm install -g cezar-cli@pr-915 (moving tag for this PR).
Packages: cezar-cli@0.10.0-pr915.1333@open-mercato/cezar@0.10.0-pr915.1333@open-mercato/cezar-api-client@0.10.0-pr915.1333 (provenance attested).

@wojciechszyjka

Copy link
Copy Markdown
Contributor Author

QA evidence — self-verified

Checked the branch out and ran it, per the self-QA exception in SDLC.md. Two cockpits were built and served side by side from real worktrees, both CEZ_DRY_RUN=1, so every result below is an A/B against the unfixed code rather than a one-sided "it works now":

  • baselineorigin/main @ 185c68a7, port 4332
  • candidate — this PR @ 7a6b7787, port 4331

Confirmed the served bundle actually carries the change before testing: the new toast string is present in the candidate's built assets and absent from the baseline's, so nothing below is a stale-cache reading.

1. Agent-facing: a second workflow step keeps the attachment

A two-agent-step workflow, started through POST /api/v1/runs with a real 629 KB screenshot attached. CEZ_MOCK_STDIN_FILE captured what each step actually received:

step one step two
baseline imageCount=1, path note present imageCount=0, path note ABSENT
candidate imageCount=1, path note present imageCount=0, path note PRESENT

On main the second step learned nothing at all about the screenshot — neither the inline copy nor the path. That is the bug. The candidate's step two receives:

The user attached 1 pasted file, also saved on disk at:
- …/61daea92-…-images/pasted-1.png
Read a path above whenever you need to SEE an attachment and no image is visible in this message…

The persisted file is byte-identical to the source screenshot (643842 B, compared binary).

2. UI: the composer, driven in a real Chrome

A headless Chromium dispatched genuine ClipboardEvents whose items are kind: 'file' — the same shape ⌘V produces, not a stub:

scenario baseline candidate
paste image/png → thumbnail PASS PASS
paste an untyped (type: '') screenshot → thumbnail FAIL PASS
paste an untyped non-image → toast naming the file FAIL PASS
paste text/plain → still ignored silently (unchanged) PASS PASS

On the baseline both middle cases vanished with no thumbnail and no toast, which is indistinguishable from a paste that never registered. Screenshots captured for all four.

3. Manual pass by a human, through the browser

A maintainer tunnelled into the candidate cockpit and pasted a screenshot into a live task's composer by hand. Observed end to end:

  • thumbnail appeared in the composer and rode the submit;
  • the thread bubble rendered the image itself, not a bare count — the user-message event carries imageCount=1 and images: [".../pasted-4.png"];
  • the file landed on disk (pasted-4.png, 1108341 B);
  • the reopened session's turn received imageCount=1 and the new path note, and the agent echoed the on-disk path back in its reply.

Not covered

npm run test:e2e did not run here — the agent-browser provider could not be provisioned on this machine and the harness printed its own E2E SKIPPED — the UI was NOT verified banner. That is not a pass and is not being counted as one; the Chrome run in §2 is a separate, purpose-written check, not that suite. Worth re-running on a machine with network access to the Chrome-for-Testing hosts.

Applying qa-approved + qa-self-verified.

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

Labels

bug Something isn't working needs-qa Requires manual QA before merge priority-medium Ordinary bug or feature qa-approved Manual QA passed qa-self-verified Self-QA exception used review Ready for code review risk-medium Ordinary change with tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant