cmux NEXT: a prompt's attachments never add up past one acpmux message
Leo, 2026-10-31: "this is completely hardlocked bc of message size". His chat showed "message too large to send: 16907300 bytes, the limit is 15728640".
Follows #28754 (the daemon's attach reply fits one message) and #28778 (the client never retries message.too_large unchanged, and the error names the attachment).
What was wrong
Each attachment kind has its own cap (an image preview's IMAGE_BUDGET_BYTES of 1.5 MiB, a recording's MAX_AUDIO_BYTES), but nothing capped their sum. A prompt is one acpmux message, and acpmux refuses one over rpc::MAX_MESSAGE_BYTES (15 MiB). A dozen screenshots' previews went inline as base64 and the whole prompt was refused.
What changed
promptBudget.ts(new): once the prompt's inline bytes (base64 plus escaped UTF-8 text) pass a 12 MiB budget, under 15 MiB with room for the envelope, the largest image previews go asresource_links to their saved originals, largest first, until the rest fits. Only an image whose original is already saved on the chat's machine (originalPath) goes this way. Its link to that original was already in the prompt, so the agent still gets every image. Each one keeps its chip id, so it stays where the composer placed it in the text. Copies of one image in the draft share an original, so they go by reference together.direct.tssendNow: the prompt's blocks, its inline display parts and the shown text all come from the fitted attachments. Retry keeps the chips as they were sent, so a Retry fits them again. The person's draft and chips don't change.direct.tsalso changes what the row and a Retry keep: what the prompt carried. An image that went as its original's link becomes that link chip, and the bubble draws it as a file, the same way acpmux's echo and a reload draw it. None of the images disappears from the bubble.- Composer and
attachmentIntake.tsx: intake now tracks which images' originals are still uploading. A send over the budget waits for those uploads (useSendWhenOriginals), then goes with the originals as links. Before this, the send went with the previews inline and cancelled the originals. A prompt that fits goes at once, as before. Removing a chip with its Ć lets a held send go without it. Every send, a command such as a bare/planincluded, first drops a held one, so a held send never goes later on its own.
Peer and Cloud chats: the originals come from the composer's existing upload (uploadToMachine, chat.upload.begin/chunk/finish). The host's per-tab stager (AgentTabs.uploadStager, CloudHandlers+Move) saves them on the chat's machine: _acpmux/file_put for a peer, the same path #26953/#26966 use for dictation. So the link names a path the chat's agent can open.
Not in this PR (follow-up)
Attachments with no saved path still go inline: older drafts' PDF and text chips, an inline recording (at most 4 MiB), and an image whose original upload failed. Uploading those at send time needs a host change. chat.upload.begin without a batch token spends the person's one gesture credit (AgentPaneUserGestures.consume()), and session/prompt then has none left (consumeForSend()), so the prompt would be refused as gestureRequired. The fix is for chat.upload.begin to take the send ticket the way uploads.begin already does (holdForUpload). That is Swift, and it gets its own PR. Meanwhile #28778's error names the attachment, so nothing is dropped silently.
Red / green
cd webviews && bun test <file>; each red commit comes before its fix.
| test | red | green |
|---|---|---|
promptBudget.test.ts (a dozen screenshots, placed screenshots, waiting for originals) | 8cc6ff75422b, the test plus a pass-through stub: 2 pass, 3 fail | 0a114d21ac43: 5 pass |
promptBudgetSend.test.ts (one message under the budget, and every screenshot stays in the sent bubble) | 0a114d21ac43: 0 pass, 1 fail | e6f069132cb6: 1 pass |
composer/sendWhenOriginals.test.tsx (a send that goes ends an earlier wait) | the red commit before 694eb4dc2dcb: 1 pass, 1 fail | 694eb4dc2dcb: 2 pass |
promptBudget.test.ts (copies of one image go together) | e45b2842ae25: 5 pass, 1 fail | f4aff6222e75: 6 pass |
At the head, those files plus attachments, inlinePrompt, failedPromptRetry, promptDelivery, ComposerQueue and composerRestore also pass under cmux-ci test.
Observed / Not run
- Observed: the red and green runs above (local bun, plus
cmux-ci testat the pushed heads);scripts/web-lint.shpasses (oxfmt, oxlint, type check);prepush-gate.pyreports no new violations. - Not run: a Composer-level test of a bare
/plandropping a held send (that path is the hook's cancel, whichsendWhenOriginals.test.tsxcovers, called at the top ofsend); a live send of a dozen screenshots in a DEV build; the full webview suite (left toweb / react-apps-base-check). No capture: what changed is what the prompt carries, not the UI. A bubble for an image that went by link shows it as a file chip once acpmux echoes it.
risk: normal (web composer send path).
Changelog
- Fixed: a prompt with many images no longer fails as too large to send; the largest go as links to their saved originals.
Agent session: agent session
š¤ Generated with Claude Code