Skip to content

feat(acp): 把 v2 草案公开面标记为实验性,并封堵 AcpJsonContext 旁路 #165

Description

@YoungSx

优先级与时间窗

高,且有硬时限。 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.DraftPropd 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.mdLICENSE、nuspec,没有 build/ / buildTransitive/grep -ril "nowarn|SEACP" 零命中。继承链也干净(根 Directory.Build.props 无 NoWarn,src/ 下无 Directory.Build.props)。所以消费方侧的保护是真的 —— 仅限"具名引用"这条路径。

仓内爆炸半径为零(已全量核实)

三个 ProjectReference 消费方(SalmonEgg.ApplicationSalmonEgg.InfrastructureSalmonEgg.Presentation.Core)对 38 个类型名 0 命中;它们只用 AcpProtocolVersion.Default。但 tests/SalmonEgg.Acp.TestsNoWarn硬需求:38 个类型在 7 个测试文件里被引用 88 次,漏了它 run-acp-sdk-gates.sh 会在 [gate] Build ACP SDK tests with analyzers 直接红。

要做的事

1. 打标(38 个类型 + 诊断 id)

单一诊断 id(建议 SEACP002SEACP001 已被 AcpProtocolVersion.Latest[Obsolete] 占用)。不建议按生命周期段分组 id:上游 v2 整体是 Draft,不存在「terminal 稳了但 diff 没稳」的状态,分组 id 会造出「可逐段启用」的错觉,正是 AGENTS.md §11 明令禁止的。

38 个类型已逐名对照 v1/v2 schema 核实全部是 v2-onlyIconSessionListCursorMcpHttpCapabilitiesTerminalOutput 这几个看起来像共用的,在 v1 schema 里都是 0 命中);新增到共享 SessionUpdate 的 8 个判别值在 v1 schema 同样零命中,与 v1 的 agent_message_chunk / plan 不冲突。

2. 两处项目级 NoWarn

src/SalmonEgg.Acp/SalmonEgg.Acp.csprojtests/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 手工分派(同文件 :224StateSessionUpdateWireFormat 就是现成先例),并把 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 APIsrc/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 在把包版本改坏时不得误判为通过。

参考资料

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority: highHigh priority; has a hard deadline or blocks other work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions