feat(v2): check the extensible-union values you build - #268
Merged
Merged
Conversation
An extensible union ends in a catch-all variant whose tag is any string, because receivers must accept future ACP variants. That also lets a producer's malformed known variant, with a misspelled or missing field, type-check as the catch-all. For each extensible union, the generator now also emits an Outgoing<Union> type for values a producer sends: the known variants, exactly as the guards narrow them, plus custom variants under a `_`-prefixed tag, which the protocol requires for implementation-specific values. Known tags never start with `_`, so a malformed known variant no longer falls through to the catch-all, while extensions stay expressible. The types live in a new outgoing.gen.ts per lane, re-exported from both entry points. A generated compile-time check asserts that every outgoing type is a value of its open union.
Replaces the opt-in Outgoing types with a change to the v2 types themselves. A catch-all variant must accept any string tag, so receivers tolerate future ACP variants. Typed that way, it also let a value you build pass a malformed known variant (misspelled or missing field) as the catch-all, at any depth. The v2 types now split each catch-all in two: - custom variants, whose tag starts with `_`, the protocol's prefix for implementation-specific values. Known tags never start with `_`. - UnknownVariant, with any string tag, branded with a symbol that only the types of received values carry, so a value you build cannot be one. So a value you build is checked against the known variants at any depth, with nothing to import or annotate, while received values, unknown variants included, pass on unchanged: an agent echoing a prompt's content into its user message needs no conversion. The brand exists only in the types: the zod validators are generated from the unchanged schema, and a received() helper types parsed values. The generator produces the branded types from a copy of the schema whose catch-all tags are pinned to a marker. Narrowing works as before, so the guards stay. v1 is unchanged.
3 tasks
benbrandt
added a commit
to agentclientprotocol/claude-agent-acp
that referenced
this pull request
Oct 2, 2026
Step 2 of opt-in ACP v2 support (step 1: #1217). Behind `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`, the v2 surface now serves `session/new`, `session/list`, `session/resume` (without replay), `session/close`, `session/delete`, and `session/set_config_option`. ACP v1 is unchanged. `capabilities.session` is still not advertised. Under v2, advertising it commits the agent to the whole baseline, including `session/prompt` and `replayFrom: start` replay, and those come in later steps. Until then these methods are served but not advertised. ## Translation As in step 1, each v2 request becomes the v1 request that `ClaudeAcpAgent` already serves, and each v1 response or update becomes its v2 form (`src/v2/session.ts`, `src/v2/session-update.ts`): - **MCP servers:** v2 requires a `type` on every transport, but the agent recognizes a v1 stdio server by its *missing* `type`. A v2 `type: "stdio"` config passed through as is would be silently skipped, so the tag is dropped. The lists v1 requires (`args`, `env`, `headers`) are defaulted. A transport v1 can't express is rejected with invalid params instead of being left out of the session. - **Config options:** `id` → `configId`, a select group's `group` → `groupId`. A select value in `session/set_config_option` drops its v2 `type: "id"`, and value types v1 can't express are rejected. - **Modes:** responses drop `modes`, and `current_mode_update` is dropped. The agent always lists the mode as the `mode` config option, and every mode change also reaches the client as a `config_option_update` or in the `set_config_option` response. - **Commands:** `available_commands_update` inputs gain `type: "text"`. - **Notices, session info, usage:** passed through unchanged. v2 needs no capability for notices, so a v2 client is reported to the agent as taking them, and advisories arrive as `notice` instead of transcript messages. - **Elicitation:** the same in v1 and v2, so it's forwarded. MCP OAuth at `session/new` works. - Update kinds that aren't translated yet (messages, tool calls, plans, extensions) throw, so a gap shows up as an error, not a missing update. - `replayFrom` is rejected until replayed history can be translated. Also bumps `@agentclientprotocol/sdk`: - to 1.6.1, whose router types `closed` as always present, so `serveAcp` drops its `closed!`; - to 1.7.0, which type-checks the v2 extensible-union values the adapter builds, at any depth ([typescript-sdk#268](agentclientprotocol/typescript-sdk#268)). The v2 translations compile unchanged. The exception is `createElicitation`, which relays the v1 request: v1 types a property schema's tag as any string, and an MCP server's schema passes through unchecked, as on v1. It gets a cast with a comment saying so. ## Testing - `src/tests/acp-v2.test.ts` drives the router with the SDK's v2 client app, which validates every response, session update, and elicitation against the v2 schema. New cases cover session creation with v2 MCP servers, config options, and commands; rejected MCP transports; list/close/resume/delete and rejected `replayFrom`; setting the mode with no `current_mode_update` reaching the client; notice support; MCP OAuth through URL elicitation; and unit tests for grouped options, rejected value types, and untranslated updates. Breaking the stdio or command-input translation makes the tests fail. - Ran the built binary over stdio against a real Claude Code session: v2 `initialize`, `session/new`, `list`, setting the mode, and `close` all passed the v2 client's validation, and the 52 real commands arrived with `type: "text"` inputs. `session/new` took ~0.7 s, the same as over v1. - `npm run build`, `npm run check`, `npm run test:run`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found while adding experimental v2 support to
claude-agent-acp.Problem
Every extensible union ends in a catch-all variant whose tag is any string, e.g.
{ type: string; [key: string]: unknown }. Receivers need that, because they must accept future ACP variants. But it also means a value you build type-checks with a malformed known variant, at any depth, because it falls through to the catch-all:Change
In the v2 types, each catch-all is split in two:
_, the protocol's prefix for implementation-specific values. Known tags never start with_.UnknownVariant<…>, with any string tag, branded with a symbol that only the types of received values carry. A value you build can't be one.So:
_tag, with any payload:{ sessionUpdate: "_acme/progress", percent: 40 }. An unknown tag without_, which the spec reserves for future versions, is rejected.user_message:content: params.promptcompiles as-is. The same goes for relaying, or spreading a received value into a new message.The brand exists only in the types:
received()helper types parsed values at the parse boundary, with the reasoning documented once.v1 is unchanged.
How
The generator produces the v2 types from a copy of the schema whose catch-all tags are pinned to a marker. It then replaces each object type holding the marker with
(custom | UnknownVariant<unknown>), using a small scanner that skips comments and strings, since the docs contain braces. A count check fails generation if the number of split members doesn't match the number marked.This replaces the first version of this PR, which added opt-in
Outgoing*aliases. Users had to discover them and import them, and they didn't help with nested values.Testing
usage_updatemissingsize, and a nested text block with a misnamed field are all rejected;_extensions are accepted, top-level, in a config option, and in a nested block;_unknown tags and extensions missing the shared fields are rejected;npm run check: generate check, lint, format, spellcheck, build, 976 tests, TypeDoc.