feat(web): render conductor fleet calls as action cards - #323
Conversation
Second slice of P5.1 (docs/conductor-frontends-design.md §5, §12). The conductor drives the whole fleet through `mcp__codeoid_fleet__*` tools, and they rendered like any other tool: a long prefixed name and a collapsed blob of raw JSON. That is the right default for an arbitrary tool and the wrong one here, because these few verbs ARE the conductor's vocabulary — "which session did it pick, what did it send, where did it spawn" is the thing you open the transcript to find out. Splits the work so the part worth testing needs no reactive root, the same shape `lib/fleet.ts` uses for grouping: `lib/fleet-cards.ts` holds classification and field extraction as pure functions, and MessageRow renders the result. Ordinary tools are untouched — the classifier returns null and the existing path runs unchanged. Three decisions carry most of the value. **Unknown verbs fail safe.** The read/send split is security-relevant and enforced daemon-side (`FLEET_SEND_TOOL_NAMES`); the web cannot import daemon code, so the vocabulary is duplicated and can drift. An unrecognised verb is therefore classified `unknown`, never `observe`, so a send-class verb added daemon-side can never render here as a harmless read. Pinned by a test. **Input is model-generated and typed `unknown`.** Every field is narrowed rather than cast, and a wrong-typed or hallucinated field is omitted rather than rendered — an invalid `shape` produces no shape claim at all. Asserted against missing/null/string/number/array inputs. **The input is read off the STATE while awaiting approval.** A call at `waiting_confirmation` carries its complete input on `state.input`, not `tool.input` — and that is exactly the card that has to be readable, since it is the approval prompt where the owner decides whether a dispatch runs. Reading only `tool.input` would have blanked it. A `streaming` phase is deliberately NOT consulted: `partialInput` is a half-generated fragment, and a card built from it would show a workdir the model has not finished writing. Field names were taken from the daemon's own zod schemas rather than guessed — `fleet_panel` uses `sessions`/`message`, not `targets`/`prompt`, which the first draft had wrong. Near-miss aliases are still tolerated for the target of single-target verbs, because a blank target on an approval prompt is worse than a tolerated alias. The card's left border carries the read/act distinction, and send-class calls get an `act` badge. `unknown` deliberately does not borrow a colour it has not earned. 17 new tests (180 web tests total), typecheck, lint and build clean; the two remaining MessageRow lint warnings are pre-existing on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔮 Oracle Review
🎯 Start Here
web/src/components/transcript/MessageRow.tsx (~15 min) — Logic changes in MessageRow.tsx
📋 PR Summary
What this PR does: Renders conductor fleet tool calls (mcp__codeoid_fleet__*) as structured action cards in the web transcript instead of collapsed raw JSON, via a new pure classification module (lib/fleet-cards.ts) consumed by MessageRow. Ordinary tools are untouched — the classifier returns null for non-fleet tools and the existing render path runs unchanged.
Key changes:
- New pure classification/extraction module web/src/lib/fleet-cards.ts that maps fleet tool calls to card data (verb, title, fields) with no reactive dependencies, mirroring the lib/fleet.ts split
- MessageRow renders fleet calls as action cards showing shape, workdir, backend, task, and tool/session identifiers
- Fail-safe verb classification: unrecognized verbs are classified 'unknown', never 'observe', so a send-class verb added daemon-side can never render as a harmless read
- All model-generated input fields are narrowed rather than cast; wrongly-typed or hallucinated fields are omitted rather than rendered
- Input resolution reads state.input while a call is at waiting_confirmation, ensuring approval prompts display the complete dispatch input
- Field names sourced from the daemon's zod schemas, with near-miss aliases tolerated for single-target verbs to avoid blank approval prompts
Areas affected: web transcript rendering (MessageRow), fleet tool call presentation layer, conductor frontend (P5.1)
Testing notes: 17 new tests (180 web tests total) including a hostile/malformed-input block covering missing, null, string, number, and array inputs; tsc, eslint, and production vite build clean. Component rendering remains untestable locally due to the Node <20.19 jsdom gap from #321, but CI runs those suites; the two remaining MessageRow lint warnings were verified as pre-existing on main.
🔍 Code Review
This is a carefully engineered slice with unusually thoughtful security reasoning — fail-safe classification of unknown verbs, strict narrowing of model-generated input, and reading input from state during approval all demonstrate a clear understanding of where these cards matter most. The pure-module split makes the security-critical logic testable without a reactive root, and verification discipline (schema-sourced field names, baseline lint comparison) is evident throughout. The remaining inline comments are hardening opportunities rather than corrections.
What's good:
- ✨ Fail-safe classification of unknown verbs as 'unknown' rather than defaulting to 'observe' — correctly treating the daemon/web vocabulary duplication as a drift risk on a security-relevant boundary and pinning it with a test
- ✨ Narrowing every model-generated field instead of casting, so hallucinated or wrongly-typed input is omitted rather than rendered as fact
- ✨ Reading input from state.input during waiting_confirmation, recognizing that the approval prompt is exactly where card readability matters most
- ✨ Deliberately excluding partialInput while streaming to avoid rendering half-generated fields as if they were decisions the model made
- ✨ Pure-module split (no reactive root) enabling the valuable logic to be tested directly, consistent with the lib/fleet.ts pattern
- ✨ Sourcing field names from the daemon's zod schemas rather than assuming them, and transparently documenting the near-miss that was caught
Generated by Oracle - Highflame's AI Code Reviewer
Review follow-up on #323 (Oracle suggestions 1-3). **The verb lists move to @highflame/codeoid-protocol.** Oracle asked for a CI assertion that the web's duplicated copy still mirrors the daemon's `FLEET_SEND_TOOL_NAMES`, since the fail-safe only CONTAINS drift — a send-class verb added daemon-side would render as `unknown` here indefinitely and nobody would notice. A mirror test turned out to be the weaker of the two options Oracle offered. Tests can import daemon code, but doing so drags `bun:sqlite` and the `Bun` global into web's `tsc -b`, which fails the web-check job. More importantly, a tripwire only DETECTS drift. Both sides now import `FLEET_READ_TOOLS` / `FLEET_SEND_TOOLS` / `FLEET_TOOL_PREFIX` from the shared protocol package, so there is no second copy to fall out of date. It sits beside `CAPABILITIES`, which is already a shared runtime constant there for the same reason. The daemon keeps its existing export names as aliases, so its providers and tests are untouched. The fail-safe stays: a verb NEITHER list names — an older client meeting a newer daemon — still classifies as `unknown` rather than `observe`. **`resolveToolInput` now tests `!= null`, not `!== undefined`.** A real bug. `input` is typed `unknown`, so `null` is representable, and a JSON round-trip preserves an explicit `null` while turning a missing key into `undefined`. The old check accepted that `null`, `isRecord` then rejected it, and the card rendered fieldless — precisely the approval-prompt card the state fallback exists to protect. Covered by a test. **The card model now states its rendering contract.** `summary` and `value` are plain text, to be rendered by text interpolation only, never `innerHTML` or a raw-HTML markdown pass. The content is doubly untrusted: model-generated, and often lifted from repo content the model just read, which is the path a prompt injection takes into a `task` or `message` field. Solid escapes by default so today's renderer is safe; the note is for whoever later adds a rich-text affordance. Web 184 tests, daemon 2434, typecheck and lint clean on both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second slice of P5.1 (conductor-frontends-design.md §5). Follows #320.
Why
The conductor drives the whole fleet through
mcp__codeoid_fleet__*tools, and they render like any other tool — a long prefixed name and a collapsed blob of raw JSON:That's the right default for an arbitrary tool and the wrong one here. These few verbs are the conductor's vocabulary — "which session did it pick, what did it send, where did it spawn" is what you open the transcript to find out.
Now:
Shape
Split so the part worth testing needs no reactive root — same as
lib/fleet.ts.lib/fleet-cards.tsis pure classification and field extraction;MessageRowrenders the result. Ordinary tools are untouched: the classifier returnsnulland the existing path runs unchanged.Three decisions that carry the value
Unknown verbs fail safe. The read/send split is security-relevant and enforced daemon-side (
FLEET_SEND_TOOL_NAMES— send-class can never be auto-approved). The web can't import daemon code, so the vocabulary is duplicated here and can drift. An unrecognised verb is therefore classifiedunknown, neverobserve— so a send-class verb added daemon-side can never render here as a harmless read. Pinned by a test.inputis model-generated and typedunknown. Every field is narrowed, not cast. A wrong-typed or hallucinated field is omitted rather than rendered — an invalidshapeproduces no shape claim at all. Asserted against missing / null / string / number / array inputs.The input is read off the STATE while awaiting approval. A call at
waiting_confirmationcarries its complete input onstate.input, nottool.input— and that is exactly the card that must be readable, since it's the approval prompt where you decide whether a dispatch runs. Reading onlytool.inputwould have blanked it.streamingis deliberately not consulted:partialInputis a half-generated fragment, and a card built from it would show a workdir the model hasn't finished writing.A guess I caught
Field names are taken from the daemon's own zod schemas, not assumed. My first draft had
fleet_panelastargets/prompt; it's actuallysessions/message, andfleet_sendusessession, notname. Near-miss aliases are still tolerated for single-target verbs, because a blank target on an approval prompt is worse than a tolerated alias.Verification
tsc -b,eslint, and productionvite buildcleanMessageRowlint warnings are pre-existing on main (verified by linting the stashed baseline)Component rendering itself still isn't locally testable (the Node <20.19 jsdom gap from #321), but CI runs those suites — as #320 demonstrated.
Next
The Conductor ⇄ Sessions toggle (§3.A) completes P5.1, then P5.2's docked conductor surface with
state/fleet.tsover thefleet.subscribecontract.🤖 Generated with Claude Code