protocol/mcp: own the Result to ToolsCallResult adapter - #1718
Conversation
Adds `toolResultStep` and converts the five handlers that hand-rolled it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jZKR1C2qxXCueDSBKBnFg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jZKR1C2qxXCueDSBKBnFg
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e539bb05d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | 4248009 | Commit Preview URL Branch Preview URL |
Aug 26 2026, 02:48 PM |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approving. The move is exact and neither caller changed.
The five base sites were the same expression differing on two axes only — the error renderer (errorSummary vs evoSummary) and the per-tool ok renderer — and toolResultStep(e, text, errorText) parameterizes precisely those and nothing else. The unfolding is expression-for-expression: resultMapStep(e, f) is resultStep(e, r => pure(f(r))) and pureOk(v) is pure(ok(v)), so head's form is base's form rewritten, not reinterpreted.
I measured it rather than reading it: 44 comparisons through the real registries in both worktrees, dumping the full ToolsCallResult with key order — the evo registry over a 10-value corpus × 4 tools, plus cas_list through the same synthetic-op drive its proof uses. cas_list identical including key order; evo 37/40 identical, and the 3 that differ are type-impossible inputs where only a local variable name inside a JS TypeError message differs. Nothing the type admits diverges.
Mutation testing shows coverage stacked rather than thinned. Five mutations, applied to toolResultStep at head and to all five inline copies at base: all killed on both sides, and head's killer set is base's plus exactly one new unit proof each time — errorIsRenderedInBand on top of the five caller proofs for the isError mutations, okIsRendered on top of the round-trip proofs for the text ones. No mutation survived at head that base killed.
Worth noting M1 (tag === 'error' → !==) is killed by tsc with two TS2345s at module.f.mjs:218-219 — which is the empirical demonstration of the README's claim that a required errorText pins the handler's error channel at compile time.
Layering is right: protocol/mcp's import block is byte-identical base to head (resultMapStep and ok were already there), no fjs/mcp/** import anywhere in it, and mcp/evo correctly drops its now-unused imports while mcp/cas keeps the ones cas_add/cas_get still use. Callers lost nothing — assert sites 10 → 10 and 37 → 37, leaf delta is exactly the two new proofs. The deleted todo's tasks are both done and exceeded (5 sites converted, not 2), no dangling links, and the body honestly documents the two departures from its proposal.
One thing, not blocking and not in the Changelog: section — that one is byte-identical to the file and correctly placed. The body prose carries 5 HTML-escaped entities: Effect<O, ToolsCallResult, never>, <T>(render: (value: T) => string), r => pureOk(…). Same escaping that hit #1708's changelog item; it's confined to prose here, so nothing malformed ships, but it will land in the squash commit looking like that.
Gates: npm test 3396 → 3398, tsc --noEmit exit 0, purge matched nothing. No **BREAKING CHANGES:** needed — toolResultStep is purely additive and no caller's output moved.
|
@codex review |
|
Thanks — and the escaping note is right, with one correction worth recording since it recurs. The 5 entities you counted were mine, and they are now removed. I had put them there deliberately: the original body wrote the type as What is not fixable from the prose side: the remaining Head is unchanged at Generated by Claude Code |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Fixes
fjs/protocol/mcp/todo/result-adapter.md, deleted here.fjs/protocol/mcpalready owns this vocabulary —okResult, anderrorResultderived from it. The missing third member is the one that picks between them,
and five tool handlers had each written that dispatch out by hand:
toolResultStep(e, text, errorText)is that member. It isresultMapStepoverone fallible operation, rendering the
okvalue withtextand the failurewith
errorTextinto anisErrorresult. Its success channel isToolsCallResultand its error channel isnever— a tool-level failure is aresult the client reads, never a transport error. Every converted handler is
now one line:
Two corrections to the issue's design
The issue proposed a pure adapter,
resultResult, curried over a single okrenderer, taking a
Resultwhose error channel is already astringandanswering a
ToolsCallResult. Neither half survives contact with the code as itstands, so the design shipped here differs on both points:
string. All five sites render a structuredfailure through a summariser (
evoSummaryinfjs/mcp/evo,errorSummaryin
fjs/mcp/cas), so the adapter takes two renderers, not one. RequiringerrorTextrather than defaulting it is the load-bearing part, for the reasonunwrapStepgives for its ownsummary: a renderer written for a particularerror channel cannot accept a wider one, so a fallible call added upstream
becomes a compile error at the tool that has to say what the new failure reads
like, instead of reaching the client as whatever
Stringmade of it.resultStep(effect, r => pureOk(…)), so an adapter over the plainResultwould still be spelled
r => pureOk(resultResult(…)(r))at each one. Takingthe effect makes the handler a single line and is what closes the duplication
the issue is about. This is not the
step(e, x => pure(f(x)))spellingmap-step-combinatoris retiring — it is written with the combinator thatissue points at,
resultMapStep.The issue also cited a
§6.3that no longer exists under that number after theguidelines were restructured; the rule it meant is DESIGN.md §4 (DRY) and §9
(signal-to-noise). Nothing else referenced the file, so it is simply deleted.
Scope
The issue's task list named
evo_revisionandevo_add.evo_list,evo_headand
cas_listare the identical shape and are converted too — five sites, oneimprovement. The per-handler comments explaining why a cache-slot failure is a
tool-level error, and why
evo_listencodes JSON rather than lines, are keptwhere they were.
Checks
npx tsc— clean.fjs test— 3398 pass, 0 fail, including the two newprotocol/mcpproofs(
toolResultStep.okIsRendered,toolResultStep.errorIsRenderedInBand, oneper branch; the error one asserts
isErroris set, which is what thenevererror channel claims).
npm run cov— 100% lines / branches / functions, thresholds enforced.Changelog:
protocol/mcp: newtoolResultStep(e, text, errorText)— answers a tool callwith the outcome of one fallible operation, rendering the value or the failure
(as
isError) into aToolsCallResult.🤖 Generated with Claude Code
https://claude.ai/code/session_014jZKR1C2qxXCueDSBKBnFg