Skip to content

feat(acp): 标记 v2 草案公开面,并按协商版本选择 wire 面 - #168

Merged
YoungSx merged 11 commits into
developfrom
feat/acp-draft-surface-165
Sep 3, 2026
Merged

feat(acp): 标记 v2 草案公开面,并按协商版本选择 wire 面#168
YoungSx merged 11 commits into
developfrom
feat/acp-draft-surface-165

Conversation

@YoungSx

@YoungSx YoungSx commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes #165. Closes #166.

本 PR 现在装了两件事。 原本 #166 的工作在 PR #169 里、叠在本分支之上;#169 已先被合入本分支(rebase merge,内容逐字相同),所以本分支现在是 11 个提交,两个 issue 一起交付。评审可以分两段读:前 7 个提交 = #165(草案公开面的编译期标记),后 4 个提交 = #166(按协商版本选择 wire 面)。#169 的完整正文仍可读,本文把要点内联,评审不必回头翻。


第一部分 · #165 草案公开面标记为实验性

问题

PR #160 把 ACP v2 的 wire contract 建模成了 37 个裸公开类型。1.x 消费方拿到 nupkg 后可以毫无提示地基于一个 client lifecycle 未实现、且 AcpProtocolVersion.RuntimeServed 明确不协商的协议面写代码 —— 唯一的反馈是运行时 initialize 被拒。AGENTS.md §11 早就禁止生产入口发送未完整实现的草案 wire,但这条规则在 SDK 边界之外没有任何执行力。

时间窗是硬的:这 37 个类型一个都没发布过(1.0.0 导出 153 个类型,全不在其中),所以在打下一个 acp-sdk-v* tag 之前收窄公开面是免费的,PackageValidation 不会拦。

做了什么

37 个草案类型全部带 [Experimental("SEACP002")] —— 编译器默认按 error 报,不是 warning。id / 诊断文本 / 文档链接单一来源在 AcpDraftProtocol:这三样有三处必须一致(attribute、两处 NoWarn、门禁),逐处写字面量等于允许任意一处静默漂移。默认的 "for evaluation purposes only" 被替换掉了 —— 它没说清那个真正决定要不要用的事实:没有 live client 协商 v2,基于这些类型写的东西今天连不上任何真 Agent,而 CLI 是原样打印 Message 的。

两处项目级 NoWarn 是被迫的,不是偷懒。 JSON 源生成器产出 AcpJsonContext.*.g.cs,里面点名每个注册的草案类型,而手写文件里的 #pragma 覆盖不到生成代码 —— 摘掉 NoWarn 实测 615 个 error,SDK 编不出自己的 contract。代价写在它落地的地方:AcpClient.cs 里的标记也一起被静音,所以编译器不再为「生产入口」那条规则兜底,门禁必须接过这个重量。

PublicSurface.Types.txt 每行加显式 stable|draft 分类,缺分类解析失败而不是默认成 stable。默认就是那个失效模式:新增导出类型本来就必须写进这个文件(既有门禁强制),唯一漏网路径就是「加了清单、忘了 attribute」;现在必须在一个明显是 v2 更新的类型旁边亲手写下 stable,那是评审能看见的主张,不是没人能看见的遗漏

8 条门禁,每条都靠破坏它验证过。 分类判定是双向补集而不是两集合相等 —— 「带标记集合 == draft 集合」在两处都漏时会绿,而那正是要抓的错。

旁路被钉死而不是被无视。 源生成器为每个注册类型产出公开 JsonTypeInfo<T> 且不复制 attribute,26 个草案 contract 因此可以在完全不点名的情况下拿到 —— 端到端实测:零诊断、读写成功。attribute 关不掉它(public DTO 上做 internal context 是 CS0053;删注册也没用,类型仍通过 SessionUpdate 的多态元数据可达),所以门禁遍历每个稳定导出类型的每个消费者可见签名,逐名放行恰好这 26 个。第 27 个注册转红,陈旧条目也转红。

消费方 smoke 现在测真东西。 生成一个点名全部草案 contract、不带任何抑制的消费方,要求 build 失败且 error SEACP002 逐个引用类型名、定位在 Program.cs(行,列)对一个故意剥掉标记的包验证过:抓到了。

顺带处理的两处公开面遗留

