feat(acp): 标记 v2 草案公开面,并按协商版本选择 wire 面 - #168
Merged
Merged
Conversation
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]>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This was referenced Sep 2, 2026
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]>
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.
Closes #165. Closes #166.
第一部分 · #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]AcpJsonContext加第 27 个草案注册Message/UrlFormatSessionUpdate真加一条草案判别子 / 钉死表删一条9.9.9)NoWarn三个会让门禁「看起来绿、实际啥也没保护」的坑(都实测过)
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 基线SEACP零命中第二部分 · #166 按协商版本选择 wire 面
缺陷
v1 已协商的连接会物化 v2 契约。所有版本检查都在写一侧,读路径压根没有「协商版本」这个概念 —— agent 往 v1 client 发
terminal_update/agent_message/state_update,SDK 照样绑成草案类型递给上层,而这条连接写不回去。顺带挖出一个镜像缺陷,#166 正文里没有
把上游 schema 当 JSON 读(站点 markdown 会截断,拉
protocol/v2/schema.md时内容在ElicitationPropertySchema中途就断,到不了SessionUpdate):discriminatoroneOfanyOf两个面互不为超集。 那张 19 条的单表同时把两个版本都服务错了 —— v2 一跑起来会照样认 v2 明确删掉的三个变体。这也说明「把 7 个草案判别子直接删掉」那个省事方案是错的:修一半、留一半。
方案:版本是序列化契约的属性
规格原话(
protocol/v2/migration.md):「挑一个面」在 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.Write、McpServerConfig.WriteStdio)—— 这是不用环境状态的诚实代价。InitializeTypes故意不动:它从报文自带的value.ProtocolVersion分叉,比环境状态和契约都更好 —— initialize 正是版本被提出的地方,读不到「已协商」的版本。一个值得单独点名的陷阱
STJ 源生成默认模式的 fast-path 会架空 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.DefaultInitializeTypes与 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才能把违规归给对端)。第二部分的反向验证:14 条,全红 0 假绿
state_update读侧闸门state_update变成普通注册有一条我第一次配错了对:拿「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 基线已知残留
AcpJsonContext.Default仍是公开的,消费方自建传输时可以拿它绕过版本约束 —— 但它现在解析出的是稳定面,绕过的结果是拿到 v1 契约,安全方向。README 写明了。🤖 Generated with Claude Code