优先级与时间窗
高,且有硬时限。 PR #160 引入的 38 个 v2 草案公开类型一个都还没发布过 —— 实测 SalmonEgg.Acp 1.0.0 程序集导出 153 个类型,这 38 个全不在其中:
1.0.0 导出类型总数: 153
待核草案类型数: 38
其中 1.0.0 已发布过的: 0
所以在打下一个 acp-sdk-v* tag 之前,公开面形态可以自由调整、PackageValidation 不会拦;打了 tag 之后任何收窄都是破坏性变更 。窗口在发版那一刻关闭。
结论
PR #160 把 #149 清单里的 v2 draft wire DTO 全部建模(Protocol 32 个 + Tool 6 个新公开类型),但它们裸着 进了公开面:没有任何编译期标记。1.x 消费方拿到 nupkg 后可以毫无提示地使用一个连生命周期都没实现、且上游仍是 Draft 的协议面。
方向:保留 public,标记 [Experimental] —— 我们计划实现 v2,草案面最终要转正,中间过程用实验性标记表达「可评估、随上游 draft 变动、不承诺稳定」。
但 [Experimental] 单独用是不够的。 实测存在一条它完全管不到的旁路(见下),必须一并处理;否则消费方 smoke 会为一条有 26 个类型缺口的防线出具绿灯证明。
实测证据链
本机 net10.0(SDK 10.0.302),最小工程逐条验证。
[Experimental] 的真实行为
行为
结果
诊断默认严重性
error ,不是 warning。不设 TreatWarningsAsErrors 也是 error
自标 [Experimental] 的类型引用其他 Experimental 类型
免疫,跨 id 也免疫 → 草案家族内部互引零成本
派生自 Experimental 基类,自己没标
报错;自己标了则免疫
#pragma warning disable ID
能压住普通使用、特性参数里的 typeof(Draft)、成员签名
[SuppressMessage]
完全无效 。Experimental 是编译器诊断(非 DiagnosticAnalyzer 产),换 category 也压不住。文档里不要提这个手段
触发条件
只在具名引用时触发 。stable.DraftProp、d with { }、在推断类型上访问成员,全都不报
成员级标注
有效:读、调、对象初始化器赋值三种都报 error
旁路:STJ 源生成的 JsonSerializerContext
AcpJsonContext 是 public,PR #160 往里加了 25 条 [JsonSerializable(typeof(草案类型))]。源生成器为每个注册类型产出公开 的 JsonTypeInfo<T> 属性,并且不继承 [Experimental] 。
消费方绕一下就完全敞开(已端到端跑通,库侧打标 + 项目级 NoWarn,消费侧无任何抑制):
var ti = AcpJsonContext . Default . StateSessionUpdate ; // 全程不写草案类型名
var x = JsonSerializer . Deserialize ( json , ti ) ; // 编译退出码 0,error SEACP001 出现 0 次
var s = JsonSerializer . Serialize ( x ! , ti ) ; // 读写都成
在打好标注的真程序集上反射统计:52 处 public 成员挂在非 Experimental 导出类型上、签名却提到草案类型,100% 在 AcpJsonContext (26 个 JsonTypeInfo<草案> 属性 + getter),每一个自身都不带 Experimental。
两条封堵捷径实测都不成立:
删掉 [JsonSerializable(typeof(草案))] 不管用 —— 草案子类经 SessionUpdate 的 [JsonDerivedType] 仍可达,生成器照样产 public 属性。相关注册见 src/SalmonEgg.Acp/Protocol/SessionUpdateTypes.cs:70-76(7 个 v2 判别子)。
把 DTO 改 internal 而 context 仍 public → error CS0053: Inconsistent accessibility: property type 'JsonTypeInfo<DraftChild>' is less accessible than property。
项目级 NoWarn 是被迫的,不是偷懒
源生成代码在 obj/**/AcpJsonContext.*.g.cs 里引用草案类型并报 error(最小工程里 17~18 处),手写文件里的 #pragma 覆盖不到生成代码 。只有 <NoWarn>$(NoWarn);SEACP…</NoWarn> 能让 SDK 编过。
代价必须写进注释:整个 SDK 工程(含 AcpClient.cs)被静音,AGENTS.md §11「禁止生产入口静默发送未完整实现的草案 wire」因此失去编译器兜底 ,必须靠反射门禁补偿。
NoWarn 不会泄漏给消费方(已 pack 验证)
-p:MinVerVersionOverride=1.1.0 -p:AcpPackageBaselineVersion=1.0.0 pack 后解包,包内只有 lib/、README.md、LICENSE、nuspec,没有 build/ / buildTransitive/ ,grep -ril "nowarn|SEACP" 零命中。继承链也干净(根 Directory.Build.props 无 NoWarn,src/ 下无 Directory.Build.props)。所以消费方侧的保护是真的 —— 仅限"具名引用"这条路径。
仓内爆炸半径为零(已全量核实)
三个 ProjectReference 消费方(SalmonEgg.Application、SalmonEgg.Infrastructure、SalmonEgg.Presentation.Core)对 38 个类型名 0 命中 ;它们只用 AcpProtocolVersion.Default。但 tests/SalmonEgg.Acp.Tests 的 NoWarn 是硬需求 :38 个类型在 7 个测试文件里被引用 88 次 ,漏了它 run-acp-sdk-gates.sh 会在 [gate] Build ACP SDK tests with analyzers 直接红。
要做的事
1. 打标(38 个类型 + 诊断 id)
单一诊断 id(建议 SEACP002,SEACP001 已被 AcpProtocolVersion.Latest 的 [Obsolete] 占用)。不建议按生命周期段分组 id :上游 v2 整体是 Draft,不存在「terminal 稳了但 diff 没稳」的状态,分组 id 会造出「可逐段启用」的错觉,正是 AGENTS.md §11 明令禁止的。
38 个类型已逐名对照 v1/v2 schema 核实全部是 v2-only (Icon、SessionListCursor、McpHttpCapabilities、TerminalOutput 这几个看起来像共用的,在 v1 schema 里都是 0 命中);新增到共享 SessionUpdate 的 8 个判别值在 v1 schema 同样零命中,与 v1 的 agent_message_chunk / plan 不冲突。
2. 两处项目级 NoWarn
src/SalmonEgg.Acp/SalmonEgg.Acp.csproj 与 tests/SalmonEgg.Acp.Tests/SalmonEgg.Acp.Tests.csproj,都要带注释说明「这是源生成器强迫的,不是放弃保护」。
3. 旁路的受管处置(本 issue 的关键项)
[Experimental] 挡不住 AcpJsonContext,所以必须把它变成受管的、可评审的事实 ,而不是无人知晓的洞:
新增门禁:非 [Experimental] 导出类型的 public 成员签名不得提到草案类型 ,以一张逐个列出、数量钉死 的例外表豁免 AcpJsonContext 的 26 个属性。第 27 个草案类型注册进来就红。
打包 README(src/SalmonEgg.Acp/README.md,它是 PackageReadmeFile)必须写明:SEACP002 是什么、怎么抑制(只有 #pragma / NoWarn 两条,不要写 SuppressMessage )、以及序列化上下文是已知残留通道 。
若将来想真正闭合:把 7 个 v2 判别子从 SessionUpdate 的 [JsonDerivedType] 搬进 SessionUpdateParamsJsonConverter 手工分派(同文件 :224 的 StateSessionUpdateWireFormat 就是现成先例),并把 26 条注册移到 internal 的第二个 context、DTO 一起 internal 化 —— 两步必须同时做,否则撞 CS0053。记在这里备查,本 issue 不做。
4. 漏标门禁(必须 fail-closed)
不要 写成「带 Experimental 的集合 == 草案清单集合」—— 新增类型两处都忘时两个集合同时不变,门禁绿。必须写成补集判定 :
Protocol + Tool 下所有导出类型 − 签入的稳定面白名单 ⊆ 带 [Experimental] 的集合
并且要遍历成员 ,不能只遍历类型(成员级标注反射可见,已验证)。
已核实机械可行性:反射能看到全部 38 个(含 readonly record struct SessionListCursor、静态类 SessionWorkStateKind / IconThemeKind / DiffOperationKind),无嵌套类型、无编译器生成类型干扰。
建议定义成对既有 PublicSurface.Types.txt 基线的谓词 ,而不是第二份清单 —— tests/SalmonEgg.Acp.Tests/Architecture/PublicSurfaceBaselineTests.cs:17 已经用它钉死了 229 个导出名,两份清单会各自漂移。
5. 消费方 smoke 加固(现有脚本会假绿)
扩展 scripts/gates/run-acp-consumer-package-smoke.sh(该 job 在 PR→develop 上会跑,ci-acp-sdk.yml:85-108):
断言 error SEACP002,不要 grep 裸 token。 实测:在成功 的 build 上,-v normal 输出里 SEACP002 会出现 2 次、-v detailed 5 次,来源是 csc 命令行里的 nowarn: 参数。现有脚本用 -v minimal 所以今天安全,但任何人提高 verbosity 就会静默反转断言。
restore 与 build 分开跑并分别断言 :把包版本改成不存在的 9.9.9 实测 → restore 失败、build 失败、Build FAILED、但 SEACP002 出现 0 次。只断言「build 必须失败」的门禁在这里通过,而实际保护为零。还要断言输出不含 NU1101/NU1102/NU1605/MSB,且 SEACP002 出现在 Program.cs(行,列) 前缀行上。
set -euo pipefail 会把预期失败炸掉 ,dotnet build … | sed 的 $? 是 sed 的。必须 set +e; out="$(… 2>&1)"; rc=$?; set -e 再显式判 rc。
对每一个草案类型循环 ,或至少让第 4 项的门禁覆盖全集 —— 只测一个类型是采样不是门禁。
加一个反向 case:消费方只用稳定 v1 面 必须零警告通过 —— 这是唯一能证明 38 个标注没有误伤稳定面的方向。
6. 两处公开面遗留
SessionListCursor 是死 public API :src/SalmonEgg.Acp/Protocol/SessionList.cs:12 与 :22 用的是 string?,没有任何 public 成员接受或返回这个 struct,除一个自证测试外零引用。要么在 v2 分页里真正接线,要么删掉 —— 不该只是「实验性冻结」。
SessionPromptResponse.HasStopReason 不要打标。 src/SalmonEgg.Acp/Protocol/SessionPromptTypes.cs:98 的判定与协商版本无关 (root.TryGetProperty("stopReason", …),缺字段或非字符串时 false 且不抛错)。v1 里 stopReason 是必填,所以 HasStopReason == false 正是「对端违反了 v1 必填」的唯一可观测信号,对 v1 消费方有正当用途(符合 AGENTS.md §11「对端违反协议时的归责与呈现」)。打标等于把 v1 唯一的违规检测口封成实验性。正解按 chore(acp): ACP v2 draft 缺口清账(state_update 生命周期、整消息 upsert、终端归属、diff 结构化等) #149 是把 v2 的 ack 语义拆成独立类型;在那之前保持不打标,并在 XML doc 里写清 v1 用途 。
验证要求
scripts/gates/run-acp-sdk-gates.sh 六步全绿(含 pack 的 ApiCompat 对 1.0.0 基线)。已验证:给 38 个类型全打标 + DiagnosticId 后 pack 1.1.0 ⇒ APICompat ran successfully without finding any breaking changes.(EnableRuleAttributesMustMatch 默认关闭,属性差异不比较)。反向验证也做过:删掉一个已发布成员 ⇒ 正常报 CP0002,门禁是活的。
dotnet format --verify-no-changes 通过。已验证:打好 38 个标注(含新增 using System.Diagnostics.CodeAnalysis;)后 exit 0。注意仓库根没有 .editorconfig (唯一一份在 SalmonEgg/),ACP SDK 用默认规则,using 排序不会咬人。
反向验证(缺一不可) :摘掉任意一个类型的 [Experimental] ⇒ 第 4 项门禁必须转红;给 AcpJsonContext 加第 27 个草案注册 ⇒ 第 3 项门禁必须转红;消费方 smoke 的失败 case 在把包版本改坏时不得 误判为通过。
参考资料
优先级与时间窗
高,且有硬时限。 PR #160 引入的 38 个 v2 草案公开类型一个都还没发布过 —— 实测
SalmonEgg.Acp1.0.0 程序集导出 153 个类型,这 38 个全不在其中:所以在打下一个
acp-sdk-v*tag 之前,公开面形态可以自由调整、PackageValidation不会拦;打了 tag 之后任何收窄都是破坏性变更。窗口在发版那一刻关闭。结论
PR #160 把 #149 清单里的 v2 draft wire DTO 全部建模(
Protocol32 个 +Tool6 个新公开类型),但它们裸着进了公开面:没有任何编译期标记。1.x 消费方拿到 nupkg 后可以毫无提示地使用一个连生命周期都没实现、且上游仍是 Draft 的协议面。方向:保留 public,标记
[Experimental]—— 我们计划实现 v2,草案面最终要转正,中间过程用实验性标记表达「可评估、随上游 draft 变动、不承诺稳定」。但
[Experimental]单独用是不够的。 实测存在一条它完全管不到的旁路(见下),必须一并处理;否则消费方 smoke 会为一条有 26 个类型缺口的防线出具绿灯证明。实测证据链
本机 net10.0(SDK 10.0.302),最小工程逐条验证。
[Experimental]的真实行为TreatWarningsAsErrors也是 error[Experimental]的类型引用其他 Experimental 类型#pragma warning disable IDtypeof(Draft)、成员签名[SuppressMessage]stable.DraftProp、d with { }、在推断类型上访问成员,全都不报旁路:STJ 源生成的
JsonSerializerContextAcpJsonContext是 public,PR #160 往里加了 25 条[JsonSerializable(typeof(草案类型))]。源生成器为每个注册类型产出公开的JsonTypeInfo<T>属性,并且不继承[Experimental]。消费方绕一下就完全敞开(已端到端跑通,库侧打标 + 项目级 NoWarn,消费侧无任何抑制):
在打好标注的真程序集上反射统计:52 处 public 成员挂在非 Experimental 导出类型上、签名却提到草案类型,100% 在
AcpJsonContext(26 个JsonTypeInfo<草案>属性 + getter),每一个自身都不带 Experimental。两条封堵捷径实测都不成立:
[JsonSerializable(typeof(草案))]不管用 —— 草案子类经SessionUpdate的[JsonDerivedType]仍可达,生成器照样产 public 属性。相关注册见src/SalmonEgg.Acp/Protocol/SessionUpdateTypes.cs:70-76(7 个 v2 判别子)。internal而 context 仍 public →error CS0053: Inconsistent accessibility: property type 'JsonTypeInfo<DraftChild>' is less accessible than property。项目级
NoWarn是被迫的,不是偷懒源生成代码在
obj/**/AcpJsonContext.*.g.cs里引用草案类型并报 error(最小工程里 17~18 处),手写文件里的#pragma覆盖不到生成代码。只有<NoWarn>$(NoWarn);SEACP…</NoWarn>能让 SDK 编过。代价必须写进注释:整个 SDK 工程(含
AcpClient.cs)被静音,AGENTS.md §11「禁止生产入口静默发送未完整实现的草案 wire」因此失去编译器兜底,必须靠反射门禁补偿。NoWarn不会泄漏给消费方(已 pack 验证)-p:MinVerVersionOverride=1.1.0 -p:AcpPackageBaselineVersion=1.0.0pack 后解包,包内只有lib/、README.md、LICENSE、nuspec,没有build//buildTransitive/,grep -ril "nowarn|SEACP"零命中。继承链也干净(根Directory.Build.props无 NoWarn,src/下无Directory.Build.props)。所以消费方侧的保护是真的 —— 仅限"具名引用"这条路径。仓内爆炸半径为零(已全量核实)
三个 ProjectReference 消费方(
SalmonEgg.Application、SalmonEgg.Infrastructure、SalmonEgg.Presentation.Core)对 38 个类型名 0 命中;它们只用AcpProtocolVersion.Default。但tests/SalmonEgg.Acp.Tests的NoWarn是硬需求:38 个类型在 7 个测试文件里被引用 88 次,漏了它run-acp-sdk-gates.sh会在[gate] Build ACP SDK tests with analyzers直接红。要做的事
1. 打标(38 个类型 + 诊断 id)
单一诊断 id(建议
SEACP002,SEACP001已被AcpProtocolVersion.Latest的[Obsolete]占用)。不建议按生命周期段分组 id:上游 v2 整体是 Draft,不存在「terminal 稳了但 diff 没稳」的状态,分组 id 会造出「可逐段启用」的错觉,正是 AGENTS.md §11 明令禁止的。38 个类型已逐名对照 v1/v2 schema 核实全部是 v2-only(
Icon、SessionListCursor、McpHttpCapabilities、TerminalOutput这几个看起来像共用的,在 v1 schema 里都是 0 命中);新增到共享SessionUpdate的 8 个判别值在 v1 schema 同样零命中,与 v1 的agent_message_chunk/plan不冲突。2. 两处项目级
NoWarnsrc/SalmonEgg.Acp/SalmonEgg.Acp.csproj与tests/SalmonEgg.Acp.Tests/SalmonEgg.Acp.Tests.csproj,都要带注释说明「这是源生成器强迫的,不是放弃保护」。3. 旁路的受管处置(本 issue 的关键项)
[Experimental]挡不住AcpJsonContext,所以必须把它变成受管的、可评审的事实,而不是无人知晓的洞:[Experimental]导出类型的 public 成员签名不得提到草案类型,以一张逐个列出、数量钉死的例外表豁免AcpJsonContext的 26 个属性。第 27 个草案类型注册进来就红。src/SalmonEgg.Acp/README.md,它是PackageReadmeFile)必须写明:SEACP002是什么、怎么抑制(只有#pragma/NoWarn两条,不要写SuppressMessage)、以及序列化上下文是已知残留通道。SessionUpdate的[JsonDerivedType]搬进SessionUpdateParamsJsonConverter手工分派(同文件:224的StateSessionUpdateWireFormat就是现成先例),并把 26 条注册移到internal的第二个 context、DTO 一起 internal 化 —— 两步必须同时做,否则撞 CS0053。记在这里备查,本 issue 不做。4. 漏标门禁(必须 fail-closed)
不要写成「带 Experimental 的集合 == 草案清单集合」—— 新增类型两处都忘时两个集合同时不变,门禁绿。必须写成补集判定:
并且要遍历成员,不能只遍历类型(成员级标注反射可见,已验证)。
已核实机械可行性:反射能看到全部 38 个(含
readonly record struct SessionListCursor、静态类SessionWorkStateKind/IconThemeKind/DiffOperationKind),无嵌套类型、无编译器生成类型干扰。建议定义成对既有
PublicSurface.Types.txt基线的谓词,而不是第二份清单 ——tests/SalmonEgg.Acp.Tests/Architecture/PublicSurfaceBaselineTests.cs:17已经用它钉死了 229 个导出名,两份清单会各自漂移。5. 消费方 smoke 加固(现有脚本会假绿)
扩展
scripts/gates/run-acp-consumer-package-smoke.sh(该 job 在 PR→develop 上会跑,ci-acp-sdk.yml:85-108):error SEACP002,不要 grep 裸 token。 实测:在成功的 build 上,-v normal输出里SEACP002会出现 2 次、-v detailed5 次,来源是 csc 命令行里的nowarn:参数。现有脚本用-v minimal所以今天安全,但任何人提高 verbosity 就会静默反转断言。9.9.9实测 → restore 失败、build 失败、Build FAILED、但SEACP002出现 0 次。只断言「build 必须失败」的门禁在这里通过,而实际保护为零。还要断言输出不含NU1101/NU1102/NU1605/MSB,且SEACP002出现在Program.cs(行,列)前缀行上。set -euo pipefail会把预期失败炸掉,dotnet build … | sed的$?是 sed 的。必须set +e; out="$(… 2>&1)"; rc=$?; set -e再显式判 rc。6. 两处公开面遗留
SessionListCursor是死 public API:src/SalmonEgg.Acp/Protocol/SessionList.cs:12与:22用的是string?,没有任何 public 成员接受或返回这个 struct,除一个自证测试外零引用。要么在 v2 分页里真正接线,要么删掉 —— 不该只是「实验性冻结」。SessionPromptResponse.HasStopReason不要打标。src/SalmonEgg.Acp/Protocol/SessionPromptTypes.cs:98的判定与协商版本无关(root.TryGetProperty("stopReason", …),缺字段或非字符串时false且不抛错)。v1 里stopReason是必填,所以HasStopReason == false正是「对端违反了 v1 必填」的唯一可观测信号,对 v1 消费方有正当用途(符合 AGENTS.md §11「对端违反协议时的归责与呈现」)。打标等于把 v1 唯一的违规检测口封成实验性。正解按 chore(acp): ACP v2 draft 缺口清账(state_update 生命周期、整消息 upsert、终端归属、diff 结构化等) #149 是把 v2 的 ack 语义拆成独立类型;在那之前保持不打标,并在 XML doc 里写清 v1 用途。验证要求
scripts/gates/run-acp-sdk-gates.sh六步全绿(含 pack 的 ApiCompat 对 1.0.0 基线)。已验证:给 38 个类型全打标 +DiagnosticId后 pack 1.1.0 ⇒APICompat ran successfully without finding any breaking changes.(EnableRuleAttributesMustMatch默认关闭,属性差异不比较)。反向验证也做过:删掉一个已发布成员 ⇒ 正常报CP0002,门禁是活的。dotnet format --verify-no-changes通过。已验证:打好 38 个标注(含新增using System.Diagnostics.CodeAnalysis;)后 exit 0。注意仓库根没有.editorconfig(唯一一份在SalmonEgg/),ACP SDK 用默认规则,using 排序不会咬人。[Experimental]⇒ 第 4 项门禁必须转红;给AcpJsonContext加第 27 个草案注册 ⇒ 第 3 项门禁必须转红;消费方 smoke 的失败 case 在把包版本改坏时不得误判为通过。参考资料
https://raw.githubusercontent.com/zed-industries/agent-client-protocol/main/schema/v2/schema.json(288KB,$defs175 条)。v1 同路径 404,改用站点:https://agentclientprotocol.com/llms.txt→protocol/v1/schema.md。ExperimentalAttribute:https://learn.microsoft.com/dotnet/fundamentals/apicompat/preview-apisObsoleteAttribute.DiagnosticId:https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/obsoletions-overview