Skip to content

fix(acp): v1 协商连接仍会读写 v2 草案 wire(读路径无闸门 / 四族无写闸门 / structured diff 破坏 round-trip) #166

Description

@YoungSx

优先级与时间窗

高。 这些是已经在 develop 上的行为缺陷,不是未来功能:v1 已协商的连接现在就会读出、并在某些路径写出 v2 草案 wire。与 #149 的关系是:#149 记录「v2 有哪些东西没建模」,本 issue 记录「已建模的这些东西泄漏进了 v1 通道」。

修法与 #165(草案面公开边界)有交集(读路径的正解顺带会被那边的重构做掉),建议两个一起排,并先定公开面方向再动手 —— 否则这里做的写路径闸门会被返工。

结论

PR #160 建模的 v2 wire 与 v1 通道之间没有完整隔离,三处具体缺陷:

  1. 读路径完全没有版本闸门 —— v1 连接收到 v2 判别值仍会物化成草案类型交给上层。
  2. 四族在 V1 默认写上下文照常吐 v2 报文 —— whole-message、terminal、tool_call_content_chunk、plan_update。
  3. StructuredDiff 在 v1 读得进、写不出 —— 破坏 round-trip,违反 AGENTS.md §11「协议宽松度不得反向收紧」明文的原样 round-trip 要求。

这三条合起来意味着 AGENTS.md §11 要求的「显式草案上下文的隔离 wire 测试」目前只覆盖 2/6 族

证据链

1. 版本检查全都只在 Write 一侧

src/SalmonEgg.Acp/Protocol/SessionWorkStateUpdateTypes.cs:199   Write: if (AcpProtocolWriteContext.Current != V2) throw
src/SalmonEgg.Acp/Protocol/SessionWorkStateUpdateTypes.cs:150-178  Read: 无任何版本检查
src/SalmonEgg.Acp/Tool/DiffTypes.cs:248                          同样只在 Write
src/SalmonEgg.Acp/Protocol/OtherSessionTypes.cs:275              同样只在 Write
src/SalmonEgg.Acp/Protocol/SessionUpdateTypes.cs:70-76           7 个 v2 判别子静态注册在 public SessionUpdate 上

AcpProtocolWriteContext 顾名思义只是上下文(src/SalmonEgg.Acp/Protocol/AcpProtocolWriteContext.cs),读路径没有对应概念。于是在已协商为 v1 的连接上,agent 发来 "sessionUpdate":"agent_message" / "terminal_update" / "state_update",SDK 会照样绑定成草案类型交给上层。

AcpProtocolVersion.RuntimeServed = V1 这个「唯一权威」(AcpProtocolVersion.cs:63)在读方向上其实是半开的。任何只比对这个常量的门禁都发现不了这一点。

2. 四族零闸门(逐文件计数)

Protocol/SessionUpdateTypes.cs              AcpProtocolWriteContext 出现 0 次
Protocol/TerminalUpdateTypes.cs             AcpProtocolWriteContext 出现 0 次
Protocol/V2SupplementalTypes.cs             AcpProtocolWriteContext 出现 0 次
Protocol/SessionWorkStateUpdateTypes.cs     出现 1 次   ← state_update 有闸门
Tool/DiffTypes.cs                           出现 1 次   ← structured diff 有闸门

tests/SalmonEgg.Acp.Tests/Protocol/TerminalUpdateTypesTests.cs:105-129 就是在默认(V1)上下文里断言 terminal_update 的 v2 报文形态成立 —— 现状被测试固化了。

3. StructuredDiff 的 round-trip 破坏

StructuredDiff 与 v1 的 flat diff 共用 "diff" 判别值,只靠有没有 changes 数组区分(src/SalmonEgg.Acp/Tool/DiffTypes.cs:185-187)。于是 v1 连接上收到一个带 changes 的 diff → 绑成 StructuredDiff → 在 V1 上下文不可再序列化:248JsonException)。

在 PR #160 之前,这种载荷会落到 CustomToolCallContent 原样 round-trip。所以这是一次行为回归,而不只是「新功能没做完」。

正解与反模式

反模式:别照抄现有的「写时抛」

现有 state_update / structured diff 用的是「先绑定成草案类型,写的时候如果不是 V2 上下文就抛」。这个形状本身就是次优解 —— 它制造了「读得进、写不出」,也就是第 3 条缺陷的形状。如果给剩下四族照抄这个写法,等于把 round-trip 破坏面从 1 族扩大到 5 族。

正解:读路径分流

v1 上下文下根本不该绑定成草案类型,而应走 passthrough:这样既不吐 v2 报文,又保持原样 round-trip,两条 AGENTS 规则同时满足。

好消息是基础设施已经就位。 SessionUpdate 的多态配置(SessionUpdateTypes.cs:55-58)已经是:

[JsonPolymorphic(
    TypeDiscriminatorPropertyName = "sessionUpdate",
    UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToBaseType,
    IgnoreUnrecognizedTypeDiscriminators = true)]

未知判别值回落到基类 + ExtensionData 承载完整 payload(已有测试 SessionUpdatePolymorphismTests.cs:39 Deserialize_UnknownSessionUpdate_FallsBack…)。也就是说 —— 把 v2 判别子从 [JsonDerivedType] 列表里摘掉,v1 上下文下的 round-trip 就自动是对的。

[JsonDerivedType] 是静态特性,无法按运行时上下文动态注册。要做上下文相关分流,必须走手工分派 —— 而 SessionUpdateParams 已经有手工转换器 SessionUpdateParamsJsonConverterSessionUpdateTypes.cs:113),同文件 :224StateSessionUpdateWireFormat 就是现成先例(注释里已写明 STJ 不支持第二判别子,所以扁平化只能落在容器转换器上)。

这正是它与 #165 的交集:那边若选择把判别子搬进转换器 + 草案面 internal 化,这里的读路径闸门顺带就做完了。所以先定那边的方向。

"diff" 判别值的处置

v1 flat diff 与 v2 structured diff 共用判别值这件事无法靠改判别值解决(协议就是这么定的)。建议:在 v1 上下文下,带 changes 的 diff 走 CustomToolCallContent passthrough(恢复 #160 之前的行为);只在 v2 上下文下绑成 StructuredDiff

验证要求

#149 已定的标准,逐项都要:

  • v1 与 v2 两条线的报文级断言,互不串味 —— v1 报文不得出现 v2 字段,反之亦然。六族都要有(现在只有 state_update 和 structured diff 有)。
  • 读方向也要有断言,不能只测写方向:v1 上下文下喂 v2 判别值 → 断言落到 passthrough 且原样 round-trip 回去(字节级或字段级不丢);v2 上下文下喂同一份 → 断言绑成对应草案类型。
  • 反向验证:拆掉版本判别后,对应报文断言必须转红。
  • TerminalUpdateTypesTests.cs:105-129 这类在默认上下文断言 v2 形态的测试要一并改成显式 v2 上下文,否则它会锁死错误行为。
  • scripts/gates/run-acp-sdk-gates.sh 全绿;scripts/gates/wasm-capability-boundary-smoke.mjs:96-126 已覆盖「真实 WASM initialize 只发 v1 形态」,本次改动不应触及它,但要确认仍绿。

参考资料

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

    bugSomething isn't workingpriority: 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