feat(composer): accept PDF, TXT and MD attachments, not just images - #951
Conversation
📦 npm preview published —
|
The `images` key of POST /runs, /runs/:id/messages, the queued-message PATCH and /runs/:id/continue is the wire's one attachment list, and it accepted image/* only. Widen the element schema in place — every image type still passes, and PDF/TXT/MD join them — rather than growing a second list the on-disk shape would have to mirror. The extension an attachment lands under is derived from its media type alone (`attachmentExtension`), so a user's filename never reaches a path, and `isImageAttachmentName` is the one predicate every reader branches on. Refs #950
A PDF or a .md has nothing for a model to look at, so it never rides inline: the engine writes it into the run's attachment folder, in the same `pasted-<n>` numbering space as the screenshots, and the agent gets its absolute path — the form its file tools want, the only one that survives the codex/opencode backends, and the one that keeps a multi-megabyte document out of the prompt bounds. Two gaps closed on the way, both proven red first: - the path note was gated on an image block existing, so an attachment-only task would have handed the agent a brief about a file it was never told the path of; - the note listed the initial prompt's attachments only, so a file stacked onto a queued run reached the agent as nothing at all. The restart re-read now branches on the persisted name: an image comes back as a block, a file as a path, because re-encoding a .pdf as base64 image data composes a message no backend can accept. Refs #950
All four attachment-carrying routes map through one shared helper, so an image becomes a viewable block and a file becomes a file block the engine turns into a path — none of them can invent a different mapping. The serving route is where user-supplied bytes come back from the cockpit's own origin, so a non-image now leaves under `nosniff` and an attachment disposition, and a .pdf/.txt/.md gets its true content type instead of octet-stream. Image responses are unchanged, headers included. Refs #950
The paperclip greyed a .md out in the OS dialog, paste and drop filtered on `image/*`, and everything else was discarded without a word — which reads as a broken composer rather than as an unsupported file. All three now take the same files the wire does, and anything cezar will not take is refused out loud, naming it. Two details that matter more than they look: - the browser's `file.type` is not a good enough reason to refuse. Windows reports '' for a .md on plenty of setups, so the extension decides when the type is empty or unknown — a user cannot fix their machine's MIME database. - an attachment with nothing to preview renders as a named chip, in the composer row and on the thread bubble, where the persisted NAME decides which of the two a URL in the shared list becomes. `composer-images` is now `composer-attachments`, and `ImageInput` reads `AttachmentInput` at every call site, because neither is images-only any more. Refs #950
…t files BACKWARD_COMPATIBILITY §2 gets what changed on the wire (an allowlist widened in place, two refusal strings, the serving route's new headers for non-images) and §3 gets the honest cost of keeping ONE on-disk list: an older cezar renders a broken img for a .pdf entry and re-encodes it as image/png. That was preferred to a second list every reader would have had to learn about. The agent-facing contract in the system prompt said 'the inline image is for viewing only', which reads as though every attachment has an inline copy. A non-image one does not — it exists only as the file at its path, and now says so. Refs #950
|
🤖
No |
|
🤖 |
… runner seam's shape Review follow-ups on this PR, all minor: - the refusal toast promised 'images, PDF, TXT and MD only' while the intake also takes a typeless .log as text/plain — the message now states the rule the code actually applies rather than a narrower one the user would be surprised by; - a task carrying only file attachments handed the runner seam `[]` where it has always handed `undefined`. Both runners spread with `?? []` so nothing behaved differently, but the seam keeps its historical shape; - `PersistedImages` reads `PersistedAttachments` (it returns paths for files too), and the comment left where server.ts's local image schema used to live now says what replaced it. Refs #950
🔍 Code Review: feat(composer): accept PDF, TXT and MD attachments, not just images🎯 SummaryThis PR lets the composer take a I reviewed all 25 changed files at head Three minor findings came out of the pass; all three were fixed in-review ( Verdict✅ APPROVE — with the procedural caveat that GitHub rejects self-approval, so no formal approving review could be submitted on this PR; see the note under Findings. 🧪 Validation Gate
Every command was run with FindingsMinor
Nit
Considered and deliberately not changed
Procedural note (not a code finding)GitHub does not accept a review from the PR's own author, so this report is posted as a comment rather than as an approving review, and the 💥 Breaking Changes
🧪 Test CoverageCoverage is proportionate to the risk in every direction the change moves, and the two tests that matter most are the ones asserting a negative:
Gap worth naming, and it is the QA layer's rather than this PR's: nothing here exercises a real browser, so the OS file dialog's |
|
🤖 This PR carries Setup: P0 — the feature, and the paths that used to fail silently
P1 — the edges around it
P2 — presentation
Attach a screenshot of at least items 1, 6 and 11 to this PR, then apply |
|
🤖 Three minor findings were fixed in-review on this autofix-eligible run ( The pipeline label stays |
|
🤖 Engine: om-auto-create-pr (steps: 12, --loop: no) 🎯 What this run was asked to doMake the composer accept file uploads in PDF, TXT and MD format, not just images. That brief arrived as issue #950, which I had filed earlier from the same request; this run implemented it end to end on 📋 How it was doneThe execution plan (
Then the review pass ( 🧩 The decisions worth knowing about
🐛 Two silent-drop bugs found and fixed on the wayBoth pre-existed this feature and would have made it half-work; both were proven red before the fix, per
🧪 VerificationThe full configured gate, run at the reviewed head with
New coverage is listed in the review comment above; the one that carries the design claim is the engine e2e through the real dry-run backend asserting a Still pending: the GitHub Actions run for the latest head, and manual QA. Neither was waited on before reporting — the CI follow-up is bounded and its result will be posted as its own comment. 🔍 Review
🏷️ Labels
🔗 Relationship to the in-flight PRsThis supersedes the TXT/MD half of #948 and the "any file type, no allowlist" approach of #929, and adds the PDF support neither has. It is an independent implementation — no code was taken from either branch — and the three Major findings raised on #948 (a supported file refused over its browser-reported MIME type, |
|
🤖 Green at head
This matches the local gate from the review pass, which is the outcome I expected but not one worth assuming — CI runs with a tmpdir outside the checkout, so it exercises the six "outside a git repository" cases that fail on this machine (and on Worth recording for anyone reading the run history rather than the result: the earlier red run on
|
Stamp the release set (contract, api-client, cezar, web, alias-cezar) and the lockfile 0.10.0 -> 0.10.1, with the intra-release ranges rewritten to ^0.10.1. A patch bump: every change since 0.10.0 is a feature or a fix, no breaking surface. Turn `# Unreleased` into `# 0.10.1 (2026-09-04)` and make it a real release entry: - keep the five essay-length entries already drafted (pin tasks, agent-account Continue, the CEZ:ASK brace repair, the merge-conflict chip, and the three reference-chip fixes), fixing their PR citations along the way (the pin feature cited its tracking issue, #935, instead of the PR that shipped it, #938; the CEZ:ASK and merge-conflict entries had no citation at all) - add the ~14 PRs that merged since with no changelog note at all: PDF/TXT/MD composer attachments (#951), cross-repo reference-chip safety (#946), a stale review request or an "Update branch" merge clearing a rejection (#909), cross-project task 404s (#905), the Changes tab's independent scroll (#918), a clearable composer skill pill (#919), a resumed session keeping its step's tools (#928), Alt quick replies eating Polish characters (#943), host-local Claude model discovery (#841), GitHub search across every state (#732), two mobile run-header/history fixes (#764, #873), and a fresh task's /skill expansion (#947) - add a CI/CD & Infrastructure section for the GITHUB_RUN_ATTEMPT e2e fix (#911) - add a Highlights paragraph and credit the seven contributors No code changes.
Closes #950
Tracking plan: .ai/runs/2026-09-03-pdf-text-attachments.md
Status: complete
🎯 Goal
.pdf,.txtor.mdthe same way it already takes a screenshot — paperclip, ⌘V, drag-drop — and the agent is handed the path of the file on disk rather than its bytes. Before this, the paperclip greyed a.mdout in the OS dialog, paste and drop discarded it without a word, and the wire would have refused it anyway (mediaType: /^image\//).What Changed
packages/contract/src/runs.ts— the wire widened in place, not renamed. Theimageskey onPOST /runs,POST /runs/:id/messages,PATCH /runs/:id/queued-messages/:msgIdandPOST /runs/:id/continuekeeps its name and its{mediaType, data}shape; only the element's media-type rule widens, fromimage/*toimage/*plusapplication/pdf,text/plain,text/markdown,text/x-markdown. Images were not narrowed — animage/svg+xmla client has always been allowed to paste is still allowed. Two helpers ship with it and are the single source of truth for wire, engine and cockpit alike:attachmentExtension(media type → on-disk extension) andisImageAttachmentName(the one predicate every reader branches on).imageInputSchema/ImageInputremain as deprecated aliases.packages/cezar/src/server/server.ts— one mapping, four routes, safe serving. Every attachment-carrying route maps through the sametoPastedContent, so an image becomes a viewable block and a file becomes afileblock the engine turns into a path; none of them can invent a different mapping.GET /runs/:id/images/:fileanswers an image byte-identically (headers included), while a non-image now leaves with its true content type plusX-Content-Type-Options: nosniffandContent-Disposition: attachment— these are user-supplied bytes coming back from the cockpit's own origin, so they must never render as an active document there..pdf→application/pdf,.md/.txt→text/plain, anything else keeps the pre-existingapplication/octet-stream.packages/cezar/src/workflows/run.ts— persistence, the re-read, and the paths note.persistImagebecomespersistAttachmentand takes its extension from the shared contract map, so apasted-3.mdlands in the same numbering space aspasted-3.pngand cannot collide with it. One helper (persistPastedAttachments) now serves all five entry points — task start, live delivery, the queued stack, an edit, and Continue — andcontentBlocksOfguarantees afileblock can never reach a backend. The restart/dequeue re-read (readPersistedAttachments) branches on the persisted name: an image comes back as a base64 block, a file as a path only, because re-encoding a.pdfinto image data composes a message no backend can accept.if (images?.length), so an attachment-only task would have handed the agent a brief about a file it was never told the location of. (2) The note listed the initial prompt's attachments only, so a file stacked onto a still-queued run reached the agent as nothing at all — a stacked screenshot could at least fall back on its inline block; a stacked.mdhas no fallback.packages/web— the cockpit.composer-images.tsis nowcomposer-attachments.ts(andImageInputreadsAttachmentInputat every call site) because neither is images-only any more. The paperclip accepts both the MIME and the extension spelling of every type —accept="image/*,text/plain"alone still greys a.mdout on Windows — paste filters onkind: 'file'rather than animage/type test, and anything cezar will not take is now refused out loud, naming the file. The browser'sfile.typeis not treated as a good enough reason to refuse: when it is empty or unrecognised the extension decides, because a user cannot fix their machine's MIME database. An attachment with nothing to preview renders as a named chip — in the composer row, and on the thread bubble, where the persisted name decides which of the two a URL in the shared list becomes.BACKWARD_COMPATIBILITY.md§2 records the widened request surface, the two widened refusal strings and the new response headers; §3 records the honest cost of keeping one on-disk list. The agent-facing system-prompt note said "the inline image is for viewing only", which reads as though every attachment has an inline copy — a non-image one does not, and it now says so.README.mdnames the formats a user can attach.🧪 Tests
npm run typecheck✓ ·npm test— 6252 passed / 329 files ✓ ·npm run test:unit— 36 ✓ ·npm run build(incl.check:pack) ✓ ·npm run test:package— 16 ✓.TMPDIR=/tmp. This machine'sTMPDIRpoints inside the checkout, which makes six pre-existing "outside a git repository" cases (git,git-changes,git-worktree,health-forge,projects-api, plus one automations preview-timing case) fail onmaintoo; with the tmpdir outside the repo the whole suite is green..mdand a.pdfattached at task start land aspasted-1.md/pasted-2.pdf, their paths reach the opening prompt, their bytes reach neither the prompt nor the NDJSON event log, andimageCountis 0. A second case covers a file stacked onto a queued run.git-stash-style revert, test fails, fix restored) — the ungated note and the stacked-attachment paths..pdfsitting in the same list as a screenshot comes back as a path, never as an image block; a file whose bytes are gone is noted and dropped rather than named as a dead path.attachments-api.test.ts, new) — a PDF/TXT/MD is accepted and reaches the engine as a file block,application/zipandtext/htmlare 400s,image/svg+xmlstill passes, an attachment-only message is accepted, and each serving-header case is pinned including the unchanged image response..mdextension fallback, the named refusal, the caps, and the chip-not-thumbnail rendering through the real component; thread bubbles — an image renders inline and a.pdf/.md/.txtrenders as a download chip.💥 Breaking Changes
pasted-<n>/screenshot-<n>numbering space), and a record written here parses on an older cezar.too many queued images→too many queued attachments,… at least one image→… at least one attachment); statuses and the{error}shape are unchanged.<img>for a.pdfentry and its engine would re-encode the file asimage/png. Keeping one list was preferred to a second one that every reader — the orphan sweep, the dequeue re-read, the per-stack cap, the bubble — would have had to learn about and that an older cezar would have dropped entirely.BACKWARD_COMPATIBILITY.md§3 says so.🔗 Related
changes-requested, CLA unsigned) and the "any file type, no allowlist" approach of feat(composer): non-image file attachments, restored on current main #929 (blocked). This branch is an independent implementation — no code was taken from either — and it also carries the PDF support neither of them has, plus the three Major findings raised on feat(composer): attach text files (.md, .txt) as well as images #948 (the MIME-type refusal, thetext/htmlhole, and the missingnosniff/disposition headers) addressed by construction.📋 Progress
See the Progress section in the tracking plan.