SessionListCursor 删掉 —— 从来没接线(session/list 两侧都用 string?),唯一自证测试测的是它自己的隐式转换。接线不是可选方案:它唯一可能的消费方是 session/list DTO,那是稳定面,接上去等于让稳定类型点名 v2-only 类型 —— 正是本 PR 要拆开的耦合。

SessionPromptResponse.HasStopReason 不打标,改文档。 它读起来像 v2-only,实际与协商版本无关:v1 里 stopReason 必填,所以 false 就是「对端违反了 v1」,而这是唯一可观测的地方(StopReason 是值类型、默认 EndTurn,缺字段否则读作"正常结束")。给它打标等于为了门控 v2 而封掉 v1 唯一的违规检测口。

反向验证:13 + 4 条,全红,0 假绿

破坏点 该转红的门禁 结果
摘掉一个类型的 [Experimental] 分类判定 🔴
清单把一条 draft 改成 stable 分类判定 🔴
AcpJsonContext 加第 27 个草案注册 旁路钉死 🔴
钉死表删一条 / 加一条陈旧条目 旁路钉死 🔴 🔴
把 id 挪进全仓 props(编译仍全绿 按工程静音 🔴
换成第二个 id 且两处 NoWarn 都放行(编译仍全绿 单一 id 🔴
丢掉 Message/UrlFormat 诊断文本 🔴
改 README 标题(模拟链接腐烂) 文档锚点 🔴
SessionUpdate 真加一条草案判别子 / 钉死表删一条 判别子清点 🔴 🔴
给稳定成员打成员级标记 类型级限定 🔴
清单某行缺分类列 清单解析 🔴
对剥掉标记的包跑 smoke 「必须编不过」 🔴
包版本写坏(9.9.9 restore/build 分离 🔴 无假 passed
draft build 以 MSB 原因失败 基础设施失败排除 🔴 正确诊断
摘掉 SDK 的 NoWarn 编译本身 🔴 615 errors

三个会让门禁「看起来绿、实际啥也没保护」的坑(都实测过)

set -o pipefail + grep -q = 静默反转搜索。 printf '%s' "$out" | grep -q needle 在 pipefail 下会在 grep 提前匹配退出时报「没找到」:printf 吃 SIGPIPE(141),pipefail 采用了这个状态。这不是理论 —— 它让逐类型断言把 37 个里的 4~6 个报成没打标,非确定性,而且偏向报错日志最靠前的那些(也就是它最有把握的那些)。隔离复现:payload 超过管道缓冲后 200/200 假阴性。修法是构建输出落文件,所有断言 grep 文件。

「build 必须失败」在包版本写坏时会假绿。 装不上的包同样产出 "Build FAILED",但零 SDK 诊断。restore 现在独立断言,NU/MSB/NETSDK 失败显式排除并报成基础设施失败。

别 grep 裸 token。 成功的 build 在高 verbosity 下会回显 csc 自己的 nowarn: 参数,SEACP002 出现 2~5 次 —— 任何人提高 -v 就会让断言反转。全部匹配 error SEACP002

验证

  • run-acp-sdk-gates.sh 六步全绿:formatting、SDK build(analyzers,0 warning)、tests build、468 测试 0 失败、pack + ApiCompat 对 1.0.0 基线
  • 加固后的消费方 smoke 对真实 nupkg 全绿:37 个草案 contract 全部拒绝编译、两种文档化抑制都能编过并跑通、只用稳定面的反向 case 零诊断
  • 三个仓内消费方(Application / Infrastructure / Presentation.Core)编译 0 warning、SEACP 零命中
  • 7 个提交逐个验证过 build + 468 测试 + formatting,都是绿的
  • rebase 到最新 develop(0 落后)

第二部分 · #166 按协商版本选择 wire 面

原「范围边界:不含 #166」那段已作废 —— 它说 #166 的修法取决于「7 个 v2 判别子留在 [JsonDerivedType] 还是搬进手工转换器」。查了官方规格之后两条都不选,见下。

缺陷

v1 已协商的连接会物化 v2 契约。所有版本检查都在写一侧,读路径压根没有「协商版本」这个概念 —— agent 往 v1 client 发 terminal_update / agent_message / state_update,SDK 照样绑成草案类型递给上层,而这条连接写不回去

顺带挖出一个镜像缺陷,#166 正文里没有

把上游 schema 当 JSON 读(站点 markdown 会截断,拉 protocol/v2/schema.md 时内容在 ElicitationPropertySchema 中途就断,到不了 SessionUpdate):

联合写法 discriminator 兜底变体 具名变体
v1 oneOf 0 → 封闭 11
v2 anyOf 1 → 开放 16
仅 v1 有(v2 已删): current_mode_update, plan, tool_call
仅 v2 有:           agent_message, agent_thought, plan_update, state_update,
                     terminal_output_chunk, terminal_update, tool_call_content_chunk, user_message
两版共有 8 个

两个面互不为超集。 那张 19 条的单表同时把两个版本都服务错了 —— v2 一跑起来会照样认 v2 明确删掉的三个变体。这也说明「把 7 个草案判别子直接删掉」那个省事方案是错的:修一半、留一半。

方案:版本是序列化契约的属性

规格原话(protocol/v2/migration.md):

a single connection always speaks exactly one negotiated version after initialize

Each side selects its v1 or v2 surface per connection based on the negotiated version.

「挑一个面」在 STJ 里就是 IJsonTypeInfoResolver 的活。AcpWireFormat 是携带协商版本的 resolver,每个已建模版本一份冻结 options;SessionUpdateWireSurface 是唯一那张「哪些版本定义哪个判别子、绑什么类型」的表,注册项从表重建而非打补丁(既增又删时打补丁需要两份列表)。

被否掉的两条路都是把版本放错层:静态特性表达不了运行时版本;环境状态(AsyncLocal)读方向看不见 —— 那正是「读得进写不出」的成因。

静态特性收窄成只声明 v1 面,这点值得看两遍:特性是静态的,它声明什么,每个未版本化调用方就看见什么,包括公开的 AcpJsonContext.Default。声明并集等于把默认变成一个没人协商的版本;声明 v1 让默认等于 AcpProtocolVersion.Default,未版本化路径只是「稳定」而不是「错」。

state_update 得单独补一句检查:它的内层判别子与外层同级,只能由 params 转换器手工分派,resolver 伸不进那个分支 —— 少了这句,它就成为 v1 连接唯一还在绑的 v2 更新,缺陷在机制唯一看不见的地方存活。

顺带删掉 AsyncLocal

版本挂在契约上之后,6 处 AcpProtocolWriteContext.Current 都能从手上已有的 options 取值,63 行 AsyncLocal 整个删掉。两个写辅助函数需要把 options 穿进去(StructuredDiffWireFormat.WriteMcpServerConfig.WriteStdio)—— 这是不用环境状态的诚实代价。

InitializeTypes 故意不动:它从报文自带的 value.ProtocolVersion 分叉,比环境状态和契约都更好 —— initialize 正是版本被提出的地方,读不到「已协商」的版本。

一个值得单独点名的陷阱

STJ 源生成默认模式的 fast-path 会架空 options。 它按上下文实例解析嵌套契约:

JsonSerializer.Serialize(writer, value.ReplayFrom, SessionReplayFrom);
//                                                 ↑ 属性读的是 AcpJsonContext.Default.Options

于是携带在 options 上的版本跨一层嵌套就没了:拿 v2 契约序列化 SessionResumeParams,进到 SessionReplayFromJsonConverter 手里已经是 v1 options,抛 replayFrom is only available in protocolVersion 2 —— 而调用点看不出跨过了任何边界。

改成 GenerationMode = JsonSourceGenerationMode.Metadata,序列化走 metadata 机制,嵌套契约按当前 options 解析。用一个序列化微优化换「版本在任何深度都是同一个意思」。门禁断言 SerializeHandler 为 null,不只靠行为测试 —— 行为测试只覆盖它恰好走到的嵌套。

写方向:安静 ≠ 安全

裁剪读契约只让写方向变安静。未定义的变体没有注册项可走,会被当基类写出去 —— 基类只带 extension data,所以一个字段填满的 v2 更新出去是 {}比它替代掉的协议违规更糟:对端看到的是个「看起来合规」的更新。params 转换器里一处表驱动闸门按名字和版本拒掉,两个方向一起覆盖。

不是StructuredDiff 无法 round-trip 的那个形状 —— 那是写闸门顶替缺失的读闸门。这里读侧已经不绑了,收到的东西碰不到闸门,只有「构造了本连接发不出的契约」的调用方会碰到。

StructuredDiff 本身是那个旧形状最后一例,同样修法:v1/v2 共用 "diff" 判别值、只靠载荷形状区分,所以稳定连接上结构化 diff 现在走 passthrough(v2 形状被建模之前它就落在那儿),断言的是逐字 round-trip 而不只是「不是 StructuredDiff」。按 flat 读会更糟:静默产出空的 path/oldText/newText。

收口 AcpJsonContext.Default

InitializeTypes 与 elicitation 转换器里有 23 处AcpJsonContext.Default 取嵌套契约而不是用手上的 options。今天无害(那些子树都不区分版本),但它就是手工版的 fast-path 缺陷,会隐形到有人在下面放一个版本相关的转换器。全部改用作用域内的 options(需给 11 个静态辅助函数穿参),门禁断言 SDK 只经 wire format 拿契约,白名单恰好两处:AcpWireFormat(接缝)与 MessageParser(JSON-RPC 信封,确实与版本无关且在协商之前解析)。

两处文档是而不只是旧

  • SessionUpdate.ExtensionData 的注释写「协议要求未知更新逐字保留并 round-trip」。v1 没有这条 —— v1 的 SessionUpdate 是封闭 oneOf,它自己的扩展性规则只说 SHOULD ignore 未识别通知;逐字保留在 v1 是被允许而非被要求。仍然选保留,理由现在写成了理由(AGENTS 禁止收紧既有宽松度、代理要原样转发、UnknownUpdateKind 才能把违规归给对端)。
  • 打包 README 说 v1 连接会通过多态元数据物化草案类型 —— 第二部分的第 2 个提交起就不成立了。

第二部分的反向验证:14 条,全红 0 假绿

破坏点 该转红的门禁
resolver 不生效 → v2 少 7 个变体 / 仍认 3 个 v1-only / 契约与协商面不符 🔴 🔴 🔴
摘掉 state_update 读侧闸门 🔴
v2 判别子加回属性表 🔴
表项版本归属改错 🔴
草案契约进入 v1 面 🔴
删一条测试载荷 🔴
state_update 变成普通注册 🔴
关掉未知判别值回落 🔴
摘掉统一写闸门 🔴
v1 结构化 diff 仍绑草案类型 🔴
恢复 fast-path 生成(机制断言 + 跨嵌套行为) 🔴 🔴
某转换器改回未版本化上下文 🔴

有一条我第一次配错了对:拿「resolver 不裁剪」去测 v1 passthrough,结果绿 —— 因为 v1 passthrough 靠的是属性表已收窄成 v1,不靠 resolver。改成对 v2 侧配对后三条全红。那条链两个环节各有门禁:AttributeRegistrations_AreExactlyTheStableSurface 守属性表 == 表的 v1 子集,ResolvedContract_CarriesExactlyTheNegotiatedSurface 守 resolver 真按表重建。


合并后的整体验证

  • run-acp-sdk-gates.sh 六步全绿,509 测试 0 失败,pack + ApiCompat 对 1.0.0 基线
  • 消费方 nupkg smoke 全绿:37 个草案 contract 逐个拒编、两种文档化抑制可用、稳定面反向 case 零诊断
  • 间接依赖 SDK 的三个测试工程本地全绿:Application 120、Infrastructure 734(6 skip)、Presentation.Core 3291 —— 0 失败
  • 下游三工程(Application / Infrastructure / Presentation.Core)编译 0 warning 0 error
  • 11 个提交按阶段推进,每阶段独立可验证;第二部分是:接缝(零行为变化)→ 按版本裁剪 → 删 AsyncLocal → 写闸门与收口
  • rebase 到最新 develop(0 落后)

已知残留

AcpJsonContext.Default 仍是公开的,消费方自建传输时可以拿它绕过版本约束 —— 但它现在解析出的是稳定面,绕过的结果是拿到 v1 契约,安全方向。README 写明了。

🤖 Generated with Claude Code

YoungSx and others added 7 commits September 2, 2026 19:52
SessionListCursor was modeled with the rest of the v2 wire but never wired
up: session/list carries the cursor as string? on both SessionListParams and
SessionListResponse, and nothing else in the SDK accepts or returns the
struct. Its only test asserted its own implicit conversions.

Wiring it in is not the alternative it looks like. Its only plausible
consumers are the session/list DTOs, which are stable surface, so a stable
type would end up naming a v2-only type - the exact coupling the draft
surface has to avoid. Freezing it as evaluation-only API instead would ship a
type nobody can reach.

It has never been published (1.0.0 exports 153 types and this is not one of
them), so removing it now costs nothing and package validation has nothing to
compare. Whoever implements v2 pagination decides the shape then, with a
caller in hand.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
PR #160 modeled the v2 wire contracts and put 37 types on the public surface
with no compile-time signal at all. A 1.x consumer could build against a
protocol whose client lifecycle is unimplemented and which no live connection
negotiates, and find out only when initialize refuses the version. AGENTS.md
already forbade shipping draft wire from production entry points; nothing
enforced it past the SDK's own boundary.

Every one of those 37 types now carries [Experimental(SEACP002)], which the
compiler reports as an error by default rather than a warning. The id, the
diagnostic text and the documentation link are single-sourced in
AcpDraftProtocol because three places have to agree on them - the attributes,
the NoWarn entries, and the gates - and a literal at each site lets any one of
them drift silently. The default "for evaluation purposes only" wording is
replaced: it is true but omits the fact that decides whether to use the type
at all, and the CLI prints Message verbatim.

The two project-level NoWarn entries are forced, not chosen. The JSON source
generator emits AcpJsonContext.*.g.cs naming every registered draft type, and
a #pragma in a hand-written file cannot reach generated code - without the
suppression the SDK reports 615 errors and cannot compile its own contracts.
Verified by removing it. The cost is stated where it lands: the marker is also
silent inside AcpClient.cs, so the compiler no longer backstops the rule about
production entry points, and gate tests have to carry that weight instead.

The packaged README documents the id, both working suppressions, and the fact
that [SuppressMessage] is not one of them - SEACP002 comes from the compiler,
not an analyzer, so no attribute-based suppression applies at any category. It
also documents the channel the attribute cannot close: AcpJsonContext exposes
a JsonTypeInfo<T> per registered type without copying the attribute onto it,
so 26 draft contracts remain reachable without ever naming one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ifest

PublicSurface.Types.txt pinned which types are exported but said nothing about
which of them are supported and which are v2 draft. That distinction now
matters, and putting it in a second checked-in list would give the two lists
independent lives - the marking could be added or removed on one side without
the other noticing.

Each line becomes "<type name> <stable|draft>" and the tag is mandatory: an
untagged entry fails to parse rather than defaulting to stable. Defaulting is
the whole failure mode. A new draft type has to be added here anyway (the
existing gate forces that), so the only remaining way to ship one unmarked was
to add it and forget the attribute; now the author has to write the word
"stable" next to a type named after a v2 update, which is a claim a reviewer
can see rather than an omission nobody can.

Parsing moves into PublicSurfaceManifest so the next gate reads the same file
through the same reader instead of re-deriving the format.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The two NoWarn entries the marking needs mean the compiler is silent about the
draft surface inside the SDK, so nothing there can notice a type that was
modeled and never marked. These eight gates are what replaced it, and each one
was verified by breaking it.

The classification gate is a complement in both directions, not an equality
between two collections: "marked set equals draft set" passes when a new type
is absent from both, which is the mistake it exists to catch. Marked-but-stable
and classified-but-unmarked are reported separately, each naming the types.

The serialization-context bypass is pinned rather than papered over. The JSON
source generator emits a public JsonTypeInfo<T> per registered type without
copying the attribute, so 26 draft contracts stay reachable without naming
one - measured end to end: zero diagnostics, successful round-trip. The
attribute cannot close that (an internal context over public DTOs is CS0053,
and dropping registrations does not help while the types remain reachable
through SessionUpdate's polymorphic metadata), so the gate walks every
consumer-visible signature on every stable exported type and allows exactly
those 26 by name. A 27th registration turns it red. So does a stale entry:
a pin that no longer describes anything would silently absorb a future member
with the same name.

The remaining gates cover what would otherwise fail quietly rather than loudly:
a second diagnostic id (a marked type is immune to the diagnostic of any id, so
two ids would let one draft family reference another while both look gated);
the id moving into repository-wide props, which compiles everything green while
silencing the marker for the production projects; the diagnostic losing its
text or its link, which is all a consumer building from a terminal ever sees;
the link's anchor no longer existing in the packaged README; a member-level
marking, which the type-level manifest cannot record; and the draft
discriminators declared on the stable SessionUpdate root, pinned because each
one is a path by which a v1-negotiated read materializes a draft type.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The member read as v2-only, which made it look like draft surface that had been
missed. It is not. The check behind it (TryGetProperty on stopReason) never
consults the negotiated version, and on v1 stopReason is required - so false
means the Agent violated the contract, and this is the only place that is
observable. StopReason is a value type defaulting to EndTurn, so a missing
field otherwise reads as "the turn ended normally". A v1 client that wants to
attribute the fault to the peer instead of absorbing it has to read this.

Marking it would have closed v1's only protocol-violation signal in the name of
gating v2. The doc now states both readings and says outright why it carries no
draft marking, so the next reader does not have to re-derive that.

Also drops a summary block for _meta that had drifted onto the constructor,
leaving it with two summaries in the shipped documentation file.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ge consumer

The gate that matters most is the one run against the real nupkg: it is the only
check that sees what an external consumer sees, and it was proving one stable
type resolves. It now also generates a consumer that names every classified
draft contract with no suppression and requires the build to fail with
error SEACP002 quoting each type, at a Program.cs location - so the diagnostics
demonstrably come from consumer source rather than from inside the package.
Verified against a deliberately unmarked package: caught.

The draft list is read from PublicSurface.Types.txt rather than duplicated here,
so a newly modeled contract is covered when it is classified instead of when
somebody remembers this script.

Three ways this gate could have certified nothing are closed:

Restore is asserted before any build. A package that cannot be resolved also
produces "Build FAILED" with zero SDK diagnostics, so a gate asserting only that
the build failed passes for a broken package reference. Reproduced with a
version-mismatched nupkg; NU/MSB/NETSDK failures are now excluded explicitly and
reported as infrastructure failures instead of being read as the marking working.

Diagnostics are matched as "error SEACP002", never as a bare token: a successful
build echoes csc's own nowarn: argument at higher verbosities, so a token match
inverts the moment anyone raises -v.

Build output goes to a file, and every assertion greps that file. Piping a
captured variable into grep -q under set -o pipefail silently inverts the search:
grep exits on the first match, printf dies of SIGPIPE, and pipefail adopts 141.
This was not theoretical - it made the per-type check report 4 to 6 of 37
contracts as unmarked, nondeterministically and preferentially for the
diagnostics nearest the top of the log. Measured in isolation at 200/200 false
negatives once the payload exceeds the pipe buffer.

Both documented suppressions are exercised too, because a marking nobody can
opt out of would make the packaged README wrong, and a consumer touching only
stable v1 API must see no draft diagnostic at all - the one direction that shows
the 37 markings did not spill onto supported surface.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…c surface

Records the rule the preceding commits implement, with the verification list, so
the next draft surface does not have to rediscover which of these checks are
load-bearing and which read as green while protecting nothing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
salmonegg Ready Ready Preview Sep 3, 2026 5:37am UTC

YoungSx and others added 4 commits September 3, 2026 13:29
ACP negotiates a single major version per connection, and the specification is
explicit about what follows: "a single connection always speaks exactly one
negotiated version after initialize", and "each side selects its v1 or v2
surface per connection based on the negotiated version". Nothing in the SDK
represented that surface. The client held an int and 44 call sites each named
AcpJsonContext.Default themselves, so "which wire contract am I speaking" was
not a thing the code could answer, let alone vary.

AcpWireFormat is that thing: an IJsonTypeInfoResolver carrying the negotiated
version, with one frozen JsonSerializerOptions per modeled version. Selecting a
surface is a contract decision, and in System.Text.Json the object that decides
which contract applies is the resolver - so that is where the version lives,
rather than in static attribute metadata (which cannot vary at runtime) or in
ambient state (which the read direction never sees).

Its options are copy-constructed from the generated context rather than
re-declaring the same five knobs. They are already stated once in
AcpJsonContext's [JsonSourceGenerationOptions], the DTO contracts were authored
against those, and a wire format whose knobs disagreed with the contracts it
serves is not a wire format.

ToElement/FromElement no longer take a JsonTypeInfo. That is the point rather
than a tidy-up: a call site can no longer name a serialization context, so it
can no longer name the wrong one - the 44 sites are now structurally unable to
escape the connection's contract instead of merely being expected not to. The
type argument stays explicit so the contract follows the declared protocol type
rather than whatever static type a local happens to have. _protocolVersion
becomes a projection of the contract for the same reason: a copy could drift,
and two views of the negotiated version disagreeing is the defect class this
whole refactor exists to remove.

No behavior change yet - both versions still resolve the full discriminator
table. Pruning it to the negotiated surface is the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…defines

A v1-negotiated connection materialized v2 contracts. Every version check lived
on the write side, so the read path had no notion of a negotiated version at
all: an Agent sending terminal_update, agent_message or state_update to a v1
client got those contracts bound and handed upstream, on a connection that could
not write them back.

Reading the upstream schema as JSON rather than as the rendered docs page (which
truncates before SessionUpdate) shows the defect is not one-directional, which
nobody had noticed. v1's SessionUpdate is a closed oneOf with a discriminator
keyword and 11 named variants; v2's is an anyOf with 16 named variants plus an
open fallback. v2 adds eight - and removes three that v1 defines: tool_call,
plan and current_mode_update. Neither surface is a superset of the other, so the
single 19-entry table served both versions wrongly at once, and a v2 connection
would have bound the three variants v2 deleted.

SessionUpdateWireSurface is now the one table saying which versions define which
discriminator and what it binds to, and AcpWireFormat assembles the negotiated
surface from it. The registrations rebuild rather than patch, because with
additions and removals in both directions a patch would need two lists to stay
in step with one table, and the table is the only thing that should have to be
right.

The attribute registrations are cut back to the v1 surface, which is the part
worth reading twice. Attribute metadata is static, so whatever it declares is
what every unversioned caller sees - including the public AcpJsonContext.Default.
Declaring the union made that default a protocol version nobody negotiates;
declaring v1 makes it equal AcpProtocolVersion.Default, so the unversioned path
is merely stable instead of wrong, and reaching for it yields no draft contract.

state_update needed the check spelled out separately: its inner discriminator is
a sibling of the outer one, so the params converter dispatches it by hand where
the resolver has no reach. Without that it would have remained the one v2 update
a v1 connection still bound - the defect surviving in the single place the
mechanism cannot see.

Unknown variants keep falling back to the base type with their payload
preserved, on both surfaces. v2's schema requires the fallback; v1's closed union
means the specification permits ignoring or rejecting instead, so preserving is
this SDK's choice - AGENTS.md forbids reducing existing leniency, a proxy has to
forward what it cannot read, and UnknownUpdateKind can then attribute the
violation to the peer rather than swallow it. Asserted, so it cannot be reversed
by accident.

The tests that asserted v2 shapes through the default context were asserting the
defect. They now name their version at the call site through a Wire helper,
because with two different vocabularies "the default context" is not a neutral
choice - it is v1.

Reverse-verified, each case red: resolver not applied (v2 loses its seven
resolver-supplied variants and still binds the three v1-only ones), state_update
check removed, a v2 discriminator returned to the attribute table, a table entry
reclassified, a draft contract placed in the v1 surface, a test payload dropped,
state_update turned into a normal registration, and the unknown-variant fallback
disabled.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ent state

AcpProtocolWriteContext was an AsyncLocal that only the write path ever set, and
that asymmetry was the shape of the bug: six converters branched on it while
reading, and nothing on the read side could branch at all. With the version now a
property of the serialization contract, each of those six takes it from the
options it was already handed, and the AsyncLocal is deleted. Two write helpers
needed the options threaded through to get there - StructuredDiffWireFormat.Write
and McpServerConfig.WriteStdio - which is the honest cost of not having ambient
state.

InitializeTypes is deliberately untouched. It branches on value.ProtocolVersion,
the version carried in the payload being written, which is a better source than
either an ambient scope or the contract: the initialize request is where the
version is being proposed, so it cannot be read from an already-negotiated one.

Making the version options-carried surfaced a trap worth naming. System.Text.Json
source generation's default mode emits a per-type fast-path SerializeHandler, and
that handler resolves nested contracts from the context instance rather than from
the caller's options - it is generated as
JsonSerializer.Serialize(writer, value.ReplayFrom, SessionReplayFrom), where the
property reads AcpJsonContext.Default.Options. So the negotiated version silently
did not survive one level of nesting: serializing SessionResumeParams through the
v2 contract reached SessionReplayFromJsonConverter holding v1 options and threw
"replayFrom is only available in protocolVersion 2", with nothing at the call site
suggesting a boundary had been crossed. AcpJsonContext is now metadata-only, which
routes serialization through machinery that resolves nested contracts from the
options in force. That trades a serialization micro-optimization for the version
meaning the same thing at every depth.

AcpWireFormatTests asserts SerializeHandler is null rather than relying only on
behavior, because a behavior test covers the nestings it happens to exercise and
the next version-dependent converter added under a fast-path type would
reintroduce this with no failing test to show it.

StateSessionUpdate_ReadIsNotVersionGated is deleted. It asserted the defect - that
a v1 read binds a v2 contract - and both directions are now covered by
SessionUpdateVersionSurfaceTests. The remaining tests drop the ambient scope and
name their version at the call site; "WriteContext" is gone from their names
because the concept is gone from the code, and a test named after a deleted
mechanism sends the next reader looking for it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Pruning the read contract per version fixed what a connection accepts; it left
the write direction quiet rather than safe. An update whose discriminator the
negotiated version does not define has no registration to serialize through, so
it is written as the base type - which carries nothing but extension data, so a
fully populated v2 update leaves as {}. That is worse than the protocol
violation it replaced: the receiving Agent sees a well-formed-looking update
instead of an obviously wrong one. One table-driven guard in the params converter
now rejects it by name and version, covering both directions at once.

This is not the shape that made StructuredDiff unable to round-trip. That was a
write guard standing in for a missing read gate, so received payloads got bound
and then could not be written back. Here the read side already passes them
through untyped, which means nothing received can reach the guard - only a caller
who constructed a contract their own connection cannot send.

StructuredDiff itself is the remaining case of that old shape, and it is fixed
the same way: v1 and v2 share the "diff" discriminator and only the payload shape
distinguishes them, so on a stable connection a structured diff now goes to
passthrough - where it landed before the v2 shape was modeled - instead of
binding to a contract v1 refuses to write. Reading it as the flat form would have
been worse still, silently producing empty path/oldText/newText. Asserted as a
verbatim round-trip rather than merely "not StructuredDiff", because the point is
that nothing is lost.

23 sites in InitializeTypes and the elicitation converters resolved nested
contracts from AcpJsonContext.Default instead of the options they were handed.
Harmless today - none of those subtrees is version-dependent - but it is the
fast-path failure arrived at by hand, and it would stay invisible until someone
put a version-dependent converter underneath. They take the options in scope now,
which needed threading through eleven static helpers, and a gate asserts the SDK
reaches contracts only through the wire format, allowing exactly AcpWireFormat
(the seam) and MessageParser (the JSON-RPC envelope, which is genuinely
version-independent and parsed before negotiation).

Two documentation claims were wrong rather than merely stale. SessionUpdate's
ExtensionData comment said the protocol requires unknown updates to be preserved
verbatim; v1's SessionUpdate is a closed oneOf whose own extensibility rules only
say implementations SHOULD ignore unrecognized notifications, so preservation is
permitted there, not mandated. It is still the right choice and the reasons are
now stated as reasons. The packaged README said a v1 connection materializes
draft types through polymorphic metadata, which stopped being true one commit
ago.

Reverse-verified, each case red: write guard removed, v1 structured diff bound
again, fast-path generation restored (both the mechanism assertion and the nested
version-propagation behavior), a converter returned to the unversioned context,
and the README anchor renamed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@YoungSx YoungSx changed the title feat(acp): 把 v2 草案公开面标记为实验性并封堵可封堵的旁路 feat(acp): 标记 v2 草案公开面,并按协商版本选择 wire 面 Sep 3, 2026
@YoungSx
YoungSx merged commit d46d8cd into develop Sep 3, 2026
17 checks passed
@YoungSx
YoungSx deleted the feat/acp-draft-surface-165 branch September 3, 2026 05:44
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