Skip to content

feat(v2): check the extensible-union values you build - #268

Merged
benbrandt merged 2 commits into
mainfrom
feat/outgoing-extensible-union-types
Oct 2, 2026
Merged

benbrandt merged 2 commits into
mainfrom
feat/outgoing-extensible-union-types

Conversation

@benbrandt

@benbrandt benbrandt commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

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:

// Both compiled before this change:
const update: SessionUpdate = { sessionUpdate: "usage_update", used: 1 }; // missing `size`
const chunk: SessionUpdate = {
  sessionUpdate: "agent_message_chunk", messageId: "m",
  content: { type: "text", txt: "hi" },                                  // `txt` is not a field
};

Change

In the v2 types, each catch-all is split 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. A value you build can't be one.

So:

  • Values you build are checked against the known variants, nested ones included, with nothing to import or annotate. Both examples above are now compile errors.
  • Custom variants still work, under a _ tag, with any payload: { sessionUpdate: "_acme/progress", percent: 40 }. An unknown tag without _, which the spec reserves for future versions, is rejected.
  • Received values pass on unchanged, unknown variants included. That matters for every v2 agent, which must echo a prompt into a user_message: content: params.prompt compiles as-is. The same goes for relaying, or spreading a received value into a new message.

The brand exists only in the types:

  • The zod validators are still generated from the unchanged schema, so runtime validation doesn't change.
  • A small received() helper types parsed values at the parse boundary, with the reasoning documented once.
  • Narrowing works as before: a member with a wide string tag blocks it in either form, which I checked. So the guards stay, and their custom-variant types now match the split.

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

  • New v2 tests:
    • a select option with an extra field, a usage_update missing size, 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;
    • non-_ unknown tags and extensions missing the shared fields are rejected;
    • the untagged titled multi-select variant still works;
    • end to end: an agent echoes a prompt containing a block this SDK doesn't know, and the client receives it unchanged.
  • The rest of the SDK, its existing tests, and the dual-version example compile unchanged.
  • npm run check: generate check, lint, format, spellcheck, build, 976 tests, TypeDoc.

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.
@benbrandt benbrandt changed the title feat: generate Outgoing types for extensible unions feat(v2): check the extensible-union values you build Oct 2, 2026
@benbrandt
benbrandt merged commit 8a60383 into main Oct 2, 2026
6 checks passed
@benbrandt
benbrandt deleted the feat/outgoing-extensible-union-types branch October 2, 2026 11:06
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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant