acpmux: a prompt names its files as uploads, of any size, by reference

TE
11d ago
+941-24

The coordinator, relaying Leo, 2026-10-26: "Leo's message with 8 screenshots (about 6.6 MB) failed with "Not sent: no answer from acpmux". ... fix it so 20 screenshots send in seconds: attachments go as blobs or files referenced from the prompt, not inlined base64 in one frame."

Why the send failed (not the daemon)

Both the 8-screenshot send and its "text-only" Retry (prompt 3baa5aa2-…, 739 characters) died in the app before reaching acpmux:

  • The sending Mac's unified log (com.cmuxterm.app.next / page-host.chromium) shows page host binding payload refused at 15:03:02.968 PDT (the first send) and at 15:10:34.992 PDT, 1 s after the Retry was sent (22:10:33.931Z). The Retry re-sends the failed prompt's attachments too (direct.ts failedPrompts keeps {input, attachments, promptId}), so it was the same ~9 MB frame.
  • ChromiumPageHostBridge.swift:137 drops a page call over 4 MiB with only that log line and never answers, so the page's transport.send never settles and the row fails at the 60 s deliveryTiming.failMs.
  • That Mac's daemon has no record of 3baa5aa2 in any session log, and in the same minute it took a 1-image prompt from the same pane in 31 ms. Its log shows no slow handler, lock or store stall at that time.
  • The daemon accepts a prompt to a busy session in milliseconds. On a daemon with 98 live sessions, with a turn running sleep 150, a raw session/prompt got _acpmux/prompt_accepted in 1.4 ms (text steer), 93 ms (8 x 820 KB images inline, 9 MB) and 207 ms (20 images inline, 22 MB).

So large prompts need to stop riding in one frame. Every acpmux hop also caps one message at rpc::MAX_MESSAGE_BYTES (15 MiB), so 20 inline screenshots could not pass a peer link either.

Leo, later: "why the FUCK SHOULD THERE BE A FUCKING SIZE CAP ANYWAYS JUST FUCKING / I ALREADY SAID WE NEED ARBITARRILY LARGE FILES OF ANY TYPE"

Change

A prompt block may name a finished upload of its own chat instead of carrying the bytes. The file is uploaded first with the existing resumable _acpmux/file_put_begin/_chunk/_finish (chunks of at most 512 KiB, so no frame grows with the file). The prompt itself stays a few hundred bytes. The two ref shapes:

  • {"type": "image", "mimeType": "image/png", "_meta": {"acpmux": {"upload": {"uploadId": ID, "fileName": NAME}}}} (no data, no uri)
  • {"type": "resource_link", "uri": "acpmux-upload:ID", "name": NAME, "_meta": {"acpmux": {"upload": {"uploadId": ID, "fileName": NAME}}}}

The daemon that runs the chat resolves them (src/prompt_uploads.rs) before anything reads the prompt (server/requests.rs session/prompt), off the async runtime (spawn_blocking):

  • An image that fits the agent's inline image size (about 3.75 MiB of bytes, up to 10 MiB of inline images per prompt) becomes an ordinary inline image block, so transcripts, queue and ledger see what an inline sender would have sent.
  • Every other file, of any type and any size, goes to the agent by path: a resource_link with uri: file://<path of the finished upload>, its name, MIME type (sniffed from the first bytes) and size. A text block (_meta.acpmux.context: true) lists the paths so the agent can open them.
  • No size or count cap: the 1 GiB file_uploads::MAX_BYTES is gone from begin/chunk/finish; writing past the size declared at begin is filePut.pastSize. The one-shot file_put keeps its old limit (it is one frame).

