docs(i18n): align Agent Note terminology

This commit is contained in:
Tianyi Cui
2026-07-24 13:57:16 +08:00
parent d46471e11f
commit c0eacae2ba
228 changed files with 584 additions and 584 deletions
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-19-acp-snapshot-tests.md: 43900632c4e5e3904c2d0e3b75f2a3fe3b3a50cc
2026-06-19-acp-snapshot-tests.zh.md: c3a926dfe6ed2c0228cc99a5258cbb35798591f8
2026-06-19-acp-snapshot-tests.zh.md: a4613b19001afe87c1f6bdb61196fabbfa947a4c
@@ -14,17 +14,17 @@ Status: implemented
## 决策
快照测试会启动真实 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将规范化输出与已提交的预期输出比较。从真实 API 一次记录的 session log 为后续所有模型流提供数据。fixture 就是产品普通的持久化 JSONL。
快照测试会启动真实 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将规范化输出与已提交的预期输出比较。从真实 API 一次记录的会话日志为后续所有模型流提供数据。fixture 就是产品普通的持久化 JSONL。
### fixture 即持久化的会话 JSONL
每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通 session 产物同时充当重放来源和行为预期输出。
每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通会话产物同时充当重放来源和行为预期输出。
当场景固定另一种物理存储布局时,其 fixture 会从真实的未打包对应项机械派生。场景测试要求包含每一种预期存储行类型,并在解码后逐事件精确相等;随后,普通重放与日志比较才会证明组合后的进程能够消费并复现该布局。
### 回放从日志推导模型脚本
`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()``(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。agent loop(智能体循环)每个步骤发起一次流调用,因此分组精确对应,错误结束 chunk 也无需特殊处理。
`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()``(turn, step)` 对已录制的分片分组,每次模型调用服务一组。agent loop(智能体循环)每个步骤发起一次流调用,因此分组精确对应,错误结束分片也无需特殊处理。
### 内存中的回放条目遵守完整的 LLM 契约
@@ -36,7 +36,7 @@ Status: implemented
| { kind: 'hang' }
```
日志推导出 chunk 条目。流开始前的抛出和挂起没有可重建的 chunk 表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀 chunk 以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。
日志推导出分片条目。流开始前的抛出和挂起没有可重建的分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。
### 位置式回放,单个在途流
@@ -44,24 +44,24 @@ Status: implemented
### 录制采集日志;无密钥回放需要无提供方的配置
记录模式使用真实 `llm-deepseek` adapter 和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交重放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责重放。
记录模式使用真实 `llm-deepseek` 适配器和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交重放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责重放。
重放使用 `cordis.snapshot.yml` overlay,以 `llm-replay` 替换真实 adapter,同时保留实时组合。记录使用普通配置和由 harness 提供的持久化根目录。重放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发实时调用。参见[单一来源配置 Agent Note](2026-07-04-single-source-acp-replay-config.md)。
重放使用 `cordis.snapshot.yml` overlay,以 `llm-replay` 替换真实适配器,同时保留实时组合。记录使用普通配置和由 harness 提供的持久化根目录。重放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发实时调用。参见[单一来源配置 Agent Note](2026-07-04-single-source-acp-replay-config.md)。
### 两个表面:归一化后比对
快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的:
1. **stdout transcript**——编辑器看到的、经过 framing 的 `session/update` JSON-RPC。用于捕获 ACP bridge 中事件→更新转换(`streamSessionEventUpdate`)的回归。与已提交的 `stdout.expected.jsonl` 比较。
2. **重新持久化的 session JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。Prompt 文本会被清理;按照[请求头固定 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读 prompt 与工具内容。Override 场景仅从其 sidecar 派生模型行为。
2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词文本会被清理;按照[请求头固定 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读提示词与工具内容。Override 场景仅从其 sidecar 派生模型行为。
两个表面互补:stdout 覆盖 bridge 投影,JSONL 覆盖投影所省略的 loop、tool 和 boundary 结构。
两个表面互补:stdout 覆盖 bridge 投影,JSONL 覆盖投影所省略的 loop、工具和 boundary 结构。
规范化会替换 session、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化 session 相等性检查从不覆盖重放 fixture。
规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化会话相等性检查从不覆盖重放 fixture。
### 隔离:当前靠归一化,后续可加沙箱
工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发重放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的拆除无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,sandbox executor 可以通过现有[能力 seam](../architecture/2026-06-13-capability-seams.md)替换本地后端。
工具确定性来自生成的 cwd、清理后的环境、全新的非登录 shell、受限命令和规范化。cwd 默认为平台临时目录;当临时目录是始终可写的策略根,而行为需要独立项目位置时,场景可以改为提供其父目录。并发重放运行各自拥有独立 cwd、持久化目录和由定长场景键区分的 spill 根目录,因此一个场景的拆除无法删除另一个场景仍在进行的完整输出恢复,同时真实路径预览预算保持稳定。该层不声称提供 OS 级隔离。如果需要更强层级,沙箱执行器可以通过现有[能力 seam](../architecture/2026-06-13-capability-seams.md)替换本地后端。
### 回放插件是独立的包
@@ -69,16 +69,16 @@ Status: implemented
### 两个子命令,回放在默认门禁中
`pnpm run test:snapshot` 无需密钥即可重放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写采集的 session log 与 stdout 预期输出。缺少 fixture 时会响亮失败。每个场景都包含 `input.json``stdout.expected.jsonl``session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生重放。Fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。
`pnpm run test:snapshot` 无需密钥即可重放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写采集的会话日志与 stdout 预期输出。缺少 fixture 时会响亮失败。每个场景都包含 `input.json``stdout.expected.jsonl``session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生重放。Fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。
## 曾考虑的替代方案
- **手工编写包含模型 chunk `llm.json`**——早期草案;复用真实 session log,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。
- **手工编写包含模型分片`llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。
- **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSEServer-Sent Events)时笨拙,且层级低于被测对象。
- **从 `turn/end {kind:'error'|'aborted'}` 合成 throw/cancel 条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。
- **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。
## 后果
新测试层为每个场景增加经过评审的输入、session、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥 transcript 覆盖。子进程、输入、workspace、规范化和重放 harness 也可以支持 ACP 之外的示例。
新测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥 transcript 覆盖。子进程、输入、workspace、规范化和重放 harness 也可以支持 ACP 之外的示例。
本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生 session *消息历史*(内部一致性不变量),而快照测试固定*外部协议输出*。两者相互补充——一个守护事件溯源不变量,另一个守护面向编辑器的契约。
本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而快照测试固定*外部协议输出*。两者相互补充——一个守护事件溯源不变量,另一个守护面向编辑器的契约。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-19-real-api-e2e-ci.md: a9289980ada8ab6390b5daa488f07ec258db1bd5
2026-06-19-real-api-e2e-ci.zh.md: bb3dd888dbdf8f725953d300c96d875cadae8673
2026-06-19-real-api-e2e-ci.zh.md: dcb5759d26e7680d392a4855665b2eb88551ba79
@@ -6,7 +6,7 @@ Status: implemented
## 问题
根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACPAgent Client Protocolinject 事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实编辑器 session 却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对实时 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多轮次、恢复、ACP-over-stdio。
根据策略,harness 高度依赖真实 API 测试:[docs/testing.md](../../../../docs/testing.md) 指出,无密钥套件证明的是管线,而非产品;[ACPAgent Client Protocolinject 事后分析](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)则是常设证据——178 项无密钥测试保持绿色时,真实编辑器会话却立即崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)的存在正是为了弥合这一缺口:它针对实时 DeepSeek API 驱动 agent(智能体)——真实模型调用、真实 bash 工具、多轮次、恢复、ACP-over-stdio。
默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意无密钥:不携带 secret,可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此将其加入该工作流只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-20-remove-redundant-snapshot-log-expected-output.md: c2452f971d3cb76dceb766072dbc0a5c81465e78
2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md: b584bfa0154c71c1bbb683c3041dc7baf7a3548e
2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md: e6175181589eabae064c34044f353efae537961b
@@ -8,17 +8,17 @@ Status: implemented
驱动模型的 ACPAgent Client Protocol)快照场景同时包含 `session.jsonl``session.expected.jsonl`。对于普通记录场景,`session.jsonl` 是从真实运行采集的重放 fixture(测试前置数据);重放测试会规范化新持久化的日志,并将其与 `session.expected.jsonl` 比较。在当前 fixture 中,普通记录场景的两份规范化日志完全相同。
手工编写的 override 场景(`error-finish``cancel`)目前使用 `replay.override.json` 驱动模型行为,并把 `session.jsonl` 保留为最小 dummy fixture,而 `session.expected.jsonl` 存放预期的持久化日志。override 文件是由 `ReplayEntry` 对象组成的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }``{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }``{ "kind": "hang" }`。这种拆分同样没有必要:override sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 取得模型 chunk,因此 `session.jsonl` 仍可作为场景的预期 session log 产物。
手工编写的 override 场景(`error-finish``cancel`)目前使用 `replay.override.json` 驱动模型行为,并把 `session.jsonl` 保留为最小 dummy fixture,而 `session.expected.jsonl` 存放预期的持久化日志。override 文件是由 `ReplayEntry` 对象组成的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }``{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }``{ "kind": "hang" }`。这种拆分同样没有必要:override sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 取得模型分片,因此 `session.jsonl` 仍可作为场景的预期会话日志产物。
## 决策
彻底移除 `session.expected.jsonl` 概念。每个场景最多只有一个已提交 session log 产物,即 `session.jsonl`
彻底移除 `session.expected.jsonl` 概念。每个场景最多只有一个已提交会话日志产物,即 `session.jsonl`
- 对于录制场景,`session.jsonl` 仍是原始采集的日志。回放仍从中派生模型分片,快照测试将回放运行归一化后的持久化日志与归一化后的 `session.jsonl` 进行比较。
- 对于手工编写的覆盖场景,`replay.override.json` 驱动模型行为,`session.jsonl` 存放预期产出的会话日志。当覆盖文件存在时,回放适配器不从 fixture 获取模型分片,因此同一个文件既可作为预期日志,又不影响回放行为。
- 对于无模型场景,`session.jsonl` 可保留为引导 `llm-replay` 所需的最小 fixture;除非场景创建了持久化会话,否则无需进行会话日志比较。
Stdout 预期输出保持不变;它们是面向编辑器的投影,与 session fixture 并不重复。
Stdout 预期输出保持不变;它们是面向编辑器的投影,与会话 fixture 并不重复。
## 曾考虑的替代方案
@@ -26,7 +26,7 @@ Stdout 预期输出保持不变;它们是面向编辑器的投影,与 sessio
## 验证
快照 harness、fixture、孤立项守卫和文档中都不再出现 `session.expected.jsonl`;对于每个模型场景,快照测试都从 `session.jsonl` 派生预期 session log;手工编写 sidecar 的场景把预期生成日志提交为 `session.jsonl`,并以 `replay.override.json` 覆盖模型行为;孤立 fixture 守卫知道每种场景类型所需的文件。[ACP 快照测试 Agent Noteagent 决策记录)](2026-06-19-acp-snapshot-tests.md)描述了精简后的 fixture 集合。
快照 harness、fixture、孤立项守卫和文档中都不再出现 `session.expected.jsonl`;对于每个模型场景,快照测试都从 `session.jsonl` 派生预期会话日志;手工编写 sidecar 的场景把预期生成日志提交为 `session.jsonl`,并以 `replay.override.json` 覆盖模型行为;孤立 fixture 守卫知道每种场景类型所需的文件。[ACP 快照测试 Agent Noteagent 决策记录)](2026-06-19-acp-snapshot-tests.md)描述了精简后的 fixture 集合。
## 后果
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-22-fork-child-replay-seed-boundary.md: d3cbbb1dae1d64a10973bd5895ccc47d877eba28
2026-06-22-fork-child-replay-seed-boundary.zh.md: 8af1ba869ba5484442a6575f78556f47abe99d22
2026-06-22-fork-child-replay-seed-boundary.zh.md: a944538b9dcb74eb15142593089c7905efc3f565
@@ -6,13 +6,13 @@ Status: implemented
## 问题
[ session 快照重放 Agent Noteagent 决策记录)](2026-06-22-subagent-snapshot-replay.md)使快照层能够表达嵌套 agent 形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用 session 作为键,以独立脚本重放。它曾指出(§ 范围,最后一个项目符号),fork 快照“只是未来很容易添加的一项,并非键控缺口”。这一判断对 fork 子项而言是错误的——问题不在键控,而在*脚本派生*。
[会话快照重放 Agent Noteagent 决策记录)](2026-06-22-subagent-snapshot-replay.md)使快照层能够表达嵌套 agent 形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用会话作为键,以独立脚本重放。它曾指出(§ 范围,最后一个项目符号),fork 快照“只是未来很容易添加的一项,并非键控缺口”。这一判断对 fork 子项而言是错误的——问题不在键控,而在*脚本派生*。
subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-replay) 从已录制的会话日志推导:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自身的模型调用。
**fork** 子会话不同。fork 后端用*父日志的一段平衡的已完成轮次前缀*([`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess))来播种子会话,而该 seed 会成为子会话持久化的 `log``Session` 构造函数将 seed 复制进 `this.log`)。因此 fork 子会话的 `.jsonl` 以**父会话**的事件开头——包括父会话的 `assistant/chunk` 事件——之后才是子会话自身的轮次。
从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段 chunk 序列而非自身的。目前已录制的场景全部是 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。
从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段分片序列而非自身的。目前已录制的场景全部是 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。
## 决策
@@ -22,7 +22,7 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla
`SessionHeader` 新增可选字段 `seedLength: number`——表示有多少前导事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= 播种前缀的长度);全新的 spawn 子会话不设置(等同于 0)。它通过 `CreateSessionOptions.meta`(及 `CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。
`seedLength` 是**显式**的,绝不从 `seed.length` 推断。重建(resume/load时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——resume 路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:重建时显式保留,而非重新默认为当前时间。)
`seedLength` 是**显式**的,绝不从 `seed.length` 推断。恢复/加载时用会话的完整已存储日志作为 seed,此时 `seed.length` 是全长而非原始边界——恢复路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:恢复时显式保留,而非重新默认为当前时间。)
### 2. 两个持久化后端均完整往返
@@ -46,4 +46,4 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla
- core 与两个后端新增一个持久化 header 字段;核心数据结构目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions``type-equiv` 块)。
- 既有的 schema v2 SQLite 数据库在打开时被拒绝(预发布阶段无用户数据)。
- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的 chunk——推导出的子会话脚本必须排除它,不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。
- spawn 回放不变(`seedLength` 为 0)。fork 回放现在将子会话路由到自身的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture,其播种前缀包含父会话的分片——推导出的子会话脚本必须排除它,不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-22-fork-snapshot-scenarios.md: 46c688a4095a1d8af32b3b99887929f71a1526ce
2026-06-22-fork-snapshot-scenarios.zh.md: 4fe184685d4cfaa6f096af33d782ce3a1391b877
2026-06-22-fork-snapshot-scenarios.zh.md: 8823611ec02f269e5a21a8065c9ed3cc3911f749
@@ -15,16 +15,16 @@ Status: implemented
针对真实 API 记录两个场景,均在默认门禁中以无密钥方式回放:
- **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此可以从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的记录而非手工合成。
- **`subagent-mixed`**——父项完成一个轮次,随后在同一 transcript 中通过 `subagent` 委托一次(全新 spawn 子项,`seedLength` 为 0),再通过 `subagent_fork` 委托一次(fork 子项,`seedLength` 非零)。这是 seed 边界与逐 session 重放 Agent Note 都点名作为未来新增项的 spawn+fork 混合场景:一份 transcript 覆盖两种传输方式和 slice 的两个分支(`seedLength` 为 0 = 无操作,`seedLength > 0` = 裁剪继承前缀),两个子项按 `createdAt` 排列为先 spawn、后 fork。
- **`subagent-mixed`**——父项完成一个轮次,随后在同一 transcript 中通过 `subagent` 委托一次(全新 spawn 子项,`seedLength` 为 0),再通过 `subagent_fork` 委托一次(fork 子项,`seedLength` 非零)。这是 seed 边界与逐会话重放 Agent Note 都点名作为未来新增项的 spawn+fork 混合场景:一份 transcript 覆盖两种传输方式和 slice 的两个分支(`seedLength` 为 0 = 无操作,`seedLength > 0` = 裁剪继承前缀),两个子项按 `createdAt` 排列为先 spawn、后 fork。
### 为什么需要一个已完成的第一轮次
fork 后端使用父项的**已配平完整轮次前缀**为子项提供 seed。父项若在第一个轮次就执行 fork,没有已完成轮次可供继承,因此 seed 为空(≡ 全新 spawn`seedLength` 为 0)——这不会覆盖 slice。因此,两个场景都使用双 prompt 输入:第一个 prompt 完成一个轮次(建立稍后要求子项回忆的 codeword),第二个 prompt 委托 fork。子项 transcript 中回忆出的 codeword 只是模型行为的附带结果;承载关键约束的产物是子项 fixture 中记录、由重放 slice 消费的 `seedLength`
fork 后端使用父项的**已配平完整轮次前缀**为子项提供 seed。父项若在第一个轮次就执行 fork,没有已完成轮次可供继承,因此 seed 为空(≡ 全新 spawn`seedLength` 为 0)——这不会覆盖 slice。因此,两个场景都使用双提示词输入:第一个提示词完成一个轮次(建立稍后要求子项回忆的 codeword),第二个提示词委托 fork。子项 transcript 中回忆出的 codeword 只是模型行为的附带结果;承载关键约束的产物是子项 fixture 中记录、由重放 slice 消费的 `seedLength`
## 后果
- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话记录的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。
- `subagent-mixed` 是第一个在同一个 transcript 中驱动两种*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。
- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话记录的分片而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。
- `subagent-mixed` 是第一个在同一个 transcript 中驱动两种*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的逐会话回放键控。
- 进程外(ACPAgent Client Protocol))subagent 回放形态不同(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。
- 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture;两个新场景在无密钥时自动跳过,与所有已录制场景一致。
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-22-subagent-snapshot-replay.md: 4aad8b6ddd3e4f8b8ad5565e10b263953d81ea31
2026-06-22-subagent-snapshot-replay.zh.md: 2dfcee22463c8bb31e68bfb078915475e2bd7f78
2026-06-22-subagent-snapshot-replay.zh.md: 96890bc98a64357d9785b8081c8ba123bb3c716e
@@ -6,7 +6,7 @@ Status: implemented
## 问题
快照层(`pnpm run test:snapshot`)会启动真实 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 重放已记录 session,并将规范化 stdout transcript(文本记录)+ 重新持久化的 session log 与已提交预期输出进行 diff。它是唯一端到端覆盖完整面向编辑器 transcript 的测试层。
快照层(`pnpm run test:snapshot`)会启动真实 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 重放已记录会话,并将规范化 stdout transcript(文本记录)+ 重新持久化的会话日志与已提交预期输出进行 diff。它是唯一端到端覆盖完整面向编辑器 transcript 的测试层。
该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处:
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-04-hook-snapshot-matrix.md: ceb91a70e1f9582cf2a7cb4cec0ba8aaf5699b8e
2026-07-04-hook-snapshot-matrix.zh.md: 61c0c06569db17e7daec8d75d4af8150a1f9dd52
2026-07-04-hook-snapshot-matrix.zh.md: 250995dd1aae566aa5b9d40a14ed281b14fbfe0b
@@ -1,4 +1,4 @@
# Agent Note: Hook 快照矩阵——覆盖两种 bridge 的端到端 预期输出 测试
# Agent Note: 钩子快照矩阵——覆盖两种 bridge 的端到端预期输出测试
Status: implemented
@@ -6,47 +6,47 @@ Status: implemented
## 问题
hook bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)7 个 Claude Code hook 点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)5 个 Codex 点)——把外部 hook 命令映射到 harness 拦截 seam。它们有深入的单元与覆盖率规格覆盖(每个决策分支、每种 payload dialect,针对 mock seam 驱动),外加一个受密钥门控的 e2e(`hooks.e2e.ts`,实时 `PreToolUse` 阻止)。但完整 transcript(文本记录)快照层——会启动真实 `acp-agent` 子进程、无需密钥重放已记录 session,并将规范化 ACPAgent Client Protocolstdout + 重新持久化日志与已提交预期输出进行 diff 的那张网——只覆盖了一个 hookClaude `UserPromptSubmit` 阻止(`hook-cc-promptsubmit-block`)。
钩子 bridge——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)7 个 Claude Code 钩子点)和 [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)5 个 Codex 点)——把外部钩子命令映射到 harness 拦截 seam。它们有深入的单元与覆盖率规格覆盖(每个决策分支、每种 payload dialect,针对 mock seam 驱动),外加一个受密钥门控的 e2e(`hooks.e2e.ts`,实时 `PreToolUse` 阻止)。但完整 transcript(文本记录)快照层——会启动真实 `acp-agent` 子进程、无需密钥重放已记录会话,并将规范化 ACPAgent Client Protocolstdout + 重新持久化日志与已提交预期输出进行 diff 的那张网——只覆盖了一个钩子Claude `UserPromptSubmit` 阻止(`hook-cc-promptsubmit-block`)。
这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实 hook 进程的结果翻译到真实 seam 决策,再到真实 agent loop(智能体循环)的反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个 hook 点之外的所有点上逃逸;而对于 Codex bridge,ACP 示例甚至没有加载它,因此没有任何 Codex hook 能端到端触发。
这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实钩子进程的结果翻译到真实 seam 决策,再到真实 agent loop(智能体循环)的反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个钩子点之外的所有点上逃逸;而对于 Codex bridge,ACP 示例甚至没有加载它,因此没有任何 Codex 钩子能端到端触发。
## 决策
实现由两个耦合部分组成:
### 1. ACP 示例同时加载两种 hook bridge
### 1. ACP 示例同时加载两种钩子 bridge
`examples/acp-agent/cordis.yml``cordis.snapshot.yml` 现在同时加载 `dsh-hooks-codex``dsh-hooks-claude`,各自指向自己的配置文件(Claude 用 `./hooks.json`Codex 用 `./codex-hooks.json`——两种方言无法共用一个文件)。这是一个真正的产品接口变更,而非仅用于测试的接线:交付的 ACP 服务器(以及 `demo:acp` 入口)现在同时携带两种 bridge。
这是安全的,因为配置文件不存在时 bridge 是**静默无操作**的:`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不附带 stdout logger,因此警告不会到达 ACP JSON-RPC 通道。只需要 Claude hook 的场景(或真实项目)只提供 `hooks.json`Codex bridge 找不到 `codex-hooks.json` 便自动消失。这已通过实验验证:在两种 bridge 同时加载的情况下,所有既有快照(均不附带 `codex-hooks.json`)逐字节一致。
这是安全的,因为配置文件不存在时 bridge 是**静默无操作**的:`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不附带 stdout logger,因此警告不会到达 ACP JSON-RPC 通道。只需要 Claude 钩子的场景(或真实项目)只提供 `hooks.json`Codex bridge 找不到 `codex-hooks.json` 便自动消失。这已通过实验验证:在两种 bridge 同时加载的情况下,所有既有快照(均不附带 `codex-hooks.json`)逐字节一致。
同时加载是让快照层能够在产品交付的同一个真实应用上验证每种方言的最低要求。录制(启动 `cordis.yml`)天然加载两者,回放以同样方式继承:`cordis.snapshot.yml``cordis.yml` 的 include-overlay,只替换 llm 入口(见[单一来源 acp-agent 回放配置](2026-07-04-single-source-acp-replay-config.md)),因此添加到运行时树的 bridge 无需第二次编辑即出现在回放树中。
### 2. 每个 hook 点 × 其主要结果各一个快照场景,覆盖两种方言
### 2. 每个钩子点 × 其主要结果各一个快照场景,覆盖两种方言
`examples/acp-agent/tests/snapshots/` 下共 13 个场景,命名为 `hook-<dialect>-<point>-<outcome>`
- **手工编写、无模型轮次**(无密钥、无 sidecar——派生的回放脚本为空;比对的是携带 `hook/*` 事件的 `rejected` 轮次):`hook-cc-promptsubmit-block``hook-codex-promptsubmit-block`
- **对真实 API 录制、录制期间 hook 活跃**(模型对决策的反应是捕获的 transcript 的一部分,此后无密钥回放):`hook-{cc,codex}-promptsubmit-context`allow + additionalContext 折叠)、`hook-cc-pretool-deny` / `hook-codex-pretool-block`deny → `isError` 工具结果)、`hook-cc-pretool-ask`(ask → 降级为 deny 并附带 approval-required 原因)、`hook-{cc,codex}-posttool-block`block 并附带反馈)、`hook-{cc,codex}-posttool-context`accept + additionalContext)、`hook-{cc,codex}-stop-continue`(阻塞性 Stop hook 通过 steering(中途引导)强制多走一步)。
- **对真实 API 录制、录制期间钩子活跃**(模型对决策的反应是捕获的 transcript 的一部分,此后无密钥回放):`hook-{cc,codex}-promptsubmit-context`allow + additionalContext 折叠)、`hook-cc-pretool-deny` / `hook-codex-pretool-block`deny → `isError` 工具结果)、`hook-cc-pretool-ask`(ask → 降级为 deny 并附带 approval-required 原因)、`hook-{cc,codex}-posttool-block`阻止并附带反馈)、`hook-{cc,codex}-posttool-context`accept + additionalContext)、`hook-{cc,codex}-stop-continue`(阻塞性 Stop 钩子通过 steering(中途引导)强制多走一步)。
每个 hook 命令只输出固定字面量字符串(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop hook 会在每一步都 force-continue。
每个钩子命令只输出固定字面量字符串(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop 钩子会在每一步都 force-continue。
`PostToolUse` 阻止场景会在其证明的机制处自行限制。Claude hook 在首次拒绝后持久化一个 workspace 标记,因此允许一次恢复调用;Codex prompt 发起一次调用并报告注入结果。每份预期输出固定一次遭阻止调用,不会重复阻止/重试循环。
`PostToolUse` 阻止场景会在其证明的机制处自行限制。Claude 钩子在首次拒绝后持久化一个 workspace 标记,因此允许一次恢复调用;Codex 提示词发起一次调用并报告注入结果。每份预期输出固定一次遭阻止调用,不会重复阻止/重试循环。
### 三个 hook 点被有意排除在快照之外
### 三个钩子点被有意排除在快照之外
在构建矩阵过程中发现,记录于此是因为这些遗漏是决策而非疏忽:
- **`SessionStart``SubagentStart`** 通过脱离且尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,没有轮次绑定。由此产生的 `context/message` 会与它应先于的工作(首次模型请求 / 子项的第一个轮次)竞速,并落在不确定的日志位置。记录的预期输出甚至无法在自己的重放中复现——对两者执行 10 次重放稳定性检查,结果均为 10/10 次失败。它们继续留在 bridge 的单元覆盖率中,那里会直接驱动 seam 而不存在时序竞速。(如果注入未来改为绑定轮次且具备确定性——`TODO(session-start-gating)` 所指方向——它们就能接受快照测试。)
- **`SubagentStop`** 只观察:其 `subagent/end` handler 不传递轮次(因此没有 `hook/*` 日志事件),也不执行注入。它不会向 transcript 写入任何内容,因此预期输出会与无 hook 运行逐字节相同,永远无法证明失败——一道咬不住问题的守卫。它继续由单元覆盖率负责(`bridge.spec.ts` 已断言仅观察调用)。
- **`SubagentStop`** 只观察:其 `subagent/end` handler 不传递轮次(因此没有 `hook/*` 日志事件),也不执行注入。它不会向 transcript 写入任何内容,因此预期输出会与无钩子运行逐字节相同,永远无法证明失败——一道咬不住问题的守卫。它继续由单元覆盖率负责(`bridge.spec.ts` 已断言仅观察调用)。
因此,该矩阵覆盖了所有具有确定性、可观测 transcript 足迹的 hook 点,涵盖两种方言。
因此,该矩阵覆盖了所有具有确定性、可观测 transcript 足迹的钩子点,涵盖两种方言。
## 后果
- 现在,两种 dialect 中每个具有可观察 transcript 的 bridge seam 映射,都在真实应用的完整 transcript 层受到守护——包括此前完全没有端到端覆盖的 Codex bridge。记录的预期输出捕获模型对遭拒绝/遭阻止/强制继续轮次的真实反应,而手工编写的 transcript 只能猜测这种反应。
- `UserPromptSubmit` 阻止场景无需密钥即可编写(没有模型轮次);其余场景从已记录 fixture(测试前置数据)无需密钥重放。`pnpm run test:snapshot:record` 从实时 API 重新生成记录式 fixture,并像所有记录场景一样在缺少密钥时自行跳过。
- 证明会变红的准则仍成立:篡改 hook 配置输出(例如改变拒绝理由)会让相应场景在重放时变红——hook 进程在重放期间真实运行(只有模型被重放),因此预期输出守护的是实际 hook→seam→循环路径,而非其 mock。
- 证明会变红的准则仍成立:篡改钩子配置输出(例如改变拒绝理由)会让相应场景在重放时变红——钩子进程在重放期间真实运行(只有模型被重放),因此预期输出守护的是实际钩子→seam→循环路径,而非其 mock。
- `acp-agent` 演示现在加载了一个通常会无操作的 Codex bridge(典型项目中没有 `codex-hooks.json`),这正是预期的柔性失败行为,而非代价。
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-06-pin-request-header-content-in-one-scenario.md: bca6d9eb943e758d68efaf3a76ec367179cd15fd
2026-07-06-pin-request-header-content-in-one-scenario.zh.md: 9637602aca34977bee7c0efd5dc57b848ed93e2c
2026-07-06-pin-request-header-content-in-one-scenario.zh.md: e01c84c63583e2fe14b0b4fe0381a18b209d2346
@@ -10,11 +10,11 @@ Status: implemented
## 决策
每种请求头组合类别恰好有一个场景标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.expected.md` 以普通 Markdown 包含规范化的完整 prompt 序列;`tool-schemas.expected.json` 以结构化 JSON 包含对应的完整 schema 序列;`session.jsonl` 保留 config、reason 和所有模型可见前缀,同时将 `header.system``header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其他每份 JSONL 都使用相同的 prompt 与工具 token,并同样将 session 前缀内容 token 化。固定机制位于 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md),其套件 factory 强制每种类别恰好有一个固定场景。
每种请求头组合类别恰好有一个场景标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.expected.md` 以普通 Markdown 包含规范化的完整提示词序列;`tool-schemas.expected.json` 以结构化 JSON 包含对应的完整 schema 序列;`session.jsonl` 保留 config、reason 和所有模型可见前缀,同时将 `header.system``header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其他每份 JSONL 都使用相同的提示词与工具 token,并同样将会话前缀内容 token 化。固定机制位于 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md),其套件 factory 强制每种类别恰好有一个固定场景。
`scrubSystemPrompts``scrubToolSchemas` 规范化器会分别将每个已存储完整请求头 token 化。`scrubRequestHeaders` 还会为非固定场景把 session 前缀内容 token 化,同时保留请求头数量、字段存在性、config、reason 和前缀消息数量。记录与刷新写回会在写入 JSONL 前应用适当清理,并根据规范化的实时完整请求头序列重新生成两个 sidecar,因此两条路径都无法把大段 prompt/schema 重新引入 JSONL,也不会留下陈旧的评审产物。
`scrubSystemPrompts``scrubToolSchemas` 规范化器会分别将每个已存储完整请求头 token 化。`scrubRequestHeaders` 还会为非固定场景把会话前缀内容 token 化,同时保留请求头数量、字段存在性、config、reason 和前缀消息数量。记录与刷新写回会在写入 JSONL 前应用适当清理,并根据规范化的实时完整请求头序列重新生成两个 sidecar,因此两条路径都无法把大段提示词/schema 重新引入 JSONL,也不会留下陈旧的评审产物。
守卫使这种拆分能够自我强制。在磁盘上,每个 `session*.jsonl` 都是 prompt 和 schema 清理器的固定点;只有非固定 fixture(测试前置数据)必须是完整请求头清理的固定点;两个 sidecar 恰好位于固定 fixture 旁,并采用规范、以换行符结尾的格式;每种类别都有一个固定场景。在实时运行中,由父项、spawn 子项、fork 子项、初始请求、恢复或实例内变化产生的每个 `request/header`,都必须在易变值规范化后与重建的类别序列匹配。请求头若没有字符串 prompt、没有数组值工具列表,或超过固定场景声明的变更请求头数量,就会响亮失败。
守卫使这种拆分能够自我强制。在磁盘上,每个 `session*.jsonl` 都是提示词和 schema 清理器的固定点;只有非固定 fixture(测试前置数据)必须是完整请求头清理的固定点;两个 sidecar 恰好位于固定 fixture 旁,并采用规范、以换行符结尾的格式;每种类别都有一个固定场景。在实时运行中,由父项、spawn 子项、fork 子项、初始请求、恢复或实例内变化产生的每个 `request/header`,都必须在易变值规范化后与重建的类别序列匹配。请求头若没有字符串提示词、没有数组值工具列表,或超过固定场景声明的变更请求头数量,就会响亮失败。
一个固定场景覆盖整个套件,因为每个会话(parent、spawn 子会话、fork 子会话)组合出的工具列表完全相同、提示词除 cwd 外完全相同,而一致性守卫会在这一前提不再成立时立即使套件失败。如果 header 组合将来在设计上变为会话相关的(例如受限的 subagent 工具集),那么分歧的形态将获得自己的固定场景。
@@ -24,11 +24,11 @@ Status: implemented
- **仅在比较时 scrubfixture 保持原始内容**:比较能通过,但已提交的 fixture 保留着陈旧的重复内容,下次录制时会整体重写。存储 token 诚实地表明每个 JSONL 没有固定什么。
- **全部 scrub,不做任何固定**:丢失了组合 header 实际发送内容(提示词组装、已注册工具顺序、完整 schema)的唯一端到端记录。生成的工具目录只孤立地记录每个工具;只有真实 fixture 才能固定组合后的完整集合。
- **将完整固定内容全部保留在 JSONL 中**:消除了套件范围的重复,但提示词和 schema 变更仍然是一行转义文本。Markdown 和结构化 JSON 为每种内容提供其自然的评审格式,同时不削弱重建 header 的断言。
- **收窄 session log 本身(记录内容 digest,把请求头存到其他位置)**——违反可重建性契约:产品日志必须逐 bit 复现每个请求([可重建请求 Agent Noteagent 决策记录)](../architecture/2026-07-05-reconstructable-requests.md))。请求头体积是测试产物问题,应在测试规范化中解决;实时日志保持不变。
- **收窄会话日志本身(记录内容 digest,把请求头存到其他位置)**——违反可重建性契约:产品日志必须逐 bit 复现每个请求([可重建请求 Agent Noteagent 决策记录)](../architecture/2026-07-05-reconstructable-requests.md))。请求头体积是测试产物问题,应在测试规范化中解决;实时日志保持不变。
## 验证
该套件针对拆分后的固定内容放每个场景。单元覆盖率会覆盖独立与完整清理器、两种完整请求头 sidecar 格式、记录/刷新重新生成、规范化 prompt/schema 提取、固定点强制、必需文件对称性、重建请求头一致性,以及变更请求头数量拒绝。
该套件针对拆分后的固定内容放每个场景。单元覆盖率会覆盖独立与完整清理器、两种完整请求头 sidecar 格式、记录/刷新重新生成、规范化提示词/schema 提取、固定点强制、必需文件对称性、重建请求头一致性,以及变更请求头数量拒绝。
## 后果
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-08-shared-acp-snapshot-package.md: 3e5a2b12114d535490a17361128862f6d1c09a73
2026-07-08-shared-acp-snapshot-package.zh.md: c6b943d93e58c714b52d05d1c9b352e7fb8e2205
2026-07-08-shared-acp-snapshot-package.zh.md: 072ef692702cb769c428f8b6aa3863a3b77d3d59
@@ -6,21 +6,21 @@ Status: implemented
## 问题
ACPAgent Client Protocol)快照层([快照 Agent Noteagent 决策记录)](2026-06-19-acp-snapshot-tests.md))由位于一个示例测试目录内的三个模块构建:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,采集持久化日志)、`snapshot-normalize.ts`(纯预期输出规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体与 fixture(测试前置数据)守卫(记录/放模式、stdout 预期输出与日志比较、固定请求头一致性守卫、孤立项/必需文件/单一固定项元测试)。
ACPAgent Client Protocol)快照层([快照 Agent Noteagent 决策记录)](2026-06-19-acp-snapshot-tests.md))由位于一个示例测试目录内的三个模块构建:`snapshot-harness.ts`(启动真实 bin 子进程,通过 ACP JSON-RPC 驱动它,采集持久化日志)、`snapshot-normalize.ts`(纯预期输出规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体与 fixture(测试前置数据)守卫(记录/放模式、stdout 预期输出与日志比较、固定请求头一致性守卫、孤立项/必需文件/单一固定项元测试)。
第二个希望获得快照覆盖的 ACP 示例——直接消费方是 sandbox/approval 组合——只能复制这些模块,恰好分叉了绝不能漂移的逻辑:记录写回、请求头清理、子 session 采集顺序。spawn/client 胶水也在 `acp.e2e.ts``hooks.e2e.ts` 和 harness 中重复三份。文件位置决定了测试严格度:逐文件 100% 覆盖率门禁只测量 `packages/*/*/src`,因此这些机制完全未被测量——正是同一种缺口,曾推动 `dsh-llm-replay``examples/` 移入 [packages/support](../../../../packages/support/README.md)。此外,harness 的 ACP client 硬编码 `requestPermission → cancelled`,因此 approval 往返——sandbox 组合的主打行为——完全无法在快照层表达。
第二个希望获得快照覆盖的 ACP 示例——直接消费方是沙箱/approval 组合——只能复制这些模块,恰好分叉了绝不能漂移的逻辑:记录写回、请求头清理、子会话采集顺序。spawn/client 胶水也在 `acp.e2e.ts``hooks.e2e.ts` 和 harness 中重复三份。文件位置决定了测试严格度:逐文件 100% 覆盖率门禁只测量 `packages/*/*/src`,因此这些机制完全未被测量——正是同一种缺口,曾推动 `dsh-llm-replay``examples/` 移入 [packages/support](../../../../packages/support/README.md)。此外,harness 的 ACP client 硬编码 `requestPermission → cancelled`,因此 approval 往返——沙箱组合的主打行为——完全无法在快照层表达。
## 决策
这些机制位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,依赖自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` overlay[单源 replay 配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在边缘层——库接收的是已解析的 `mode`
这些机制位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,依赖自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` overlay[单源回放配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在边缘层——库接收的是已解析的 `mode`
**`src/launcher.ts`**——`launchAcpTestAgent` 拥有通用的未构建进程边界:绝对 tsx loader 解析、`TSX_TSCONFIG_PATH`、隔离的 harness home、stdio 接线、原始字节 stdout tee、stderr 与更新捕获、失败关闭的权限后备、更新 waiter,以及优雅或信号式关闭。快照场景和普通 e2e 套件提供相同的 `AgentUnderTest``binScript``configPath``tsconfigPath`);扮演用户的测试只提供其权限 handler。ACP 与 hook e2e 套件以及 sandbox/approval e2e 套件都使用该 launcher,而不再重新构建 SDK client 边界。
**`src/launcher.ts`**——`launchAcpTestAgent` 拥有通用的未构建进程边界:绝对 tsx loader 解析、`TSX_TSCONFIG_PATH`、隔离的 harness home、stdio 接线、原始字节 stdout tee、stderr 与更新捕获、失败关闭的权限后备、更新 waiter,以及优雅或信号式关闭。快照场景和普通 e2e 套件提供相同的 `AgentUnderTest``binScript``configPath``tsconfigPath`);扮演用户的测试只提供其权限 handler。ACP 与钩子 e2e 套件以及沙箱/approval e2e 套件都使用该 launcher,而不再重新构建 SDK client 边界。
**`src/harness.ts`**——`runScenario` 和输入脚本/结果类型在 launcher 之上叠加确定性步骤、临时 workspace、快照环境和持久化日志采集。其 `session/request_permission` handler 消费可选的 `InputScript.permissionAnswers` FIFO 队列,每个条目按选项**类型**进行选择(id 是 agent 生成的随机值,已提交脚本无法预知;类型是 ACP 稳定词汇,会在回答时映射到已提供的 `optionId`);队列不存在或耗尽时回答 `cancelled`,若请求从未提供某种类型则拒绝该次运行——agent 自身收到的回答是 `cancelled`,因此场景 bug 会使 harness 失败,而不会被吸收为 agent 侧拒绝。由此,approval 套件可以根据 `input.json` 确定性地驱动允许/拒绝往返。
**`src/normalize.ts`** 是纯规范化器,按策略不含钩子:当未来某个事件携带新的易变字段(例如审批耗时),共享规范化器在同一个变更中学会它,保持「规范化」的含义只有一个归属,而非各套件各自扩展清洗逻辑。
**`src/suite.ts`**——包含 `Scenario` 类型和 `defineAcpSnapshotSuite(options)`,注册各场景比较、记录/刷新 fixture 写回、带实时一致性守卫的请求头固定项,以及 fixture 守卫块(没有孤立场景目录、必需文件存在、每种类别恰好一个固定项、每份 JSONL 都是 `scrubSystemPrompts` 固定点、非固定 fixture 同时也是 `scrubRequestHeaders` 固定点)。刷新会先展开打包的计时信封,再对齐现有易变事件时间,因此在打包与未打包布局之间切换不会移动后续记录;全新的 chunk fragment 数组仍为权威,因为其边界属于放行为。场景目录中的 `session.jsonl` 加连续的 `session.<n>.jsonl` 同级文件构成有序主项/子项清单,因此场景表可以声明策略而不重复子项数量。固定请求头契约([固定请求头 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件生效:每种请求头类别恰好标记一个 `pinsHeader` 场景,其 `system-prompt.expected.md` 和 JSONL 工具列表把组合请求头拆成可评审产物;一致性守卫会将两者与该类别的每个实时请求头比较。固定场景可以声明任何合法的变更请求头数量,其 Markdown 产物记录每个完整的已变 prompt。纯辅助函数(`sessionFixtureNames``fixtureContext``normalizedHeaders``normalizedSystemPrompts``formatSystemPromptSnapshot``headerChangeCount`)从模块导出,以便直接进行单元覆盖。
**`src/suite.ts`**——包含 `Scenario` 类型和 `defineAcpSnapshotSuite(options)`,注册各场景比较、记录/刷新 fixture 写回、带实时一致性守卫的请求头固定项,以及 fixture 守卫块(没有孤立场景目录、必需文件存在、每种类别恰好一个固定项、每份 JSONL 都是 `scrubSystemPrompts` 固定点、非固定 fixture 同时也是 `scrubRequestHeaders` 固定点)。刷新会先展开打包的计时信封,再对齐现有易变事件时间,因此在打包与未打包布局之间切换不会移动后续记录;全新的分片片段数组仍为权威,因为其边界属于放行为。场景目录中的 `session.jsonl` 加连续的 `session.<n>.jsonl` 同级文件构成有序主项/子项清单,因此场景表可以声明策略而不重复子项数量。固定请求头契约([固定请求头 Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件生效:每种请求头类别恰好标记一个 `pinsHeader` 场景,其 `system-prompt.expected.md` 和 JSONL 工具列表把组合请求头拆成可评审产物;一致性守卫会将两者与该类别的每个实时请求头比较。固定场景可以声明任何合法的变更请求头数量,其 Markdown 产物记录每个完整的已变提示词。纯辅助函数(`sessionFixtureNames``fixtureContext``normalizedHeaders``normalizedSystemPrompts``formatSystemPromptSnapshot``headerChangeCount`)从模块导出,以便直接进行单元覆盖。
## 曾考虑的替代方案
@@ -33,7 +33,7 @@ ACPAgent Client Protocol)快照层([快照 Agent Noteagent 决策记
## 测试
提取一致性得到机械证明:迁移后,`pnpm run test:snapshot` 的结果与基准提交匹配,`examples/acp-agent/tests/snapshots/` 下没有任何字节变化。包的 `src/` 在门禁单元运行中保持逐文件 100% 语句/分支/函数/行覆盖,并通过脚本化 fake ACP bin`tests/fixtures/fake-acp-agent.ts`,每个场景由 fixture 旁的 `behavior.json` 编排行为)经过真实 launcher 驱动:`harness.spec.ts` 直接覆盖 launcher 默认值、捕获、更新等待、关闭以及环境/配置变体,随后覆盖每种场景步骤操作、两个 expect-error 分支、权限队列(选择、后备、不可能点击)、workspace seed,以及采集顺序/噪音/后备分支;`suite.spec.ts` 在收集时真实运行 factory——一个针对已提交合成 fixture 的放套件和一个针对临时副本的记录套件(写回从不触及已提交树;`ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` 会重新引导它)——并包含纯辅助函数的直接用例。fake bin 会把 `session/new` cwd 而非 `process.cwd()` 代入脚本化日志,与真实 bin 请求头携带的内容一致(darwin 会将 `/var/folders/…` realpath 为 `/private/var/folders/…`)。
提取一致性得到机械证明:迁移后,`pnpm run test:snapshot` 的结果与基准提交匹配,`examples/acp-agent/tests/snapshots/` 下没有任何字节变化。包的 `src/` 在门禁单元运行中保持逐文件 100% 语句/分支/函数/行覆盖,并通过脚本化 fake ACP bin`tests/fixtures/fake-acp-agent.ts`,每个场景由 fixture 旁的 `behavior.json` 编排行为)经过真实 launcher 驱动:`harness.spec.ts` 直接覆盖 launcher 默认值、捕获、更新等待、关闭以及环境/配置变体,随后覆盖每种场景步骤操作、两个 expect-error 分支、权限队列(选择、后备、不可能点击)、workspace seed,以及采集顺序/噪音/后备分支;`suite.spec.ts` 在收集时真实运行 factory——一个针对已提交合成 fixture 的放套件和一个针对临时副本的记录套件(写回从不触及已提交树;`ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` 会重新引导它)——并包含纯辅助函数的直接用例。fake bin 会把 `session/new` cwd 而非 `process.cwd()` 代入脚本化日志,与真实 bin 请求头携带的内容一致(darwin 会将 `/var/folders/…` realpath 为 `/private/var/folders/…`)。
## 后果