Files
Arvin Xu e13ad41733 feat(server): serve tool results as render-facing view models on the v2 read path (#19666)
*  feat(server): add a tool view-model layer to the UI message read path

Tool messages carry one blob that serves two audiences: the model replays the
whole thing into the LLM context, while the screen picks a handful of fields out
of it. Measured on production, the screen's share is tiny — a crawlSinglePage
render reads title/url/description and `content.length`, and never the 37 kB page
body sitting next to them.

This adds the seam that lets the two be separated: a pure, dependency-free
projector per `identifier/apiName`, applied over `UIChatMessage[]` on the way
out of `MessageService.queryMessages`.

The split is a layer boundary rather than a query flag. The UI read path goes
through the service; the LLM context read calls `MessageModel.query` directly and
so cannot reach a projector even by accident — no caller has a parameter to
forget.

The registry ships empty, so this commit changes no bytes on the wire. Projectors
land per tool in follow-ups; a tool without one keeps today's behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

*  feat(tools): project the four heaviest tool payloads to what the UI renders

Registers projectors for the tools production measurements put at the top of the
tool payload, and moves the surfaces that need the stored bytes onto an
on-demand fetch.

What each projector keeps, from an audit of the components that consume it:

- crawlSinglePage / crawlMultiPages — the card reads title, url, description,
  crawler and `content.length`; the 37 kB page body it never touches becomes a
  200-char preview, and the tool message body (a second copy, for the model)
  goes entirely. `length` is pinned before truncating so the character count
  stays honest.
- runCommand — the output is stored three times (message body, `state.stdout`,
  legacy `state.output`) and the render reads one. The other two go. The
  surviving copy is NOT truncated; it renders inline in a scrollable block.
- readDocument — the only surface is an inspector chip showing `state.title`.
  `state.content` and `state.xml`, two representations of the same document,
  go with the body.

Nothing is lost, only moved. `message.getToolResultPayload` returns the stored
content and plugin state for one message, and three surfaces call it when they
are opened — never on mount, which would pull everything back down on load:

- the crawl detail portal, when its local body is shorter than the pinned length
- the raw/debug panel, which only mounts once toggled on
- the fallback renderer, behind an explicit control, since an empty body there
  is ambiguous between "returned nothing" and "projected away"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(server): only project tool payloads for the WebSocket v2 cohort

The projected read path was applied to every caller of
`MessageService.queryMessages`, on the assumption that the LLM context is
assembled server-side and so can never see it. That holds on the server — the
context read goes through `MessageModel.query` directly — but not in the
browser: a client-mode run builds its context from `dbMessagesMap`
(`executeClientAgent({ messages: displayMessages })`), which is filled by the
very read being projected. Tool results would have gone missing from the
model's context, silently.

Gate on the `gatewayMux` lab opt-in, read server-side from `users.preference`.
That cohort is exactly the one whose runs always execute server-side, so the
browser there never needs the stored payload for anything but rendering. The
preference is memoized per service instance — a gateway run queries once per
step — and any failure to read it falls back to the whole payload.

Turning the lab off does not invalidate already-cached projected lists. Left
alone on purpose: the mux is on its way to being the only runtime, and the gate
goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(server): project the conversation read, and name the load control

Two defects a live run surfaced that the unit tests could not.

`message.getMessages` builds its own `MessageModel` — it passes different query
options than `MessageService.queryMessages` — so the conversation load never
reached the projector at all. Measured against a seeded topic, the response was
byte-identical to the unprojected one (193,619 bytes) while the WS snapshot path
was already projecting. The projection step is now public on the service and the
router applies it explicitly; the shared-topic branch stays unprojected, since an
anonymous visitor has no authenticated way to fetch the stored payload back.

The fallback renderer's on-demand control reused `debug.response`, so it rendered
as a button labelled "Response" — a section heading, not an action, giving no
hint that it loads what the read path left behind. It gets its own key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 📝 docs(acceptance): record two worktree bootstrap traps

L-S23: a hand-built node_modules symlink farm passes vitest and tsgo but cannot
start the dev server, and a fresh install cannot be the fallback in a repo that
commits no lockfile. L-S24: the dev port is allocated per run and `/` answers 302
to /signin, so a remembered port and a 200 predicate both read as "never came up".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 💄 style(chat): load a projected tool result on expand, not on a button

Expanding a tool row is already the user asking to see its result, so a second
click to fetch what the read path left behind is a step that earns nothing. The
fallback renderer now fetches as it mounts and shows a skeleton under the
Response heading until the body lands.

Mounting is the right trigger rather than a render-time effect: this component
is the Accordion item's child and only mounts once the row opens. Verified in
the app — expanding the assistant group fires no request, expanding one row
fires exactly one.

The control's dedicated string goes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

*  feat(tools): move the command output out of the conversation too

Review asked why `runCommand` still shipped ~10 kB of state after projection.
Because the projector kept `stdout`: the card renders the output inline, so it
looked like the card needed it up front. It doesn't — the card lives inside the
row's detail, which only mounts when the row is expanded, the same property that
made load-on-expand safe for the fallback renderer.

So the output goes with the other two copies, and what stays is the settled
metadata the collapsed row reads: exit code, success, background flag, command
id, stderr, produced files. Measured on the same seeded topic, that state went
from 10,040 to 112 bytes and the whole response from 17,494 to 7,580 — 25.5x
against the unprojected 193,619.

Which surface has to fetch the payload back is now something the projector
declares (`storedPayloadNeededBy`), carried to the client on the existing
`payloadOmitted` field as `'detail' | 'render'`, rather than a second registry
the projectors could drift from. `ToolRender` hydrates the `'render'` tools as
it mounts; `'detail'` tools keep a self-sufficient card and only their detail
portal fetches. Measured per step: loading the conversation, expanding the
assistant group, and expanding the crawl row each fetch nothing; expanding the
command row and the document row fetch exactly once each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

*  feat(server): stop pushing the whole AgentState on agent_runtime_init

`agent_runtime_init` shipped the raw `initialState` to every non-visitor
subscriber: the entire `AgentState`, which carries the LLM context in `messages`
plus the tool-set maps that `stripStateForStream` already treats as the size
problem on `finalState`. It reached the wire unstripped because
`publishAgentRuntimeInit` pushes directly rather than through
`publishStreamEvent`, and because the existing chokepoint only looks for a
`finalState` key.

Nothing reads it. The web handler logs the event and breaks, the CLI prints a
fixed line, and the gateway transport does not handle the type at all. Shared-
agent visitors have been receiving `{ status }` alone for exactly this reason;
every run now gets the same shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(server): close three gaps Codex review found in the projected read path

**Recovery RPC skipped the conversation guard.** `getToolResultPayload` returned
a tool's stored content and state after only the model's ownership scoping,
which in a workspace is workspace-wide. A member holding a message id — including
one kept after access was revoked — could read output from a conversation they
cannot open. It now runs `assertCanViewMessageTargets` first, the same way the
list read guards with `assertCanUseTopicTargets`.

**Share visitors could never recover a projected payload.** `queryUiMessages`
runs under the CREATOR's identity, so the gate read the creator's lab preference
and pushed the visitor a projected snapshot; the recovery RPC then runs as the
VISITOR against ownership-scoped reads that cannot see a creator-owned row, so
the crawl portal, the raw viewer and the fallback had no way to fill it back in.
Visitor snapshots now keep their payloads whole.

**A projected tool read as still running.** The completion test was
`!!result.content`, which an emptied body fails, so while the assistant message
was busy a finished tool was swapped back to its loading placeholder. The stored
length now rides `ChatToolResult` and one shared `hasToolResultBody` answers the
question for both the group's completion check and the status indicator — the
acceptance round missed this because its fixture was a settled conversation.

Codex's fourth finding no longer applies: it described the projector deleting
`output` while keeping an empty `stdout`, and that branch is gone — both keys
are dropped together now and the card hydrates on expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

*  test(server): cover tool projection options in runtime snapshots

* ♻️ refactor: slim ws2 step message transport

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:40:09 +08:00
..