Rules:

  • Only a finished upload in the chat's own uploads folder is read (file_uploads::finished_path and open_finished: UUIDv7 id, plain file name, real folders, opened no-follow and owner-only, one hard link). Not there is upload.missing; there but unreadable is upload.unreadable. A resource_link whose uri is not acpmux-upload:<uploadId> (or that has the uri and no reference), an image with a uri, or a resource block is upload.invalid.
  • One message: inline images are counted as base64 against the rest of the prompt, the path note lists files while the message has room, and a prompt whose links alone pass one message is upload.tooMany.
  • A remote web client cannot reference uploads (upload.remote; it cannot upload either), and a person's waiting prompt that a web client edited is refused at its turn rather than handing their files to a turn the web client controls.
  • A prompt that waits (queued, held, restored) is refused with upload.missing at its turn if a file it links to was swept meanwhile. Using an upload (a prompt naming it, a turn starting with it) writes a .used marker in its folder, under the same lock the sweep holds while it decides and deletes; the sweep removes only uploads made and last used before its cutoff, and a prompt whose marker cannot be written is refused (upload.unreadable). A message with uploads never joins another agent message's turn.
  • A queue edit cannot carry a reference (attach.edit).
  • A resend under a known prompt id answers as a duplicate even when its uploads were swept (hub.knows_prompt), and a queued or held prompt restored after a restart reads its uploads at dispatch (hub/turns.rs), refusing that one prompt if they are gone.
  • Advertised as promptUploads in initialize. A chat on a peer that does not list it is refused (upload.unsupported) before anything is sent; otherwise the prompt is forwarded as it is and the peer resolves it from its own uploads.

Not in this PR: binary upload frames, file_put_status resume, finish sha256 and the disk-full reason (next PR, flag uploadFrames); model-sized copies per harness; the app sender. The contract doc is #25774. About 950 changed lines, about 500 of them tests.

Risk: contained to prompts that carry _meta.acpmux.upload, and to chunked uploads over 1 GiB that were refused before. Every other prompt takes the same path as before (prompt_uploads::any is false).

Tests

tests/prompt_uploads.rs (8 integration tests):

  • fifty_uploaded_screenshots_reach_a_busy_chat_by_reference: 50 x 800 KiB PNGs uploaded in chunks, then named in one prompt to a chat mid-turn. No frame reaches 1 MiB and the prompt is accepted (queued) in under 1 s. When the turn ends the agent gets 12 inline and 38 by path, every one SHA-256 intact.
  • a_large_image_and_any_other_file_go_by_path: a 6 MiB PNG and a binary file both arrive as resource_link with their paths, bytes intact.
  • a_reference_outside_the_chats_finished_uploads_is_refused: another chat's upload, an unfinished upload, a traversal name, a bad id, a missing name, a mismatched uri, a resource block, a web client, a queue edit; one prompt id sent twice runs once, and its resend after the uploads are swept still answers.
  • an_upload_of_any_size_begins_and_takes_its_bytes: a 2 GB upload, past the old 1 GiB cap, begins and takes bytes; past its declared size is filePut.pastSize.
  • a_waiting_prompt_whose_upload_was_swept_is_refused_at_its_turn, an_upload_whose_use_cannot_be_marked_is_refused.
  • a_peers_chat_reads_the_upload_on_the_peer, a_peer_that_cannot_read_uploads_is_refused_before_anything_is_sent.
  • Unit: the sweep keeps an old upload that was used recently; a resolved link under Web control (a web client's queue edit of a person's prompt) is refused upload.remote.

The 2 GB end-to-end test (streamed, SHA-256 at the agent's path, accept time, peak RSS) is in the binary-frames PR, #25793's follow-up, since it streams the way the app will.

Results (cmux-ci test, 32-vCPU Linux):

  • feat-cmux-next plus the first tests, 30a880d68cfe (run 40414404415): 4 failed. The agent gets an image with no bytes (image:e3b0c442…, the SHA-256 of nothing), the bad references run, the old peer is sent the prompt.
  • With the caps still in (588df63166ec plus this head's tests, run 40420600748): 3 failed. 50 references are refused, the 6 MiB image is refused, a resource block is accepted.
  • With the caps (base of the earlier no-cap run, run 40421912687): the 2 GB upload is refused filePut.tooLarge ("a file is at most 1024 MiB").
  • This head 80a5e1e00930 (run 40429351425): prompt_uploads 8 passed, send_attach 12, file_put 7, durable_agents 57, lib file_uploads/prompt_uploads/queue_edit/digest 13, cargo clippy -p acpmux --all-targets -D warnings clean.
50 screenshots: uploaded and accepted in 251.810141ms, accept 66.1471ms
2 GB (same resolver, chunked upload, run 40421147638): uploaded in 15.3 s, accepted in 514 µs, sha256 7c0b9333…ecb41 intact at the agent's path, peak RSS 59 MiB

Agent session: agent session

🤖 Generated with Claude Code