perf(service): send tool output by reference in transcripts
PR 4a of the performance rollout. The brief: a transcript page should weigh kilobytes, not megabytes. Per agent conversation, stored parts run to 12–14 MB at the top, and most of that is tool-bash output, reasoning, read_file, and MCP outputs.
What changed
The read cuts outputs (packages/service/src/agents/coder-host/part-cut.ts, messages-procedures.ts)
-
A transcript read asked with
?parts=cutcuts some parts down: a tool output larger thanCODING_AGENT_BOUNDS.MAX_PART_CHARS(4096 characters) and a reasoning part's text larger than the same bound. Each cut part keeps only its first and last 2048 characters (head/tail), plus its full size in UTF-8 bytes. -
The cut form is explicit in the wire schema (
coding-agent-wire.ts). The message gets a newcutParts: [{ index, field, bytes, head, tail }], and the part at that index is sent without itsoutput/textfield. Nothing in the payload passes off the cut text as the real output. -
The reference to the full part is the message id plus the part index.
-
Parts under the bound are sent unchanged.
-
Only tools whose output the window just draws as text are cut:
bash,read_file,grep,glob,run_on_mac, and the web readsconnection_*and the integrations' remote tools- the Claude Code and Codex equivalents of these
Every other tool's output is sent whole, because the panel reads meaning from it: the agent a start made, a worker's result, a page id,
read_agent's agent. A tool added later is also sent whole until someone names it in that list. -
Cutting is opt-in through the query. A released desktop still gets whole parts. Copy transcript (
CODING_AGENTS_MESSAGES) also still reads whole pages. Only the followed transcript the window draws asks for the cut.
Fetching one part whole
- New routes:
GET /api/agents/{id}/messages/{message}/parts/{index}andGET /api/agents/{id}/subagents/{call}/messages/{message}/parts/{index}(messages.agentPart/agentWorkerPart). - They check access the way the transcript read does (
reachedAgent: a member of the agent's workspace), then read the message by id under the conversation's owner. That read is the newreadMessageByIdinmessage-reads.ts. - Another account, an id that is not a UUID, or an index with no part all answer not-found.
Host and panel
- New pieces from the client to the act port:
CodingAgentClient.agentPartGATEWAY_METHOD.CODING_AGENTS_PARTHostOperator.codingAgentPartACT_KIND.CODING_AGENTS_PART
- In the panel,
agentPartsmarks cut parts, anduseWholePart(whole-parts.tsx) asks for the whole part once the fold is actually open. A find that only searches inside the fold does not trigger it. - While the request runs, the fold shows "Reading the whole output…" / "Reading the whole thought…". It then draws the whole part through the same row, so the fold stays open.
- If the request fails, the fold shows the two ends and says the middle was not read.
- If a part grows after it was read (for example, a thought still streaming), it is cut again and read again.
Validation on write, not on read (packages/wire/src/ui-messages/validate.ts)
- I checked
writer.ts. Every write path that stores parts goes throughadmitted()→readStoredUIMessageswithCODER_TOOL_SET, and stores the validator's output. There are two exceptions, and both are valid by construction:- the empty-parts journal insert
turnEnded'sfinishMessage, which keeps the stored inputs and adds the unknown-outcome output thatstoreWriterchecks every tool against when it is built
- Reading a stored row now runs the SDK's structural pass without
tools. That is the shallow shape check. The checks that matter on read stay: dropping retired tool parts, the registry anddynamic-toolcheck, the tool-state check, and the metadata check by role. The write door still validates each tool's schemas.
Not done here
- The host's double decode (
host-operator.ts'sonTranscriptChanged/codingAgentAnsweron top of the hosted client's own decode) is left as a follow-up. The decode happens on each side of the gateway's process boundary, so removing one is not a small, clean change. With the cut in place, each decode walks kilobytes, not megabytes. - Attachments and inline base64 (PR 4b) and the newest-first read (PR 2a) are out of scope. 2a's
wireMessagescallers will need the newcutargument when one of the two branches is rebased onto the other. We agreed in the chat that whoever lands second rebases.
Tests
coding-agents-app.test.tscovers:- A page with a 2 MB bash output and a long thought carries the cut output and cut text, their sizes, and their indexes. A long
read_agentanswer is sent whole. - A read without
parts=cutgets the whole parts. - The part fetch returns the whole part to its owner and not-found to another account, for an index past the end, and for an id that is not a UUID.
- A page with a 2 MB bash output and a long thought carries the cut output and cut text, their sizes, and their indexes. A long
message-reads.test.ts:readMessageById, and that it reads nothing from another conversation or another account.validate.test.ts: a stored-row read accepts an output that the tool's schema refuses (the write door still refuses it), and a structurally broken row is still refused.whole-parts.test.ts, which rendersAgentTabthrough its public export:- a cut bash output is fetched only when its fold opens, shows the reading state, then the whole output, with the fold still open
- a cut thought is shown whole after opening
- a failed read shows the two ends
- Host gateway and act-port tests cover the new method and act.
Made with Luke