docs(i18n): re-translate RFC batch with the prompt-v4 pipeline

146 篇 RFC 译文按 v4 基线(#348)重出:v4 模板+术语表、金标
few-shot、三段协议、切换行后处理;全量机械核对零异常(一处
task id 术语违规已修)。三篇超长 RFC(code-mode 已入,web-seam/
agent-scope/sandbox/cds-core 仍在长文档通道产出)随后补。
This commit is contained in:
ZiyaZhang
2026-07-22 03:07:36 -07:00
parent 839b88a53a
commit 8ea5cdd894
292 changed files with 2819 additions and 2820 deletions

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-property-based-testing.md: 169989746ea5114b1f35e7ebe35a02e8aeb0f782
2026-06-11-property-based-testing.zh.md: 9e1532c02bb2c46c40577af7275d6aabbb2f9a4f
2026-06-11-property-based-testing.zh.md: 4f2303010f44279c4edd0ec5a509b71f8aad606b

View File

@@ -4,26 +4,26 @@ Status: implemented
[English](2026-06-11-property-based-testing.md) | 中文
> 将原始提案与决策记录合并为一篇。首次运行即发现了 BlockAssembler 重复 `block-end` 真实 bug。
> 将原始提案与同一主题的决策记录合并为一篇。首次运行即发现了 BlockAssembler 重复 `block-end` 真实 bug。
## 问题
基于示例的测试只能固定我们想到的用例。harness 的核心是协议形态的代码分片流、事件日志、schema 转换、收件箱调度。这类代码的输入空间是组合爆炸的,有趣的 bug 藏在没人写过示例的交错序列。佐证:一个 block 组装的排序 bug 曾在 happy path 100% 行覆盖率下存活。逐文件 100% 覆盖率只能证明每行都跑过不能证明每种交错都正确。
基于示例的测试只能固定我们想到的用例。harness 的核心是协议形态的代码分片流、事件日志、schema 转换、收件箱调度。这些场景的输入空间是组合的,有趣的 bug 藏在没人写过示例的交错序列。佐证:一个组装的排序 bug 曾在 happy path 100% 行覆盖率下存活。逐文件 100% 覆盖率证明每行都跑过了,但不能证明每种交错都正确
## 决策
引入 `fast-check`(根 devDependency在每个协议形态的包中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。原始提案还草拟了一个夜间 CI job以 100 倍迭代运行;该部分未交付——属性测试套件仅在常规 `push`/`pull_request` CI 中运行,定时高迭代 job 仍属可能的后续工作。)
引入 `fast-check`作为根 devDependency在每个协议形态的包package中编写一个 `tests/properties.spec.ts`。生成器调优为*逼真但对抗性*的输入(而非均匀噪声),`numRuns` 控制在本地套件总耗时远低于约 10 秒。失败时打印可复现的 seed。原始提案还草拟了一个夜间 CI job以 100 倍迭代运行;该部分未交付属性测试套件仅在常规 `push`/`pull_request` CI 中运行,定时高迭代 job 仍属可能的后续工作。)
- **dsh-llm / BlockAssembler** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start。不变式`blocks()`出现过的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无 `finish` 分片时默认为 `{kind:'stop'}`
- **dsh-session** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致seq 严格单调递增;非消息事件不影响派生历史;派生内容与日志解耦。
- **dsh-tools** 任意 `SchemaSpec`。不变式JSON Schema 的 `required` 等于每层 `required:true` 的键集;转换是全函数;**并且与[运行时参数校验](../architecture/2026-06-11-runtime-arg-validation.md)组合验证**——满足 spec 的生成参数通过 `validateArgs`,定向破坏(删除 required 键、顶层非 object)被拒绝。这封堵了 validator 与 `InferArgs` 漂移的风险。
- **dsh-agent-loop** 任意发送调度,对接一个永不耗尽的适配器,通过 `agent/status` settle 信号驱动(无挂钟 sleep。不变式无消息丢失轮次编号严格递增状态转换始终在合法状态机上。
- **dsh-llm / BlockAssembler** 任意分片流(合法 + 畸形:重复索引、滞后分片、缺少 block-start。不变式`blocks()` 数 ≤ 已见到的不同索引数;重组幂等(`blocks()` 在重复调用间稳定,且 `message().content` 与之一致);`blocks()` 从不抛异常且产出合法的 content-block 标签;`finish` 反映最后一个 `finish` 分片,无 `finish` 分片时默认为 `{kind:'stop'}`
- **dsh-session** 任意事件日志。不变式:`deriveMessages` 确定性;从 seed 回放结果一致seq 严格单调递增;非消息事件不影响推导出的历史;推导出的内容与日志解耦。
- **dsh-tools** 任意 `SchemaSpec`。不变式JSON Schema 的 `required` 等于每`required:true` 的键集;转换是全函数;**并且与[运行时参数校验](../architecture/2026-06-11-runtime-arg-validation.md)组合验证**——满足 spec 的生成参数通过 `validateArgs`定向破坏(删除必填键、顶层非对象)被拒绝。这封堵了 validator 与 `InferArgs` 漂移的风险。
- **dsh-agent-loop** 任意发送调度,对接一个永不耗尽的适配器,通过 `agent/status` settle 信号驱动(无挂钟 sleep。不变式无消息丢失轮次编号严格递增状态转换保持在合法状态机上。
## 后果
- 生成器质量是价值杠杆——生成器偏向小索引池和短字符串,使碰撞与交错频繁出现
- **已经产出回报:** BlockAssembler 流测试发现了一个真实 bug——同一索引的重复 `block-end`了已刷出的块,导致流式前缀与最终 `blocks()` 不一致。已修复(首次关闭生效,与既有的滞后分片规则一致),并附带专门的回归测试。
- 属性测试因超时而 flake 是一个发现,不应重试了事。agent loop 的属性测试在设计上是确定性的(通过 `agent/status` settle因此挂起即为真实缺陷。
- 属性测试是示例测试的补充而非替代;示例测试固定特定分支,服务于 100% 覆盖率门禁。
- 生成器质量是价值杠杆——生成器偏向小索引池和短字符串,使碰撞与交错频繁发生
- **已经产出回报:** BlockAssembler 流测试发现了一个真实 bug——同一索引的重复 `block-end`了已刷出的块,导致流式前缀与最终 `blocks()` 不一致。已修复(首次关闭生效,与既有的滞后分片规则一致),并附带一个专门的回归测试。
- 属性测试因超时而 flake 是一个发现,不应通过重试消除。循环属性测试在设计上是确定性的(通过 `agent/status` settle因此挂起即为真实缺陷。
- 属性测试是示例测试的补充而非替代;示例测试固定特定分支,服务于 100% 覆盖率门禁。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->

View File

@@ -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: 0b93c99932a33bca9945dd88ce45f4a1e100ccfc
2026-06-19-acp-snapshot-tests.zh.md: dc0aeb102039d3261ad6bbbb21321ca2b6ef5ec3
2026-06-19-acp-snapshot-tests.zh.md: 26c583ba47b8ec10ab3d0e2102a8b791549fda38

View File

@@ -6,11 +6,11 @@ Status: implemented
## 问题
单元测试无法覆盖完整的 ACP 子进程 transcript文本记录而真实 API 测试既不确定又依赖密钥。因此,面向编辑器的 `session/update` 输出可能在单元覆盖率全绿的情况下发生回归,正如 [default-export 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)所示的那样。
单元测试无法覆盖完整的 ACPAgent Client Protocol子进程 transcript文本记录而真实 API 测试既不确定又需要密钥。因此,面向编辑器的 `session/update` 输出可能在单元覆盖率全绿的情况下发生回归,正如 [default-export 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)所示的那样。
全 transcript 测试的阻塞在于模型agent(智能体)的输出由非确定性的 LLM大语言模型驱动而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度,同时具备 fixture测试前置数据的确定性。
全 transcript 测试的阻塞因素在于模型agent 的输出由非确定性的 LLM大语言模型驱动而每次运行都命中真实 API 的密钥门控测试既不确定也无法在 CI 中运行。我们需要真实运行的保真度 fixture测试前置数据的确定性兼得
本 RFC 记录了添加第三层测试——**快照测试**——的决策以及使其确定、CI 中无需密钥维护成本低的设计选择。
本 RFC 记录了新增第三层测试——**快照测试**——的决策,以及使其具备确定、CI 中无需密钥维护成本低的设计选择。
## 决策
@@ -18,11 +18,11 @@ Status: implemented
### fixture 即持久化的会话 JSONL
每个场景的 `session.jsonl` 从一次真实运行中采集。`assistant/chunk` 事件重现模型流;工具、消息和边界事件捕获 harness 行为。一份普通的会话产物因此同时充当回放源和行为 golden。
每个场景的 `session.jsonl` 从一次真实运行中采集。`assistant/chunk` 事件重现模型流;tool、message 和 boundary 事件捕获 harness 行为。一份普通的会话产物因此同时充当回放源和行为 golden。
### 回放从日志推导模型脚本
`llm-replay` 短路了提供方无关的 `llm/stream` waterfall瀑布式事件`deriveReplayScript()``(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。循环每步发起一次流调用,因此分组精确的,且无需特殊处理即可包含 error finish chunk
`llm-replay` 短路了提供方无关的 `llm/stream` waterfall瀑布式事件`deriveReplayScript()``(turn, step)` 对已录制的 chunk 分组,每次模型调用服务一组。agent loop智能体循环每个 step 发起一次流调用,因此分组精确对应,错误结束 chunk 也无需特殊处理
### 内存中的回放条目遵守完整的 LLM 契约
@@ -34,32 +34,32 @@ Status: implemented
| { kind: 'hang' }
```
日志推导出 chunk 条目。流开始前的抛出和挂起没有可重建的 chunk 表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀 chunk 以表示流中途失败。显式覆盖避免了从有损的 turn-end reason 推断适配器行为。
日志推导出 chunk 条目。流开始前的抛出和挂起没有可重建的 chunk 表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀 chunk 以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。
### 位置式回放,单个在途流
回放是位置式的,因此每个场景只允许一个在途模型流。并发会话快照需要按请求键索引的条目。调用顺序变需要重新录制fixture 缺失或耗尽时会大声失败
回放是位置式的,因此每个场景只允许一个在途模型流。并发会话快照需要按请求键索引的条目。调用顺序变需要重新录制fixture 缺失或耗尽时立即报错
### 录制采集日志;无密钥回放需要无提供方的配置
录制使用真实的 `llm-deepseek` 适配器和 JSONL 持久化后端运行场景,然后将产出的 `.jsonl` 复制到场景目录。逐事件追加是持久的,但 harness 在采集前会优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),确保最终事件已刷`llm-replay` 本身不做录制——它只负责回放。
录制使用真实的 `llm-deepseek` 适配器和 JSONL 持久化后端运行场景,然后将产出的 `.jsonl` 复制到场景目录。逐事件追加是持久的,但 harness 在采集前会优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),确保最终事件已刷`llm-replay` 本身不做录制它只负责回放。
回放使用 `cordis.snapshot.yml` 覆盖,将真实适配器替换为 `llm-replay`,同时保留活跃的组合。录制使用普通配置和 harness 提供的持久化根目录。回放模式跳过 `.env` 加载,因此一个意外存在的 API key 不会触发真实调用。见[源配置 RFC](2026-07-04-single-source-acp-replay-config.md)。
回放使用 `cordis.snapshot.yml` 覆盖配置,将真实适配器替换为 `llm-replay`,同时保留活跃的组合。录制使用普通配置和 harness 提供的持久化根目录。回放模式跳过 `.env` 加载,因此一个意外存在的 API key 不会触发真实调用。见[单源配置 RFC](2026-07-04-single-source-acp-replay-config.md)。
### 两个表面:归一化后比对
快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的:
1. **stdout transcript**——编辑器看到的帧 `session/update` JSON-RPC。捕获 ACP bridge 事件→update 转换(`streamSessionEventUpdate`)中的回归。与已提交的 `stdout.golden.jsonl` 比对。
2. **重新持久化的会话 JSONL**,归一化后与 `session.jsonl` 比对。同一份 fixture 既是回放源也是期日志。提示词文本被擦除;每个 header 类别一个场景固定可读的 prompt 和工具内容,见 [header-pinning RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)。覆盖场景的模型行为完全来自其伴随记录
1. **stdout transcript**——编辑器看到的`session/update` JSON-RPC。捕获 ACP bridge 事件→update 转换(`streamSessionEventUpdate`)中的回归。与已提交的 `stdout.golden.jsonl` 比对。
2. **重新持久化的会话 JSONL**,归一化后与 `session.jsonl` 比对。同一份 fixture 既是回放源也是期日志。提示词文本被擦除;每个 header 类别一个场景固定可读的 prompt 和 tool 内容,见 [header-pinning RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)。覆盖场景的模型行为完全来自其伴随文件
两个表面互补stdout 覆盖 bridge 投影JSONL 覆盖投影所省略的循环、工具和边界结构。
两个表面互补stdout 覆盖 bridge 投影JSONL 覆盖投影所省略的 loop、tool 和 boundary 结构。
归一化替换会话 ID、cwd、protocol-id、时间戳、路径和进程易变值同时保留确定性序列号。场景将真实 bash 使用限制在稳定命令。stdout golden 保持协议格式wire format的 JSONL每行原始数据必须解析为 JSON。Vitest 只更新 stdout golden归一化后的会话相等性检查从不覆回放 fixture。
归一化替换 session、cwd、protocol-id、时间戳、路径和进程相关的易变值,同时保留确定性序列号。场景将真实 bash 使用限制在稳定命令范围内。stdout golden 保持协议格式wire format的 JSONL行原始数据必须解析为 JSON。Vitest 只更新 stdout golden归一化后的会话相等性检查从不覆回放 fixture。
### 隔离:当前靠归一化,后续可沙箱
### 隔离:当前靠归一化,后续可沙箱
工具确定性来自临时 cwd、擦除的环境变量、全新的非登录 shell、受限命令和归一化。它不声称具备 OS 级隔离。如果需要更强的层级,沙箱执行器可以通过既有的[能力 seam](../architecture/2026-06-13-capability-seams.md) 替换本地后端。
工具确定性来自临时 cwd、擦除的环境变量、全新的非登录 shell、受限命令和归一化。它不声称具备操作系统级隔离。如果需要更强的隔离层级,通过既有的[能力 seam](../architecture/2026-06-13-capability-seams.md) 将沙箱执行器替换本地后端。
### 回放插件是独立的包
@@ -67,16 +67,16 @@ Status: implemented
### 两个子命令,回放在默认门禁中
`pnpm run test:snapshot` 无密钥回放已提交的 fixture`test:snapshot:record` 使用真实 API 并重写采集到的会话日志和 stdout golden。fixture 缺失时大声失败。每个场景携带 `input.json``stdout.golden.jsonl``session.jsonl`;无模型场景使用仅含 header 的日志。`replay.override.json` 仅在标记为 `overridden` 的场景中必需因为它的存在会替换推导出的回放。fixture 守卫拒绝缺失、不匹配和遗留的文件。两个命令接受场景过滤器。
`pnpm run test:snapshot`密钥回放已提交的 fixture`test:snapshot:record` 使用真实 API 并重写采集到的会话日志和 stdout golden。fixture 缺失时立即报错。每个场景携带 `input.json``stdout.golden.jsonl``session.jsonl`;无模型场景使用仅含 header 的日志。`replay.override.json` 仅在标记为 `overridden` 的场景中必需因为它的存在会替换推导出的回放。fixture 守卫拒绝缺失、不匹配和遗留的文件。两个命令接受场景过滤器。
## 曾考虑的替代方案
- **手写的模型 chunk `llm.json`**:早期草案复用真实会话日志使 fixture 成为系统的真实产物而非手工构建的 mock并兼作行为 golden。
- **字节级 HTTP 录制库Polly/nock/MSW**:否决。适配器相关、与流式 SSE 配合笨拙,且层级低于被测对象。
- **从 `turn/end {kind:'error'|'aborted'}` 合成 throw/cancel 条目**:否决。这会将 `llm-replay` 耦合到循环内部的 turn 关闭语义,且 `turn/end` reason 是有损的(无法区分抛出的 401 finish-error显式的 `replay.override.json` 伴随记录是更干净的 seam。
- **手工编写的模型 chunk `llm.json`**:早期草案的做法。复用真实会话日志使 fixture 成为系统的真实产物而非手工构建的 mock并兼作行为 golden。
- **字节级 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。
## 后果
新层为每个场景添加经评审的 input、session、stdout、可选 override 和可选 workspace fixture。workspace 种子在录制和回放时都被复制到临时 cwd。作为回报该层通过真实的 Loader 和工具组合提供确定性的无密钥 transcript 覆盖。子进程、input、workspace、归一化和回放 harness 可以支持 ACP 外的示例。
测试层为每个场景增加了经评审的 input、session、stdout、可选 override 和可选 workspace fixture。workspace 种子在录制和回放时都被复制到临时 cwd。作为回报该层通过真实的 Loader 和 tool 组合提供确定性的无密钥 transcript 覆盖。子进程、input、workspace、归一化和回放 harness 可以支持 ACP 外的示例。
本 RFC 与[拟议的确定性 RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) 相关但不取代它:该提案的「通用回放 fixture」在每次测试后重新推导会话*消息历史*(一内部一致性不变式),而快照测试固定的是*外部协议输出*。二者互补:一个守护事件溯源不变式,另一个守护面向编辑器的契约。
本 RFC 与[拟议的确定性 RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) 相关但不取代它:该提案的「通用回放 fixture」在每次测试后重新推导会话*消息历史*(一内部一致性不变式),而快照测试固定的是*外部协议输出*。二者互补:一个守护事件溯源不变式,另一个守护面向编辑器的契约。

View File

@@ -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: 3b5995a3e060ef9b4b1639b5c7fb17c819e73150
2026-06-19-real-api-e2e-ci.zh.md: 4d4b38cd5989426482b773da79b3a44cec90f747
2026-06-19-real-api-e2e-ci.zh.md: 78bab7c1e00125bfbe11b8f08eeeff3f2b7723b1

View File

@@ -1,100 +1,100 @@
# RFC在 CI 中对外部 DeepSeek API 运行真实 API e2e 测试
Status: implemented
[English](2026-06-19-real-api-e2e-ci.md) | 中文
Status: implemented
## 问题
按照既定策略harness 高度依赖真实 API 测试:[docs/testing.md](../../../testing.md) 论证了无密钥测试套件只能验证管道连通性而非产品行为[ACP inject 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)是现成的证据——178 个无密钥测试全绿,而真实编辑器会话一启动就崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)正是为弥合这一缺口:它驱动 agent 对接线上 DeepSeek API——真实模型调用、真实 bash 工具、多轮对话、恢复、ACP-over-stdio。
按照策略harness 高度依赖真实 API 测试:[docs/testing.md](../../../testing.md) 论证了无密钥套件只能验证管道连通性而非产品本身[ACP inject 事后分析](../../../postmortem/0001-acp-default-export-drops-inject.md)是现成的证据——178 个无密钥测试全绿,而真实编辑器会话一启动就崩溃。真实 API e2e 套件(`pnpm run test:e2e`,即 `*.e2e.ts` 文件)正是为弥合这一差距而存在的:它驱动 agent(智能体)对接实时 DeepSeek API——真实模型调用、真实 bash 工具、多轮对话、恢复、ACP-over-stdio。
默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意不携带密钥:它不含 secret可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此把它加到 ci.yml 只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。
默认门禁([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml))刻意无密钥:不携带 secret可供 fork 运行。`test:e2e` 在无密钥时自动跳过(`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`),因此将其加入该工作流只会报绿而不会真正执行真实套件。要让真实 API 覆盖率成为合并信号,需要一个独立的、携带 secret 的工作流。
本 RFC 记录的决策是:新增一个**第二个、消费 secret 的工作流**来在 CI 中运行真实 API 套件。同时,由于这是向一个未来可能公开的仓库引入首个 CI secret属于安全/隔离决策,本文一并记录其依赖的威胁模型以及仓库公开后会发生什么变化。
本 RFC 记录的决策是:添加一个**第二个、消费 secret 的工作流**来在 CI 中运行真实 API 套件。由于这是向一个未来可能公开的仓库引入首个 CI secret属于安全/隔离决策,本文同时记录其依赖的威胁模型以及仓库公开后变化。
## 决策
新增专用工作流 [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml),与 ci.yml 分离。它仅在受信事件上使用仓库 secret 对外部 API 运行 `pnpm run test:e2e`并设有预检步骤secret 缺失时以显式失败替代假绿。无密钥工作流保持独立,使可 fork 的质量门禁与消费 secret 的真实 API 门禁各自拥有不同的触发和凭证策略。
添加一个专用工作流 [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml),与 ci.yml 分离。它仅使用 repo secret 对外部 API 运行 `pnpm run test:e2e`仅在可信事件上触发,并带有一个 preflight 检查:将缺失的 secret 转化为明确的失败而非虚假的绿色。无密钥工作流保持独立,使可 fork 的质量门禁与消费 secret 的真实 API 门禁各自拥有不同的触发和凭证策略。
### 独立工作流,而非 ci.yml 中的一个 job
ci.yml 的价值在于它无密钥、可 fork、始终绿:任何贡献者(包括外部 fork都能获得完整的无密钥信号secret 不在爆炸半径内。在那里添加消费 secret 的 job 会将这个始终绿的门禁耦合到凭证可用性和不同的触发策略上。将携带 secret 的工作放在独立文件中,隔离了 secret、触发和并发策略并为 fork 保留了 ci.yml 的特性。不同的生命周期不同的文件。
ci.yml 的价值在于它无密钥、可 fork、始终绿:任何贡献者(包括外部 fork都能获得完整的无密钥信号secret 不在爆炸半径内。在其中添加消费 secret 的 job 会将这个始终绿的门禁耦合到凭证可用性和不同的触发策略上。将携带 secret 的工作放在独立文件中,隔离了 secret、触发和并发策略并为 fork 保留了 ci.yml 的特性。不同的生命周期不同的文件。
### 成本不是约束,可靠性才是
### 约束不是成本,而是可靠性
内部推理成本不是限制因素,因此工作流以覆盖率和信号为优化目标。它在多触发条件和每个信 PR 上运行所有匹配的 `*.e2e.ts` 文件,落实 [docs/testing.md](../../../testing.md) 的 with-key 策略。
内部推理inference成本不是限制因素,因此工作流以覆盖率和信号为优化目标。它在多触发条件和每个信 PRPull Request上运行所有匹配的 `*.e2e.ts` 文件,落实 [docs/testing.md](../../../testing.md) 的有密钥策略。
### 触发条件:仅信事件
### 触发条件:仅限可信事件
`workflow_dispatch` + `push``main`/`master` + 每日定时 `schedule``17 0 * * *`,即北京时间 08:17+ `pull_request`。push 提供合并后信号schedule 捕外部 API 漂移dispatch 是手动逃生口;受信 pull request 获得合并前门禁。该合并前信号有意接受 § 安全性 中描述的更大密钥暴露面。
`workflow_dispatch` + `push``main`/`master` + 每 `schedule``17 0 * * *`,即北京时间 08:17+ `pull_request`。push 提供合并后信号schedule 捕外部 API 漂移dispatch 是手动逃生通道;可信 pull request 获得合并前门禁。该合并前信号有意接受 § 安全性中描述的更大密钥暴露面。
### 不信 PR 的门禁
### 不信 PR 的门禁
GitHub 对两类 PR 隐藏仓库 secret来自 **fork** 的 PR以及 **Dependabot** PR同仓库分支因此 `head.repo.fork == false`,但 secret 仍被隐藏)。job 级 `if:` 对两者都跳过整个 job
GitHub 对两类 PR 扣留 repo secret来自 **fork** 的 PR以及 **Dependabot** PR同仓库分支`head.repo.fork == false`,但 secret 仍被扣留)。一个 job 级 `if:` 对两者都跳过整个 job
```
github.event_name != 'pull_request'
|| !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]')
```
Dependabot 子句基于 PR **作者**`pull_request.user.login`)而非 `github.actor`(运行触发者):维护者重新打开或重跑 Dependabot PR 时,`github.actor` 会变成人类,但 PR 仍然无密钥;基于作者的判断在这种情况下依然正确。被 **job 级** `if:` 跳过的 job 报告为*成功*检查(不同于工作流/触发级跳过,后者保持 pending因此如果需要,可以安全地将此工作流标记为 required status check——fork/Dependabot PR 的跳过但绿色的检查不会阻塞合并。
Dependabot 子句基于 PR **作者**`pull_request.user.login`)而非 `github.actor`(运行触发者):维护者重新打开或重跑 Dependabot PR 时,`github.actor` 会变成人类,但 PR 仍然无密钥;基于作者的判断在这种情况下依然正确。被 **job 级** `if:` 跳过的 job 报告为*成功*检查(不同于工作流/触发级跳过保持 pending因此如果需要将此工作流标记为 required status check 也是安全的——fork/Dependabot PR 的跳过但绿色的检查不会阻塞合并。
该门禁是一个*干净跳过的便利措施*,而非 secret 的安全边界(见 § 安全性——边界是 GitHub 自身在 `pull_request` 下对 fork 的 secret 隐藏机制)。没有这个门禁fork 仍然无法读取密钥;它们只会遇到一个令人困惑的预检硬失败并浪费计算资源。
该门禁是一个*干净跳过的便利措施*,而非 secret 的安全边界(见 § 安全性——边界是 GitHub 自身在 `pull_request` 下对 fork 的 secret 扣留机制)。没有门禁fork 仍然无法读取密钥;只会遇到令人困惑的 preflight 硬失败并浪费计算资源。
### 预检:大声失败,绝不绿
### Preflight:大声失败,绝不虚假为绿
由于 job 仅在 secret 预期存在的信事件上运行,预检是无条件的存在性检查:密钥为空`exit 1` 并附带 `::error::` 注解指明需要配置的 secret 名称。这是让自跳过套件可以安全用作门禁的关键。没有它,被删除/重命名/配置错误的 secret 会让 `test:e2e` 跳过所有真实套件并报告全绿——整个安全网的静默退化。这个守卫将「secret 缺失」从不可见的假通过为可见的失败。其正确性已在实际中验证secret 存在之前的运行恰好在此步骤失败。)
由于 job 仅在 secret 应当存在的信事件上运行,preflight 是一个无条件的存在性检查:密钥为空`exit 1` 并附带 `::error::` 注解指明需要配置的 secret 名称。这是让自跳过套件可以安全地作为门禁的关键。没有它,被删除/重命名/错误配置的 secret 会让 `test:e2e` 跳过所有真实套件并报告全绿——整个安全网的静默退化。守卫将「secret 缺失」从不可见的假通过转化为可见的失败。其正确性已在实际中验证secret 存在之前的运行恰好在此步骤失败。)
### Secret 映射与卫生
仓库 secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`它被映射到适配器和测试读取的 `DEEPSEEK_API_KEY` 环境变量(`process.env.DEEPSEEK_API_KEY`)。独立的 secret 名称记录了意图(这是*外部*公开 API 密钥,不是内部端点密钥),并允许内部端点密钥日后无冲突地共存。以下卫生选择均为防御性设计:
repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试读取的 `DEEPSEEK_API_KEY` 环境变量(`process.env.DEEPSEEK_API_KEY`)。独立的 secret 名称记录了意图(这是*外部*公开 API 密钥,不是内部端点密钥),并允许内部端点密钥日后无冲突地共存。以下卫生选择均为防御性设计:
- **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在预检和 e2e 步骤的 `env:` 中设置,不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。
- **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(不写 PR 评论、不写 status因此 `GITHUB_TOKEN` 降至最小权限。
- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) 中的 `PUBLIC_BASE_URL`),但显式固定具有自文档和密封性——一个意外的仓库根目录 `.env``vitest.e2e.config.ts` 如果存在会加载)无法静默地将运行重定向到其他端点。
- **不回显 secret。** 预检仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。
- **Step 级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。
- **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限( PR 评论、 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。
- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档和密封性——仓库根目录 `.env``vitest.e2e.config.ts` 存在会加载)无法静默地将运行重定向到其他端点。
- **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。
### 范围与运行时形态
job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界可配置 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和定时运行完整执行以提供合并后信号。
job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界可配置 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和 schedule 运行完整执行以提供合并后信号。
## 安全性
仓库的首个 CI secret 需要一份记录在案的威胁模型,因为同仓库 PR、fork PR 和 Dependabot PR 之间的访问权限同,且仓库公开后会发生变化。
仓库的首个 CI secret 需要一份记录在案的威胁模型,因为同仓库 PR、fork PR 和 Dependabot PR 的访问权限各不相同,且仓库公开后会发生变化。
### 今天谁能触及 secret私有仓库
### 当前谁能触及 secret私有仓库
- **无写权限fork PR不能。** 两个独立事实阻止了它。第一,工作流使用 `pull_request` 而**非** `pull_request_target`——GitHub 不会将仓库 secret 传递给 fork PR 的 `pull_request` 运行,因此 `secrets.DEEPSEEK_API_KEY_EXTERNAL` 在 fork runner 上解析为空。第二,`if:` 门禁完全跳过 fork PR。secret 隐藏机制是真正的边界;门禁是纵深防御和用户体验。
- **写push权限能。** 同仓库分支 PR 会收到 secret因此有写权限的作者可以修改测试代码或安装生命周期脚本或其分支上的工作流 YAML来窃取密钥。这是 **GitHub Actions 固有,并非本文引入的**:任何对任何仓库有 push 权限的人都可以通过编写工作流来窃取该仓库的任何 Actions secret。写权限secret 访问权,始终如此。缓解措施在于谁被授予写权限以及分支保护,而非本文件。
- **无写权限fork PR不能。** 两个独立事实阻止了它。第一,工作流使用 `pull_request` 而**非** `pull_request_target`——GitHub 不会将 repo secret 传递给 fork PR 的 `pull_request` 运行,因此 `secrets.DEEPSEEK_API_KEY_EXTERNAL` 在 fork runner 上解析为空。第二,`if:` 门禁完全跳过 fork PR。secret 扣留是真正的边界;门禁是纵深防御和用户体验。
- **push权限能。** 同仓库分支 PR 会收到 secret因此有写权限的作者可以修改测试代码或安装生命周期脚本或其分支上的工作流 YAML来窃取密钥。这**是 GitHub Actions 固有特性,并非本文引入的**:任何对任何仓库有 push 权限的人都可以通过编写工作流来窃取该仓库的任何 Actions secret。写权限secret 访问权,始终如此。缓解措施在于谁被授予写权限以及分支保护,而非本文件。
因此「任何能开 PR 的人都能窃取它」是错误的:只有写权限集合内的人能,而该集合本来就能窃取仓库持有的任何 secret。
因此「任何能开 PR 的人都能窃取它」是错误的:只有写权限集合内的人能,而这些人本来就能窃取仓库持有的任何 secret。
### `pull_request` 触发器增加的残余暴露面
由于启用了 PR 运行,密钥会在合并前被交给**写权限作者 PR 分支上的代码**。这比 `push` + `schedule` + `workflow_dispatch` 的暴露面更大,为了在受信写权限集合内获得合并前信号而接受。如果这一权衡发生变化,可以去掉 `pull_request` 触发器,同时保留合并后、每夜和按需覆盖。
由于启用了 PR 运行,密钥会在合并前被交给**写权限作者 PR 分支上的代码**。这比 `push` + `schedule` + `workflow_dispatch` 的暴露面更大,为在可信写权限集合内获得合并前信号而接受。如果这一权衡发生变化,可移除 `pull_request` 触发器,同时保留合并后、每夜和按需覆盖。
### 仓库公开后会发生什么变化
### 仓库公开后变化
**通过本工作流**secret 对公众仍然受保护:`pull_request` 在公开仓库上行为一致——fork PR现在任何人都能开仍然收不到 secret且在公开仓库上 GitHub 额外要求维护者批准 fork PR 运行,即使批准后运行也不会获得 secret批准运行不等于交出密钥。写权限集合不因可见性改变因此内部人员的现实也不变。
**通过本工作流**secret 对公众仍然受保护:`pull_request` 在公开仓库上行为一致——fork PR现在任何人都能开仍然收不到 secret且在公开仓库上 GitHub 额外要求维护者批准 fork PR 运行,即使批准后运行也不会获得 secret批准运行不等于交出密钥。写权限集合不因可见性改变而改变,因此内部人员的现实也不变。
变差的是*周边*模型,以下是翻转可见性之前需要处理的事项:
- **日志变为全球可读。** 今天泄露给组织成员的粗心 secret 回显,公开后会泄露给整个互联网并在分钟内被爬取。secret 处理纪律(不回显值/长度——已完成)的重要性大幅提升。
- **`pull_request_target` 陷阱变为灾难性的。** 如果有人为了「修复」PR 运行而将触发器切换为 `pull_request_target`,工作流将在 base 仓库上下文中运行不信的 fork 代码**携带** secret——完整的密钥泄露向量。在私有仓库上尚可容忍,在公开仓库则是灾难。e2e.yml 中触发器上的 `SECURITY —` 注释禁止此更改并指向本文。
- **翻转时轮换密钥。** 密钥曾存在于私有仓库的 CI 中;将公开视为「假已暴露」,在那一刻轮换 `DEEPSEEK_API_KEY_EXTERNAL`
- **将 secret 置于控制之下。** 确认 Settings → Actions → *"Send secrets to workflows from fork pull requests"* 保持**关闭**(这是唯一真正打破 fork 边界的设置),并考虑将密钥移入带有 required reviewers 的 GitHub **Environment**,使即使已合并的代码也只在受控条件下使用它,且轮换有一个统一的归属。
- **日志变为全球可读。** 今天泄露给组织成员的粗心 secret 回显,公开后会泄露给整个互联网并在分钟内被爬取。secret 处理纪律(不回显值/长度——已做到)的重要性大幅提升。
- **`pull_request_target` 陷阱变为灾难性的。** 如果有人为了「修复」PR 运行而将触发器切换为 `pull_request_target`,工作流将在 base-repo 上下文中运行不信的 fork 代码**携带** secret——完整的密钥泄露向量。在私有仓库中这勉强无害,在公开仓库则是灾难。e2e.yml 中触发器上的 `SECURITY —` 注释禁止此更改并指向本文。
- **翻转时轮换密钥。** 密钥曾存在于私有仓库的 CI 中;将公开视为「假已暴露」,在那一刻轮换 `DEEPSEEK_API_KEY_EXTERNAL`
- **将 secret 置于控制之下。** 确认 Settings → Actions → *"Send secrets to workflows from fork pull requests"* 保持**关闭**(这是唯一真正打破 fork 边界的设置),并考虑将密钥移入带有 required reviewers 的 GitHub **Environment**,使即使已合并的代码也只在受控条件下使用它,且轮换有一归属。
以上均不需要修改工作流即可公开仓库;它们是运维步骤加上已添加的 `pull_request_target` 守卫注释。
以上均不需要修改工作流即可公开;它们是运维步骤加上已添加的 `pull_request_target` 守卫注释。
## 曾考虑的替代方案
- **在 ci.yml 中添加消费 secret 的 job**:否决。会将无密钥、可 fork、始终绿的门禁耦合到凭证可用性和不同的触发/并发策略上;不同的生命周期,不同的文件。
- **省略 `pull_request` 触发器**(更小的密钥暴露面):为合并前信号而否决;安全性节承载了接受的暴露分析。
- **在 ci.yml 中添加消费 secret 的 job**:否决。会将无密钥、可 fork、始终绿的门禁耦合到凭证可用性和不同的触发/并发策略上;不同的生命周期,不同的文件。
- **省略 `pull_request` 触发器**(更小的密钥暴露面):为获得合并前信号而否决;安全性节承载了接受的暴露分析。
## 后果
新增一个 CI 工作流和仓库首个需要维护的 secret。真实 API 套件现在为合并门禁(信 PR 上的合并前门禁、main 分支上的合并后门禁)并每夜运行,因此 agent 与外部 API 交互中的真实故障会在 CI 中浮现,而非仅在开发者的本地运行中出现——代价是每个信 PR 和合并都会产生真实(但内部免费)API 调用。预检使 secret 配置错误变为自我通告而非静默禁用安全网。
新增一个 CI 工作流和仓库首个需要维护的 secret。真实 API 套件现在为合并门禁(信 PR 上的合并前门禁、分支上的合并后门禁)并每夜运行,因此 agent 与外部 API 交互中的真实故障会在 CI 中浮现,而非仅在开发者的本地运行中出现——代价是每个信 PR 和合并都会产生真实(但内部免费API 调用。preflight 使 secret 配置错误变为自我通告而非静默禁用安全网。
本设计携带一个记录在案的约束面:`pull_request` 触发器的密钥暴露权衡(去掉它以加固)、`if:` 门禁对基于作者的 Dependabot 判断的依赖,以及对 `pull_request_target` 的硬性禁止。上述公开清单是运维伴侣——本 RFC 是未来维护者在更改触发器集合或翻转仓库可见性之前应重新阅读的地方,而非从头重新推导 fork/secret 模型。
本设计携带一个记录在案的约束面:`pull_request` 触发器的密钥暴露权衡(移除以加固)、`if:` 门禁对基于作者的 Dependabot 判断的依赖,以及对 `pull_request_target` 的硬性禁止。上述公开清单是运维伴侣——本 RFC 是未来维护者在更改触发器集合或翻转仓库可见性之前应重读的地方,而非从头重新推导 fork/secret 模型。
定时触发器在仓库不活跃 60 天后会自动禁用GitHub 行为push/PR/dispatch 是后备,活跃的 monorepo 不会触及此限制。假设 runner 可出站访问 `https://api.deepseek.com`——GitHub 托管的 `ubuntu-latest` 具备此条件;出站限的自托管 runner 需要在依赖每夜运行之前确认连通性。
schedule 触发器在仓库不活跃 60 天后会自动禁用GitHub 行为push/PR/dispatch 是后备,活跃的 monorepo 不会触及此限制。假设 runner `https://api.deepseek.com` 有出站连通性——GitHub 托管的 `ubuntu-latest` 具备此条件;出站限的自托管 runner 需要在依赖每夜运行之前确认连通性。

View File

@@ -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-goldens.md: badd32d4479ac6d44bb7be3cd262cba57b1b3a38
2026-06-20-remove-redundant-snapshot-log-goldens.zh.md: 35e4a698cbd19bcceb97df714d2b6bfb371c155a
2026-06-20-remove-redundant-snapshot-log-goldens.zh.md: b791fbcc971eb1340f0d43ef6a60ebb29e4711f9

View File

@@ -6,32 +6,32 @@ Status: implemented
## 问题
模型驱动的 ACP 快照场景同时包含 `session.jsonl``session.golden.jsonl`。对于普通录制场景,`session.jsonl` 是从真实运行中采集的回放 fixture测试前置数据回放测试新持久化的日志归一化后与 `session.golden.jsonl` 比较。在当前 fixture 中,普通录制场景的归一化录制日志与归一化 golden 完全相同
模型驱动的 ACPAgent Client Protocol快照场景同时包含 `session.jsonl``session.golden.jsonl`。对于普通录制场景,`session.jsonl` 是从真实运行中采集的回放 fixture测试前置数据回放测试新持久化的日志归一化后与 `session.golden.jsonl` 比较。在当前 fixture 中,普通录制场景的归一化录制日志与归一化 golden 完全一致
手工编写的覆盖场景(`error-finish``cancel`)目前使用 `replay.override.json` 驱动模型行为,并保留 `session.jsonl` 作为最小占位 fixture`session.golden.jsonl` 存放预期的持久化日志。覆盖文件是一个 `ReplayEntry` 对象的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }``{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }``{ "kind": "hang" }`。这种拆分同样没有必要:当覆盖 sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 获取模型分片,因此 `session.jsonl`然可以充当该场景的预期会话日志产物。
手工编写的覆盖场景(`error-finish``cancel`)目前使用 `replay.override.json` 驱动模型行为,并保留 `session.jsonl` 作为最小占位 fixture`session.golden.jsonl` 存放预期的持久化日志。覆盖文件是一个 `ReplayEntry` 对象的 JSON 数组:`{ "kind": "chunks", "chunks": StreamChunk[] }``{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }``{ "kind": "hang" }`。这种拆分同样是多余的:当覆盖 sidecar 存在时,`llm-replay` 会替换派生脚本,不需要从 `session.jsonl` 获取模型分片,因此 `session.jsonl`可作为该场景的预期会话日志产物。
## 决策
彻底移除 `session.golden.jsonl` 概念。每个场景最多只有一个提交的会话日志产物 `session.jsonl`
彻底移除 `session.golden.jsonl` 概念。每个场景最多只有一个提交到仓库的会话日志产物,即 `session.jsonl`
- 对于录制场景,`session.jsonl` 仍是原始采集的日志。回放仍从中派生模型分片,快照测试将回放运行归一化持久化日志与归一化后的 `session.jsonl` 比较。
- 对于手工编写的覆盖场景,`replay.override.json` 驱动模型行为,`session.jsonl` 存放预期产出的会话日志。当覆盖文件存在时回放适配器会忽略 fixture 中的模型分片,因此同一个文件既可作为预期日志,又不影响回放行为。
- 对于无模型场景,`session.jsonl`保留为启动 `llm-replay` 所需的最小 fixture除非场景创建了持久化会话,否则无需进行会话日志比较。
- 对于录制场景,`session.jsonl` 仍是原始采集的日志。回放仍从中派生模型分片,快照测试将回放运行归一化后的持久化日志与归一化后的 `session.jsonl` 进行比较。
- 对于手工编写的覆盖场景,`replay.override.json` 驱动模型行为,`session.jsonl` 存放预期产出的会话日志。当覆盖文件存在时回放适配器不从 fixture 获取模型分片,因此同一个文件既可作为预期日志,又不影响回放行为。
- 对于无模型场景,`session.jsonl` 可保留为引导 `llm-replay` 所需的最小 fixture除非场景创建了持久化会话否则无需进行会话日志比较。
stdout golden 保持不变;它们是面向编辑器的投影,与会话 fixture 不冗余。
stdout golden 保持不变;它们是面向编辑器的投影,与会话 fixture 不构成冗余。
## 曾考虑的替代方案
**基于共享(回放运行)上下文对两侧进行归一化**:否决。`normalizeSessionLog` 通过精确字符串匹配擦除 cwd因此 fixture 中录制的 cwd 不会被擦除,每次比较都会失败。两侧各自基于自身 header 派生的上下文进行归一化——下方的实现说明描述了具体机制。
**对两侧基于共享(回放运行)上下文归一化**:否决。`normalizeSessionLog` 通过精确字符串匹配擦除 cwd因此 fixture 中录制的 cwd 不会被擦除,每次比较都会失败。两侧各自基于自身 header 派生的上下文归一化——下方的实现说明描述了具体机制。
## 验证
`session.golden.jsonl` 不再出现在快照 harness、fixture、遗留文件守卫文档中的任何位置;快照测试对每个模型场景都从 `session.jsonl` 派生预期会话日志;手工编写的 sidecar 场景将预期产出的日志提交`session.jsonl`,并以 `replay.override.json` 作为模型行为覆盖;遗留 fixture 守卫知道每种场景类型需要哪些文件。[ACP 快照测试 RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) 描述了精简后的 fixture 集合。
`session.golden.jsonl` 在快照 harness、fixture、遗留文件守卫文档中均不再出现;快照测试对每个模型场景都从 `session.jsonl` 派生预期会话日志;手工编写的 sidecar 场景将预期产出的日志`session.jsonl` 提交,并以 `replay.override.json` 作为模型行为覆盖;遗留 fixture 守卫知道每种场景类型需要哪些文件。[ACP 快照测试 RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) 描述了精简后的 fixture 集合。
## 后果
评审失去了一个让预期持久化日志在视觉上与回放 fixture 分离的产物名称。stdout golden 仍保护编辑器 transcript文本记录将回放输出与 `session.jsonl` 比较则在不重复文件的前提下保留了 agent loop智能体循环/持久化的回归检查。
评审失去了一个让预期持久化日志在视觉上与回放 fixture 分离的产物名称。stdout golden 仍保护编辑器 transcript文本记录将回放输出与 `session.jsonl` 比较则在不重复文件的前提下保留了循环/持久化的回归检查。
## 实现说明
两侧各自基于自身 header 值进行归一化,因为录制与回放具有不同的 id、路径和时间戳。`fixtureContext()` 从 fixture 的 header 派生 fixture 上下文,使已归一化的 fixture 具有幂等性。会话日志使用普通相等比较而非文件快照更新,因此比较过程永远不会改写 fixture。
两侧各自基于自身 header 值归一化,因为录制与回放具有不同的 id、路径和时间戳。`fixtureContext()` 从 fixture 的 header 派生上下文,使已归一化的 fixture 具有幂等性。会话日志使用普通相等比较而非文件快照更新,因此比较过程不会改写 fixture。

View File

@@ -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: a0bf064508107a23147df1a6c824c53a3906c43e
2026-06-22-fork-child-replay-seed-boundary.zh.md: 7ee6c3d373ff5e598efa82d5c8fbad6b8e162aeb
2026-06-22-fork-child-replay-seed-boundary.zh.md: 3825cce806c036c7fa21641a2f0b7cc0533d84bf

View File

@@ -1,18 +1,18 @@
# RFC持久化 seed 边界以确保 fork 子会话回放路由正确
Status: implemented
# RFC持久化 seed 边界以确保 fork 子会话回放正确路由
[English](2026-06-22-fork-child-replay-seed-boundary.md) | 中文
Status: implemented
## 问题
[逐会话快照回放 RFC](2026-06-22-subagent-snapshot-replay.md) 让快照层表达了嵌套 agent 的结构:一个父会话加上每个进程内 subagent 各一份录制日志,每份日志以调用方会话为键独立回放为自己的脚本。该 RFC 在 §Scope 末尾提到 fork 快照是「一个简单的后续补充,不是键控方案的缺口」。这个说法对 fork 子会话而言是错的——问题不在键控,而在*脚本推导*。
[逐会话快照回放 RFC](2026-06-22-subagent-snapshot-replay.md) 让快照层表达了嵌套 agent(智能体)的形状:一个父会话加上每个进程内 subagent 各一份录制日志,各自作为独立脚本回放、以调用方会话为键。该 RFC 指出(§ Scope 末尾条目)fork 快照是「一个平凡的后续补充,不是键控方案的缺口」。这对 fork 子会话而言是错的——问题不在键控,而在*脚本推导*。
subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-replay) 从录制的会话日志推导而来:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自的模型调用。
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 后端用*父日志一段平衡的已完成轮次前缀*[`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()` 时,会收到父会话的第一段 chunk 序列而非自的。目前录制的场景全部是 spawn所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。
## 决策
@@ -20,30 +20,30 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/support/llm-repla
### 1. 会话头部的 `seedLength`
`SessionHeader` 新增可选字段 `seedLength: number`:表示前导多少个事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= seed 前缀长度);新的 spawn 子会话不设置(等于 0该字段通过 `CreateSessionOptions.meta``CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。
`SessionHeader` 新增可选字段 `seedLength: number`——表示有多少前导事件是通过 seed 继承而来、而非本会话产生的。fork 后端在创建子会话时设置它(= 播种前缀长度);新的 spawn 子会话不设置(等于 0通过 `CreateSessionOptions.meta`(及 `CreateAgentOptions.meta`)传递,在 `SessionStore.prepare` 中设置。
`seedLength` 是**显式**的,不从 `seed.length` 推断。重建resume/load时用会话的完整存储日志作为 seed此时 `seed.length` 是全长而非原始边界——重建路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:重建时显式保留,而非重新默认为当前时间。)
`seedLength` 是**显式**的,不从 `seed.length` 推断。重建resume/load时用会话的完整存储日志作为 seed此时 `seed.length` 是全长而非原始边界——resume 路径改为从加载的 header 中取回持久化的 `seedLength`。(形状与 `createdAt` 相同:重建时显式保留,而非重新默认为当前时间。)
### 2. 两个持久化后端完整往返
### 2. 两个持久化后端完整往返
- **JSONL**header 行上的 `seedLength` 字段(`toHeaderLine`/`fromHeaderLine`)。
- **SQLite**`sessions` 表上的 `seed_length` 列。
包含 `seed_length``source_event_seqs``surface_op` 的 SQLite 布局为 schema version 4。更早的 version 3 布局存在歧义,因此预发布策,所有非当前 `user_version` 均直接拒绝,不做迁移。
包含 `seed_length``source_event_seqs``surface_op` 的 SQLite 布局为 schema version 4。更早的 version 3 布局存在歧义,因此预发布策略下,所有非当前 `user_version` 均直接拒绝,不做迁移。
### 3. 回放边界之后推导子会话脚本
### 3. 回放边界之后推导子会话脚本
`dsh-llm-replay``parseSessionHeader` 现在也读取 `seedLength`(缺失 0`loadSessionScripts``parseSessionLog(text).slice(seedLength)` 推导子会话条目——即边界及之后的事件,也就是子会话自的模型调用。对 spawn 子会话而言 `seedLength` 为 0这是一个空操作,因此 spawn 场景逐字节不变。
`dsh-llm-replay``parseSessionHeader` 现在也读取 `seedLength`(缺失则为 0`loadSessionScripts``parseSessionLog(text).slice(seedLength)` 推导子会话条目——即边界及之后的事件,也就是子会话自的模型调用。对 spawn 子会话而言 `seedLength` 为 0此操作是空操作spawn 场景逐字节不变。
这关闭了路由正确性的缺口,两个录制的 fork 场景对其进行端到端验证——见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md)。
这关闭了路由正确性的缺口,两个录制的 fork 场景对其进行端到端验证——见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md)。
## 曾考虑的替代方案
- **在 `llm-replay` 中启发式推导边界**seed 前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头fork 后端)持久化边界,是「包边界处显式优于隐式」规则跨持久化边界的应用——子会话 fixture 的读取永远不需要重建继承在哪里结束。
- **固定格式版本而不递增**(事件日志使用的 `SESSION_FORMAT_VERSION = 0`「不稳定」策略)。对 SQLite *表*布局否决:`SCHEMA_VERSION` 是单调递增并拒绝旧版的旋钮(一组值得区分的修订),与事件词汇的 `version` 不同。新增列正是它所版本化的那种破坏性表结构变更,因此递增。
- **在 `llm-replay` 中启发式推导边界**播种前缀是连续的父事件,止于子会话第一条 `user/message` 之前的最后一个 `turn/end`)。否决:在测试 harness 中用脆弱的启发式重新推导一个生产者已经知道的事实。在源头fork 后端)持久化边界,是「在包packageseam 处显式优于隐式」这条规则跨持久化边界的应用——子会话 fixture(测试前置数据)的读取永远不需要重建继承在哪里结束。
- **固定格式版本而不递增**(事件日志使用的 `SESSION_FORMAT_VERSION = 0`「不稳定」姿态)。对 SQLite *表*布局否决:`SCHEMA_VERSION` 是单调递增并拒绝旧版的旋钮(一组小的、值得区分的修订),与事件词汇`version` 不同。新增列正是它所版本化的那种破坏性表变更,因此需要递增。
## 后果
- core 与两个后端之间新增一个持久化 header 字段;核心数据结构目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions``type-equiv` 块)。
- core 与两个后端新增一个持久化 header 字段;核心数据结构目录(`persistence.md`)在同一变更中更新(其 `SessionHeader` / `CreateSessionOptions``type-equiv` 块)。
- 既有的 schema v2 SQLite 数据库在打开时被拒绝(预发布阶段无用户数据)。
- spawn 回放不变(`seedLength` 为 0。fork 回放现在将子会话路由到自的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture seed 前缀包含父会话的 chunk——推导出的子会话脚本必须排除它不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。
- spawn 回放不变(`seedLength` 为 0。fork 回放现在将子会话路由到自的脚本;由 `llm-replay` 测试中的一个回归用例覆盖(一个子会话 fixture播种前缀包含父会话的 chunk——推导出的子会话脚本必须排除它不做 slice 时该用例为红)以及一个持久化往返测试(两个后端,通过共享的 coordinator 契约)。

View File

@@ -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: baca94d6a1071ec38ee20ca841fc3472a870b1a2
2026-06-22-fork-snapshot-scenarios.zh.md: 227d54cc2bb2e66a391dddd29a7f2593cb02f7e2
2026-06-22-fork-snapshot-scenarios.zh.md: b6f3f6a6f318a343d5e32573d39f11f59b509ee3

View File

@@ -1,31 +1,31 @@
# RFC记录 fork 与混合 spawn+fork 快照场景
[English](2026-06-22-fork-snapshot-scenarios.md) | 中文
Status: implemented
[English](2026-06-22-fork-snapshot-scenarios.md) | 中文
## 问题
[seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) fork 子会话的回放路由正确工作了`dsh-llm-replay` 从子会话持久化的 `seedLength` 边界处或之后的事件推导出子会话的脚本,因此 fork 子会话继承的父会话前缀不会被当作子会话自身的模型调用来回放。但该 RFC 交付时**没有录 fork 场景**:切片逻辑仅由 `llm-replay` 的单元测试(一个合成的子会话 fixture测试前置数据和一个持久化往返测试覆盖。全 transcript文本记录快照层——那个启动真实 `acp-agent` 并回放端到端嵌套 transcript 的网——只有 spawn 子会话(`subagent-spawn``subagent-multi`)。一个让单元测试保持绿色的 fork 路由回归,仍然会逃过专为捕获 transcript 回归而建的那一层。
[seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) 使 fork 子会话的回放路由正确运作`dsh-llm-replay` 从子会话持久化的 `seedLength` 边界处或之后的事件推导出子会话的脚本,因此 fork 子会话继承的父会话前缀不会被当作子会话自身的模型调用来回放。但该 RFC 交付时**没有录 fork 场景**——该切片仅由 `llm-replay` 的单元测试(一个合成的子会话 fixture测试前置数据和一个持久化往返测试覆盖。全 transcript文本记录快照层(即启动真实 `acp-agent` 并回放端到端嵌套 transcript 的那张网)只有 spawn 子会话(`subagent-spawn``subagent-multi`)。如果一个 fork 路由回归让单元测试保持绿色,它仍然会逃过专为捕获 transcript 回归而建的那一层。
表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都通过 `cordis.yml` / `cordis.snapshot.yml` 接入为两个面向模型的工具(`subagent` → spawn、`subagent_fork` → forkharness 收集每个子会话的日志,回放按 `seedLength` 为键转发每个子会话的 fixture。缺少的是一个*录制好的场景*来驱动 fork 子会话走完这条路径。
表达 fork 场景所需的快照基础设施已经就位:两个进程内后端都 `cordis.yml` / `cordis.snapshot.yml` 中以两个面向模型的工具接入`subagent` → spawn、`subagent_fork` → forkharness 收集每个子会话的日志,回放按 `seedLength` 为键转发子会话的 fixture。缺少的是一个**已记录的场景**来驱动 fork 子会话走完这条路径。
## 决策
对真实 API 录两个场景,均在默认门禁中以 keyless 方式回放:
对真实 API 录两个场景,均在默认门禁中以无密钥方式回放:
- **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的录而非手工合成。
- **`subagent-mixed`**:父会话完成一个轮次,然后在同一个 transcript 中分别通过 `subagent`(全新的 spawn 子会话,`seedLength` 为 0`subagent_fork`fork 子会话,非零 `seedLength`)各委派一次。这是 seed-boundary 和 per-session-replay 两份 RFC 都提到的「未来补充的混合 spawn+fork 场景:一个 transcript 同时覆盖两种传输方式和切片的两个分支(`seedLength` 0 = 无操作,`seedLength > 0` = 裁继承的前缀),两个子会话按 `createdAt` 排序为先 spawn 后 fork。
- **`subagent-fork`**:父会话完成一个轮次以建立一个事实,然后通过 `subagent_fork` 委派一个子任务。fork 子会话继承对话(其日志携带非零 `seedLength`),因此可以从父会话的上下文中作答。这是聚焦的回归守卫:子会话 fixture 的 `seedLength` 就是回放切片所依赖的边界,来自真实 fork 的录而非手工合成。
- **`subagent-mixed`**:父会话完成一个轮次,然后在同一个 transcript 中分别通过 `subagent`(全新的 spawn 子会话,`seedLength` 为 0`subagent_fork`fork 子会话,`seedLength` 非零)各委派一次。这是 seed-boundary 和 per-session-replay 两份 RFC 都列为后续补充的混合 spawn+fork 场景:一个 transcript 同时覆盖两种传输方式和切片的两个分支(`seedLength` 0 = 无操作,`seedLength > 0` = 裁继承的前缀),两个子会话按 `createdAt` 排序为先 spawn 后 fork。
### 为什么需要一个已完成的第一轮次
fork 后端用父会话的**已完成轮次的平衡前缀**[`completedTurnPrefix`](../../../../packages/subagent/subagent-fork))来填充子会话种子。如果父会话在第一轮次就 fork则没有已完成的轮次可继承种子为空(≡ 全新 spawn`seedLength` 为 0这**不会**覆盖切片逻辑。因此两个场景都使用两条提示词输入:第一条提示词完成一个轮次(建立一个 codeword子会话稍后回忆),第二委派 fork。子会话 transcript 中回忆出的 codeword 只是模型行为的附带结果;真正承载验证的产物是子会话 fixture 中录`seedLength`,回放切片消费的正是它。
fork 后端用父会话的**已完成轮次的平衡前缀**[`completedTurnPrefix`](../../../../packages/subagent/subagent-fork))来初始化子会话。如果父会话在第一轮次就 fork则没有已完成的轮次可继承seed 为空(等价于全新 spawn`seedLength` 为 0这**不会**覆盖切片逻辑。因此两个场景都使用双 prompt 输入:第一个 prompt 完成一个轮次(建立一个 codeword子会话稍后被要求回忆),第二个 prompt 委派 fork。子会话 transcript 中回忆出的 codeword 只是模型行为的附带产物;真正承载验证的产物是子会话 fixture 中录的 `seedLength`,回放切片消费的正是它。
## 后果
- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话录的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。
- `subagent-mixed` 是第一个在同一个 transcript 中驱动两*不同* subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。
- 进程外ACPsubagent 回放是另一种形态(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。
- 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture两个新场景在没有 key 时与所有录制场景一样自动跳过
- fork 路由切片现在由全 transcript 层守卫,而不仅仅是单元测试。移除 `slice(seedLength)`(回放整个子会话日志)会让**两个**新场景变红——fork 子会话收到的是父会话录的 chunk 而非自己的——证明守卫确实生效(场景落地时已验证红→绿)。
- `subagent-mixed` 是第一个在同一个 transcript 中驱动两种**不同** subagent 后端的快照场景,同时覆盖了跨 spawn 和 fork 子会话的 per-session 回放键控。
- 进程外ACPsubagent 回放形态不同(每个子会话是独立进程、有自己的回放),仍以 `TODO(acp-subagent-replay)` 跟踪——本文场景仅限进程内。
- 重新录制(`pnpm run test:snapshot:record`)会从真实 API 重新生成全部四个 fork/spawn fixture两个新场景在无密钥时自动跳过,与所有录制场景一
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->

View File

@@ -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: fbb2e5b93cced118a24f5560f229e2dc341bf3b2
2026-06-22-subagent-snapshot-replay.zh.md: fd4ba0c109d77fdf9b64e25de74d3da577ce5a9b
2026-06-22-subagent-snapshot-replay.zh.md: 6514a0bcb5db3948f6d8f4693b17a74e4a9ad926

View File

@@ -1,58 +1,58 @@
# RFC嵌套 agent 的逐会话快照回放
Status: implemented
[English](2026-06-22-subagent-snapshot-replay.md) | 中文
Status: implemented
## 问题
快照测试层(`pnpm run test:snapshot`)启动真实的 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 回放录制的会话,并将归一化后的 stdout transcript文本记录与重新持久化的会话日志提交的 golden 文件做 diff。这是唯一一个端到端验证完整编辑器侧 transcript 的测试层。
快照测试层(`pnpm run test:snapshot`)启动真实的 `acp-agent` 子进程,通过 [`dsh-llm-replay`](../../../../packages/support/llm-replay) 回放录制的会话,并将归一化后的 stdout transcript文本记录与重新持久化的会话日志对已提交的金标文件做 diff。这是唯一一个端到端验证完整编辑器侧 transcript 的测试层。
最初为**单会话单进程**而建,这一假设硬编码在两处:
该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处:
- **`dsh-llm-replay` 没有任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent 和进程内 subagent 同时在一个 context 上流式输出时,调用交错,单一游标会把子 agent 的脚本给父 agent反之亦然
- **harness 只收一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的**第一个** `.jsonl`。subagent 作为同一 cwd bucket 中的第二个 `Session` 运行、拥有自己的日志,因此子 agent 的 transcript 被静默丢弃。
- **`dsh-llm-replay` 没有任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent 和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本给父 agent反之亦然
- **harness 只收一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行,在同一个 cwd bucket 下有自己的日志,因此子 agent 的 transcript 被静默丢弃。
是 [subagent seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 中记录的 `TODO(subagent-snapshots)` 延期项进程内后端PR2已有单元测试和 e2e 覆盖,但全 transcript 快照层在本基础设施落地之前无法表达嵌套 agent 的形态。本 RFC 即为该堆叠后续。
是 [subagent seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 中记录的 `TODO(subagent-snapshots)` 延期项进程内后端PR2已有单元测试和 e2e 覆盖,但全 transcript 快照层在本基础设施就绪之前无法表达嵌套 agent 的形态。本 RFC 即为该堆叠后续。
## 决策
回放按**调用方会话**键控harness 收**所有**会话日志。
回放按**调用方会话**键控harness 收**所有**会话日志。
### 1. 调用方会话 id 模型请求传递
### 1. 调用方会话 id 附着在模型请求
`GenerateOptions` 新增可选字段 `sessionId`,在请求组装时从 `agent.session.id` 打入。适配器忽略它;`llm/stream` 监听器用它按发起会话路由。其类型为 `Branded<'SessionId'>`(来自 `dsh-brand`)而非 `dsh-session``SessionId`,因为后者所在包导入了 `dsh-llm``Message`,反向导入会形成循环。两个类型等价,会话 id 赋值无需强制转换。将 brand 移专用 ids 包属于独立工作,因为它会触及所有 id 导入。
`GenerateOptions` 新增可选字段 `sessionId`,在请求组装时从 `agent.session.id` 赋值。适配器忽略它;`llm/stream` 监听器用它按发起会话路由。其类型为 `Branded<'SessionId'>`(来自 `dsh-brand`)而非 `dsh-session``SessionId`,因为后者所在包package导入了 `dsh-llm``Message`,反向导入会形成循环。两个类型等价,因此会话 id 赋值无需类型转换。将 brand 移到一个专用 ids 包属于独立工作,因为它会影响所有 id 导入。
### 2. 回放按首次调用顺序将活跃会话绑定到录制脚本
嵌套场景录制不止一份日志:父会话(`session.jsonl`)加每个 subagent 子会话各一份(`session.1.jsonl`……)。`dsh-llm-replay` 全部加载,为每个录制会话推导一份脚本,并按 header 中的 `createdAt` 排序(父会话先于子会话创建)。
嵌套场景录制份日志:父会话(`session.jsonl`)加每个 subagent 子会话各一份(`session.1.jsonl`……)。`dsh-llm-replay` 全部加载,为每个录制会话派生一份脚本,并按 header 中的 `createdAt` 排序(父会话先于子会话创建)。
活跃会话 id 每次运行都是全新随机值,永远不等于录制时的 id因此活跃会话无法通过 id 相等绑定脚本。取而代之的是**首次调用顺序**绑定:第一个发起模型调用的活跃会话认领排序第一的脚本(即父会话——`createdAt` 最早,且必然最先流式输出,因为它必须先运行一个轮次才能委派),下一个新活跃会话认领下一份脚本,依此类推。后每个会话独立推进自己的游标。
活跃会话 id 每次运行都是全新随机值,永远不等于录制时的 id因此活跃会话无法通过 id 相等绑定脚本。取而代之的是**首次调用顺序**绑定:第一个发起任何模型调用的活跃会话认领第一份有序脚本(即父会话`createdAt` 最早,且必然最先流式输出,因为它必须先运行一个轮次才能委派),下一个新活跃会话认领下一份脚本,依此类推。后每个会话独立推进自己的游标。
这按**谁在调用**键控,而非按全局调用顺序——因此即使 subagent 将来并发运行或在后台运行也保持正确(全局游标会导致交错)。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径的行为与旧版逐字节一致。活跃会话数多于录制脚本数是一个 fail-loud 错误(出现了未录制的 subagent绝不会静默误路由。
种方式按**谁在调用**键控,而非按全局调用顺序因此即使 subagent 将来并发或在后台运行(全局游标会导致交错),它仍然正确。不携带 `sessionId` 的调用(直接在单元测试中调用 `stream()`)被视为一个匿名会话、绑定到主脚本,因此单会话路径与旧行为逐字节一致。活跃会话数多于录制脚本数时会快速失败报错(出现了未录制的 subagent绝不会静默误路由。
子 fixture `createdAt` 排序在兄弟会话严格顺序执行时与调用顺序一致。id 平局打破只是让退化碰撞确定。并发或后台子会话必须引入显式的首次调用序号,而非依赖时间戳。
子 fixture(测试前置数据)`createdAt` 排序在兄弟会话严格顺序执行时与调用顺序一致。id 平局打破仅使退化碰撞具有确定。并发或后台子会话必须引入显式的首次调用序号,而非依赖时间戳。
## 曾考虑的替代方案
曾考虑否决的方案是将父子日志**按调用顺序合并**为一份全局脚本(仅在进程内 subagent 严格嵌套执行——父 agent 阻塞等待子 agent——时才正确。对当前的同步切面更简单,但「父阻塞于子」这一不变式烤死了;未来的后台/并发 subagent 会打破它,而逐会话键控不会。
曾考虑否决的方案是**将父子日志按调用顺序合并**为一份全局脚本(仅在进程内 subagent 执行严格嵌套——父 agent 阻塞等待子 agent——时才正确。对当前的同步裁剪而言更简单,但「父阻塞于子」这一不变式固化了进去;未来若引入后台/并发 subagent 就会失效。逐会话键控不会。
### 3. harness 收所有日志,主会话优先
### 3. harness 收所有日志,主会话优先
`harvestSessionLogs` 收集 sessions 根目录下每个 cwd bucket 中的所有 `.jsonl`JSONL 后端将父会话与同 cwd 的子会话放在同一 bucket解析各自的 header并按主会话优先排序顶层会话`parentSession`)在前,子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果spec 在录制时将每份日志写回 fixture`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收的日志与对应 fixture 做 diff。归一化器已接受复数会话 id 并折叠任何游离 UUID因此无需修改归一化器。
`harvestSessionLogs` 收集 sessions 根目录下每个 cwd bucket 中的所有 `.jsonl`JSONL 后端将父会话与同 cwd 的子会话放在同一 bucket解析各自的 header并按主会话优先排序顶层会话`parentSession`)在前,子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果spec 在录制时将每份日志写回对应 fixture`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收集到的日志与 fixture 做 diff。归一化器已支持复数会话 id 并折叠任何游离 UUID因此无需修改归一化器。
### 4. 场景
新增两个嵌套场景,均对真实 API 录制:
- **`subagent-spawn`**:父 agent 通过 `subagent` 工具将一个子任务委派给一个新 spawn 子会话2 个会话)。
- **`subagent-multi`**:父 agent 委派两个子任务,各自交给独立的 spawn 子会话3 个会话),以三份并行脚本和同一父会话下两个子会话的 `createdAt` 排序来压测逐会话键控。
- **`subagent-spawn`**:父 agent 通过 `subagent` 工具将一个子任务委派给一个新 spawn 的子 agent2 个会话)。
- **`subagent-multi`**:父 agent 委派两个子任务,各自交给自己的 spawn 子 agent3 个会话),以三份并行脚本和同一父 agent 下两个子会话的 `createdAt` 排序来压测逐会话键控。
两者均在默认门禁中以 keyless 方式回放。
## 后果
- `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent transcript 现在是快照的一等形态。
- `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外有用(遥测、请求路由)。
- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子会话都是 spawn全新。键控按会话路由而非按后端路由因此对 fork 也已正确。但脚本*推导*并非如此fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,从整份日志推导脚本会把父会话的响应当作子会话的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。
- 进程外ACPsubagent 是完全不同的回放形态(每个子 agent 是独立进程、有自己的 replay),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。
- `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent transcript 现在是快照的一等形态。
- `GenerateOptions.sessionId` 是一个小而诚实的 core-seam 新增,在回放之外同样有用(遥测、请求路由)。
- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本**派生**逻辑此前不正确fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见 [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见 [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md))。
- 进程外ACPsubagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 PR3 计划中。

View File

@@ -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: 8505e82fb681975c7506102a3eb858a29ccc11c8
2026-07-04-hook-snapshot-matrix.zh.md: 6bd4b65b6ba2e16f8433fb0e67ae7ca4eb6a470e
2026-07-04-hook-snapshot-matrix.zh.md: 9a9f085400e938bc15c171f652d65f7bcdfa518f

View File

@@ -1,4 +1,4 @@
# RFC钩子快照矩阵——覆盖两种桥接的端到端金标测试
# RFCHook 快照矩阵——覆盖两种 bridge 的端到端 golden 测试
Status: implemented
@@ -6,45 +6,45 @@ Status: implemented
## 问题
钩子桥接——[`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude)7 个 Claude Code 钩子点) [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex)5 个 Codex 钩子点)——将外部钩子命令映射到 harness 的拦截 seam 上。它们拥有深度的单元测试与覆盖率规格覆盖(每个决策分支、每种 payload 方言,均对 mock seam 驱动),外加一个需要密钥的 e2e 测试(`hooks.e2e.ts`,一次真实的 `PreToolUse` 拦截)。但 transcript文本记录快照层——那张真正启动 `acp-agent` 子进程、无密钥回放录制会话、并将归一化的 ACP stdout 与重新持久化的日志对比已提交金标的网——只覆盖了**一个**钩子Claude 的 `UserPromptSubmit` 拦截(`hook-cc-promptsubmit-block`)。
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 上。它们拥有深度的单元测试和 coverage-spec 覆盖率(每个决策分支、每种 payload 方言,均对 mock seam 驱动),外加一个需要密钥的 e2e 测试(`hooks.e2e.ts`,一次真实的 `PreToolUse` 拦截)。但完整 transcript文本记录快照层那张真正启动 `acp-agent` 子进程、无密钥回放录制会话、并将规范化的 ACP stdout 与重新持久化的日志已提交 golden 做 diff 的网,只覆盖了**一个** hookClaude 的 `UserPromptSubmit` 拦截(`hook-cc-promptsubmit-block`)。
这正是 mock 单元测试在结构上无法替代的层级:它让真实的桥接翻译真实钩子进程的结果,送入真实 seam 决策,再真实 agent loop智能体循环做出反应,渲染结果与编辑器所见完全一致。一个桥接翻译或循环结构的回归,即使让所有单元测试保持绿色,也会在除那一个钩子点之外的所有点上逃逸——而对于 Codex 桥接ACP 示例甚至没有加载它,因此没有任何 Codex 钩子能端到端触发。
这正是 mock 单元测试在结构上无法替代的层级:它验证的是真实 bridge 将真实 hook 进程的结果翻译到真实 seam 决策,再真实 agent loop智能体循环反应,渲染结果与编辑器看到的完全一致。一个 bridge 翻译或 loop 结构的回归,即使让所有单元测试保持绿色,也会在除那一个 hook 点之外的所有点上逃逸而对于 Codex bridgeACP 示例甚至没有加载它,因此没有任何 Codex hook 能端到端触发。
## 决策
实现由两个耦合部分组成:
### 1. ACP 示例同时加载两种钩子桥接
### 1. ACP 示例同时加载两种 hook bridge
`examples/acp-agent/cordis.yml` `cordis.snapshot.yml` 现在 `dsh-hooks-claude` 之外同时加载 `dsh-hooks-codex`各自指向自己的配置文件Claude 用 `./hooks.json`Codex 用 `./codex-hooks.json`——两种方言无法共用一个文件)。这是一个真正的产品表面变更,而非仅测试的接线:交付的 ACP 服务器(以及 `demo:acp` 入口)现在同时携带两种桥接
`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
这是安全的,因为配置文件不存在时桥接是**静默操作**`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不挂载 stdout logger因此警告不会到达 ACP JSON-RPC 通道。只需要 Claude 钩子的场景(或真实项目)只提供 `hooks.json`Codex 桥接找不到 `codex-hooks.json` 便自消失。这已通过实验验证:两种桥接同时加载时,所有既有快照(均未提供 `codex-hooks.json`)逐字节一致。
这是安全的,因为配置文件不存在时 bridge 是**静默操作**`apply()` 捕获读取失败、通过 `ctx.logger` 记录日志、不注册任何东西——零监听器、零会话事件。`acp-agent` 应用不附带 stdout logger因此警告不会到达 ACP JSON-RPC 通道。只需要 Claude hook 的场景(或真实项目)只提供 `hooks.json`Codex bridge 找不到 `codex-hooks.json` 便自消失。这已通过实验验证:两种 bridge 同时加载的情况下,所有既有快照(均不附带 `codex-hooks.json`)逐字节一致。
同时加载是让快照层能够在产品交付的同一个真实应用上每种方言进行测试的最低要求。录制(启动 `cordis.yml`)天然加载两者,回放以同样方式继承:`cordis.snapshot.yml``cordis.yml` 的 include-overlay替换 llm 条目(见 [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)),因此添加到运行时配置树的桥接无需第二次编辑即出现在回放树中。
同时加载是让快照层能够在产品交付的同一个真实应用上验证每种方言的最低要求。录制(启动 `cordis.yml`)天然加载两者,回放以同样方式继承:`cordis.snapshot.yml``cordis.yml` 的 include-overlay替换 llm 入口(见[单一来源 acp-agent 回放配置](2026-07-04-single-source-acp-replay-config.md)),因此添加到运行时树的 bridge 无需第二次编辑即出现在回放树中。
### 2. 每个钩子×标志性结果各一个快照场景,覆盖两种方言
### 2. 每个 hook ×主要结果各一个快照场景,覆盖两种方言
`examples/acp-agent/tests/snapshots/` 下共 13 个场景,命名为 `hook-<dialect>-<point>-<outcome>`
- **手工编写、无模型轮次**(无密钥、无 sidecar——派生的回放脚本为空比对的是携带 `hook/*` 事件的 `rejected` 轮次):`hook-cc-promptsubmit-block``hook-codex-promptsubmit-block`
- **对真实 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`block 并附反馈)、`hook-{cc,codex}-posttool-context`accept + additionalContext`hook-{cc,codex}-stop-continue`(阻塞 Stop 钩子通过 steering中途引导强制多走一步
- **对真实 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中途引导强制多走一步
每个钩子命令只输出**固定字面字符串**(无时间戳/pid/`$RANDOM`/cwd 回显);快照归一化器擦除 `hook/result` 携带的唯一易变字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是桥接的一个 `TODO`,因此无条件的 Stop 钩子会对每一步都 force-continue。
每个 hook 命令只输出**固定字面字符串**(无时间戳/pid/`$RANDOM`/cwd 回显);快照规范化器擦除 `hook/result` 携带的唯一不稳定字段(`durationMs`)。`Stop` 场景通过标记文件(`.stop_fired`)自限,使 force-continue 不会循环——`stop_hook_active` 循环守卫仍是 bridge 的一个 `TODO`,因此无条件的 Stop hook 会在每一步都 force-continue。
### 三个钩子点被有意排除在快照之外
### 三个 hook 点被有意排除在快照之外
在构建矩阵过程中发现,记录此是因为这是一个决策而非疏
在构建矩阵过程中发现,记录此是因为这些遗漏是决策而非疏
- **`SessionStart``SubagentStart`** 通过一个分离的、尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,**没有轮次绑定**。产生的 `context/message` 与它所先于的工作(首次模型请求/子 agent 的首轮)存在竞争,落在日志中的位置不确定。录制的金标甚至在自身回放时都无法复现——10 次回放稳定性检查对两者均 10/10 失败。它们留在桥接的单元覆盖中,单元测试直接驱动 seam 而无时序竞争。(如果注入将来变为轮次绑定且确定性的——`TODO(session-start-gating)` 所指的方向——这些点就可以纳入快照。)
- **`SubagentStop`** 是纯观察:其 `subagent/end` 处理器不传递轮次(因此无 `hook/*` 日志事件)、不做注入。它对 transcript **什么都不写**,因此金标会与无钩子运行逐字节一致,永远无法被证明失败——一道咬不到人的守卫。它留在单元覆盖中(`bridge.spec.ts` 已断言纯观察调用)。
- **`SessionStart``SubagentStart`** 通过一个分离的、尽力而为的 `void runPoint(...).then(agent.inject())` 注入上下文,**没有**轮次绑定。由此产生的 `context/message` 与它所先于的工作(首次模型请求/子 agent 的首轮)存在竞争,落在日志中的位置不确定。录制的 golden 甚至无法在自身回放复现——10 次回放稳定性检查对两者均 10/10 失败。它们留在 bridge 的单元覆盖中,单元测试直接驱动 seam 而无时序竞争。(如果注入将来变为轮次绑定且确定性的——`TODO(session-start-gating)` 所指的方向——它们就可以纳入快照。)
- **`SubagentStop`** 是纯观察性的:其 `subagent/end` 处理器不传递轮次(因此无 `hook/*` 日志事件)、不做注入。它对 transcript **不写入任何内容**,因此 golden 与无 hook 运行逐字节一致,永远无法被证明失败——一道永远不会触发的守卫。它留在单元覆盖中(`bridge.spec.ts` 已断言纯观察调用)。
因此该矩阵覆盖了所有具有**确定性、可观测 transcript 足迹**的钩子点,涵盖两种方言。
因此该矩阵覆盖了所有具有**确定性、可观测** transcript 足迹的 hook 点,涵盖两种方言。
## 后果
- 每个具有可观测 transcript 的桥接 seam 映射现在都在 transcript 层、在真实应用中、两种方言设有守卫——包括此前完全没有端到端覆盖的 Codex 桥接。录制的金标捕获了模型对 denied/blocked/force-continued 轮次的真实反应,这是手工编写的 transcript 只能猜测的。
- block 场景无需密钥(无模型轮次);其余场景从录制的 fixture测试前置数据无密钥回放。`pnpm run test:snapshot:record` 从真实 API 重新生成录制的 fixture无密钥时所有录制场景一样自动跳过
- prove-red 纪律成立:篡改钩子配置的输出(例如修改 deny 原因)会使其场景在回放时变红——钩子进程在回放期间**真实运行**(只有模型被回放),因此金标守卫的是实际的 hook→seam→loop 路径,而非它的 mock。
- `acp-agent` 演示现在加载了一个通常会操作的 Codex 桥接(典型项目中没有 `codex-hooks.json`),这正是预期的 fail-soft 行为,而非代价。
- 每个具有可观测 transcript 的 bridge seam 映射现在都在完整 transcript 层、在真实应用中、两种方言受到守护——包括此前完全没有端到端覆盖的 Codex bridge。录制的 golden 捕获了模型对 deny/block/force-continue 轮次的真实反应,这是手工编写的 transcript 只能猜测的。
- block 场景无需密钥(无模型轮次);其余场景从录制的 fixture测试前置数据无密钥回放。`pnpm run test:snapshot:record` 从真实 API 重新生成录制的 fixture无密钥时自动跳过,与所有录制场景一
- prove-red 纪律成立:篡改 hook 配置的输出(例如修改 deny 原因)会使其场景在回放时变红——hook 进程在回放期间**真实运行**(只有模型被回放),因此 golden 守护的是实际的 hook→seam→loop 路径,而非它的 mock。
- `acp-agent` 演示现在加载了一个通常会操作的 Codex bridge(典型项目中没有 `codex-hooks.json`),这正是预期的柔性失败行为,而非代价。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->

View File

@@ -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-single-source-acp-replay-config.md: 922bdcced50f8e289449e05b51774f202228b0f8
2026-07-04-single-source-acp-replay-config.zh.md: d27ea0d3b7227f0fb5f349478591962dd5fe8030
2026-07-04-single-source-acp-replay-config.zh.md: b347186f362fa5454f7bd5106c2e261cb8a00b61

View File

@@ -1,27 +1,27 @@
# RFC将 acp-agent 回放配置收归单一来源
Status: implemented
# RFC将 acp-agent 回放配置改为单一来源
[English](2026-07-04-single-source-acp-replay-config.md) | 中文
Status: implemented
## 问题
`examples/acp-agent` 曾维护两份手写配置:`cordis.yml`线上树)和一份 `cordis.snapshot.yml`,后者逐条镜像前者仅替换 LLM 后端。去掉注释后,全部差异只是八行 `llm-deepseek` 段落换成两行 `llm-replay` 段落。每次应用形态变更都要改两遍,且没有门禁保对称性:如果两份副本漂移,快照层会静默地测试一个与实际交付不同的应用——正是快照层本要消除的[单元全绿、产品却坏类缺口](../../../postmortem/0001-acp-default-export-drops-inject.md),在更高一层被重新引入,唯一的防线是评审者的警觉。
`examples/acp-agent` 曾维护两份手写配置:`cordis.yml`正式运行树)和 `cordis.snapshot.yml`逐条镜像前者仅替换 LLM(大语言模型)后端。去掉注释后,全部差异只是八行 `llm-deepseek` 段落换成两行 `llm-replay` 段落。每次应用结构变更都要改两遍,且没有门禁保对称性:一旦两份副本漂移,快照层就会悄悄测试一个与实际交付不同的应用——正是快照层本要消除的["单元测试全绿、产品却坏了"这类缺口](../../../postmortem/0001-acp-default-export-drops-inject.md),在一层被重新引入,唯一的防线是评审者的警觉。
## 决策
`cordis.snapshot.yml` include 线上配置, id 和 name 禁用指定的 DeepSeek 适配器,并插入回放适配器。因此除此之外的所有条目均来自交付树。回放时选择 overlay录制仍然启动 `cordis.yml`,加载守卫允许被有意禁用的条目。
`cordis.snapshot.yml` include 正式配置,通过 id 和 name 禁用指定的 DeepSeek 适配器,并插入回放适配器。其余所有条目因此来自正式运行树。回放时选择 overlay录制仍然启动 `cordis.yml`,加载守卫允许被有意禁用的条目。
overlay 依赖一个 vendor 插件事实有意为之include 在加载文件时应用 `patches`——`refresh()`/`internal/update` 路径重读时不重新打补丁——这恰好满足一次性回放启动的需要(回放应用不加载 `hmr`,也没有东西在运行中改写配置)。快照套件即为证明:所有场景在 overlay 上原样通过,包括逐字节一致的 golden 文件。
overlay 依赖一个 vendor 插件事实,这是有意为之include 在加载文件时应用 `patches``refresh()`/`internal/update` 路径重读时不重新打补丁这恰好满足一次性回放启动的需要(回放应用不加载 `hmr`,也没有东西在运行中改写配置)。快照套件即为证明:所有场景在 overlay 上原样通过,包括逐字节一致的 golden 文件。
## 曾考虑的替代方案
### 为什么不选这些方案?
### 为何不采用这些替代方案?
保留完整的双份配置并加一道对称性校验门禁是记录在案的兜底方案——它能消除静默漂移这一类问题,但仍保留一份 125 行的近似副本,其全部内容只是一个条目的差异,且随应用每增加一个插件而增长。在 bin 侧做替换(解析配置、替换条目、删除文件)会把 YAML 手术放进发布产物,并回放差异移出视野overlay 方案让差异保持声明式、可读且紧邻基础配置——这正是双份配置的支持者真正想要的教学价值。
保留完整的双副本并加一道对称性校验门禁是记录在案的退路——它能消除静默漂移这一类问题,但仍保留一份 125 行的近乎复制品,其全部内容只是一个条目的差异,且随应用每增加一个插件而增长。在 bin 侧做替换(解析配置、替换条目、删除文件)会把 YAML 手术放进发布产物,并回放差异藏到视线之外overlay 让差异保持声明式、可读且紧邻基础配置——这正是双副本支持者真正看重的教学价值。
## 后果
-`cordis.yml` 添加插件无需第二次编辑即进入回放树;漂移类问题从结构上消,而非靠门禁拦截。
- overlay 依赖条目携带稳定的 `id:`。禁用补丁上的 `name` 断言防止误定位id 被复用时补丁跳过而非禁用错误的插件。id **重命名**会使补丁退化为跳过,其警告需要一个回放应用有意不具备的 logger——可观察的结果是一条无的无密钥 `llm-deepseek` 条目与 `llm-replay` 并存,回放输出仍然正确(`llm-replay` 拥有流的短路权);这属于留给评审发现的配置腐烂,而非错误的快照。顶层插入的条目若 id 与有条目冲突,通过 loader 的 id map 以 last-wins 解析——当前配置无冲突,新增补丁行才是引入冲突的位置
- 如果未来回放树需要第二处分歧(另一个后端被替换),只需多加一行补丁,而非再 fork 一份文件。
-`cordis.yml` 添加插件即自动进入回放树,无需第二次编辑;漂移这一类问题从结构上消,而非靠门禁拦截。
- overlay 依赖条目携带稳定的 `id:`。禁用补丁上的 `name` 断言防止误定位id 被复用时补丁跳过而非禁用错误的插件)。如果 id **重命名**补丁退化为跳过,其警告需要一个回放应用有意不具备的 logger——可观结果是一条无的无密钥 `llm-deepseek` 条目与 `llm-replay` 并存,回放输出仍然正确(`llm-replay` 拥有流的短路权);这属于配置腐烂,留给评审发现,不会产生错误的快照。顶层插入一个 id 与有条目冲突的新条目时loader 的 id map 以后者为准;当前配置无冲突,新增补丁行才是引入冲突的场所
- 如果未来回放树需要第二处差异(另一个后端被替换),只需多加一行补丁,而非再 fork 一份文件。

View File

@@ -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: 5ccaa23a268114c5ba37ec153f4960b47df13bfd
2026-07-06-pin-request-header-content-in-one-scenario.zh.md: f5ef5e056bb2c05d14ed71315f31c962237db72c
2026-07-06-pin-request-header-content-in-one-scenario.zh.md: 909968430dc3e648e09eeeedd47436c35b90b870

View File

@@ -1,35 +1,35 @@
# RFC在单快照场景中固定 request-header 内容
[English](2026-07-06-pin-request-header-content-in-one-scenario.md) | 中文
# RFC在单快照场景中固定请求头内容
Status: implemented
[English](2026-07-06-pin-request-header-content-in-one-scenario.md) | 中文
## 问题
ACPAgent Client Protocol快照测试套件需要证明每个 `request/header` 中实际发送的组合系统提示词工具 schema 列表,但如果在每个 `session.jsonl` 中重复这些内容,一次提示词或 schema 编辑就会改写数十条巨大的单行 JSON 记录。保留一份原始 header 可以避免重复,但提示词的评审体验仍然很差:行文被 JSON 转义到一行,与数千字符的工具 schema 混在一起。
一个 ACPAgent Client Protocol快照测试套件需要证明每个 `request/header` 中实际发送的组合系统提示词工具 schema 列表,但如果在每个 `session.jsonl` 中重复这些内容,一次提示词或 schema 编辑就会改写数十条巨大的单行 JSON 记录。保留一份原始 header 可以避免重复,但提示词的评审体验仍然很差:行文被 JSON 转义到一行,与数千字符的工具 schema 混在一起。
## 决策
每个 header 组合类别恰好有一个场景被标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.golden.md` 以普通 Markdown 存放归一化后的组合提示词,`tool-schemas.golden.json` 以结构化 JSON 存放完整的初始 schema 及后续 schema 变更,`session.jsonl` 保留 config、reason 任何模型可见的前缀,同时将 `header.system``header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其余所有 JSONL 使用相同的提示词和工具 token并同样对 session-prefix 内容做 token 化。固定机制实现在 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) 中,其套件工厂强制每个类别只有一个 pin
每个 header 组合类别恰好有一个场景被标记为 `pinsHeader`。其目录按评审格式拆分固定内容:`system-prompt.golden.md` 以普通 Markdown 存放归一化后的组合提示词,`tool-schemas.golden.json` 以结构化 JSON 存放完整的初始 schema 及后续 schema 变更,`session.jsonl` 保留 config、reason 任何模型可见的前缀,同时将 `header.system``header.tools` 存为 `"{{system}}"` / `"{{tools}}"`。其余所有 JSONL 使用相同的提示词和工具 token并同样对会话前缀内容做 token 化处理。固定机制实现在 [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) 中,其套件工厂强制每个类别只有一个固定场景
`scrubSystemPrompts``scrubToolSchemas` 归一化器应用于所有存储的 session fixture测试前置数据独立地对初始 header 内容和 header-delta 批量内容做 token 化。`scrubRequestHeaders` 还为非固定场景对 session-prefix 内容做 token 化同时保留结构性事实system-delta 的位置与数量、增删改的工具名称、前缀消息数量、字段存在性、config 和 reason。record refresh 的回写操作在写入 JSONL 前应用相应的 scrub并从归一化的实时 header 和 delta 重新生成两个 sidecar 文件,因此两条路径都不会提示词/schema 批量内容重新引入 JSONL也不会让评审产物变陈旧。
`scrubSystemPrompts``scrubToolSchemas` 归一化器应用于每个存储的会话 fixture测试前置数据独立地对初始 header 内容和 header-delta 批量内容做 token 化。`scrubRequestHeaders` 还为非固定场景的会话前缀内容做 token 化同时保留结构性事实system-delta 的位置与数量、新增/移除/变更的工具名称、前缀消息数量、字段存在性、config 和 reason。record refresh 的回写操作在写入 JSONL 前应用相应的 scrub并从归一化的实时 header 和 delta 重新生成两个 sidecar 文件,因此两条路径都不会提示词/schema 批量内容重新引入 JSONL也不会让评审产物变陈旧。
守卫使这一拆分自我强制。在磁盘上:每个 `session*.jsonl` 都是提示词和 schema 两个 scrubber 的不动点;只有非固定 fixture(测试前置数据)还必须是完整 header scrub 的不动点;两个 sidecar 恰好存在于固定 fixture 旁边,采用规范的换行终止格式;每个类别有且仅有一个 pin。在运行时:由 parent、spawn 子进程、fork 子进程、初始请求或恢复产生的每个 `request/header`,在易变值归一化后必须与重建的 pin 匹配;固定运行的提示词和 schema delta 也必须与其 sidecar 匹配。如果 header 缺少字符串类型的 prompt、缺少数组类型的工具列表,或出现未声明的 `request/header-delta`,则立即报错。
守卫机制使这一拆分自我强制。在磁盘上:每个 `session*.jsonl` 都是提示词和 schema 两个 scrubber 的不动点;只有非固定 fixture 还必须是完整 header scrub 的不动点;两个 sidecar 文件恰好存在于固定 fixture 旁边,采用规范的换行终止格式;每个类别有且仅有一个固定场景。在运行时:由 parent、spawn 子会话、fork 子会话、初始请求或 resume 产生的每个 `request/header`,在经过易变值归一化后必须与重建的固定内容匹配;固定运行的提示词和 schema delta 也必须与其 sidecar 匹配。如果 header 没有字符串类型的 prompt、没有数组类型的工具列表,或包含未声明的 `request/header-delta`,则立即失败并报错。
一个 pin 覆盖整个套件因为每个会话parent、spawn 子进程、fork 子进程)组合出的工具列表完全相同、提示词除 cwd 外完全相同,而一致性守卫会在这一前提不再成立时立即使套件失败。如果 header 组合在设计上变为会话相关的(例如受限的 subagent 工具集),则分化出的形状获得自己的固定场景。
一个固定场景覆盖整个套件因为每个会话parent、spawn 子会话、fork 子会话)组合出的工具列表完全相同、提示词除 cwd 外完全相同,而一致性守卫会在这一前提不再成立时立即使套件失败。如果 header 组合将来在设计上变为会话相关的(例如受限的 subagent 工具集),那么分歧的形态将获得自己的固定场景。
## 曾考虑的替代方案
- **每次变更重新录制或手动编辑所有 fixture**:保留了精确的 header但行为差异被重复的提示词和 schema 内容淹没。
- **仅在比较时 scrubfixture 保持原始状态**:比较能通过,但已提交的 fixture 保留着陈旧的重复内容,下次录制时整体写。存储 token 诚实地表明每个 JSONL 没有固定什么。
- **全部 scrub不做任何固定**:丢失了组合 header 实际发送内容(提示词组装、已注册工具顺序、完整 schema的唯一端到端记录。生成的工具目录只孤立地记录每个工具只有真实 fixture 能固定组合后的完整集合。
- **将完整的 pin 全部保留在 JSONL 中**:消除了套件重复,但提示词和 schema 变更仍然表现为一行转义文本。Markdown 和结构化 JSON 为各自的内容提供自然的评审格式,同时不削弱重建 header 的断言。
- **精简会话日志本身记录内容摘要header 存别处)**:违反可重建契约:产品日志必须逐比特重现每个请求([可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md)。header 体积是测试产物的问题,在测试归一化中解决;线上日志不受影响。
- **仅在比较时 scrubfixture 保持原始内容**:比较能通过,但已提交的 fixture 保留着陈旧的重复内容,下次录制时整体写。存储 token 诚实地表明每个 JSONL 没有固定什么。
- **全部 scrub不做任何固定**:丢失了组合 header 实际发送内容(提示词组装、已注册工具顺序、完整 schema的唯一端到端记录。生成的工具目录只孤立地记录每个工具只有真实 fixture 能固定组合后的完整集合。
- **将完整固定内容全部保留在 JSONL 中**:消除了套件范围的重复,但提示词和 schema 变更仍然一行转义文本。Markdown 和结构化 JSON 为每种内容提供自然的评审格式,同时不削弱重建 header 的断言。
- **精简会话日志本身(记录内容摘要,header 存放在别处)**:违反可重建契约:产品日志必须逐重现每个请求([可重建请求 RFC](../architecture/2026-07-05-reconstructable-requests.md)。header 体积是测试产物的问题,在测试归一化中解决;线上日志不受影响。
## 验证
套件针对拆分后的 pin 回放每个场景。单元覆盖率检验独立 scrubber 和完整 scrubber、两种 sidecar 格式、record/refresh 重新生成、归一化提示词/schema 提取、不动点强制、必需文件对称性、重建 header 一致性以及 delta 拒绝。
套件针对拆分后的固定内容回放每个场景。单元测试覆盖率涵盖独立 scrubber 和完整 scrubber、两种 sidecar 格式、record/refresh 重新生成、归一化提示词/schema 提取、不动点强制、必需文件对称性、重建 header 一致性以及 delta 拒绝。
## 后果
系统提示词变更在每个受影响的组合类别中产生一个面向行的 Markdown diff工具描述变更在每个类别中产生一个结构化 JSON diff普通行为 fixture 不受影响。session fixture 以 token 显示被省略的内容,运行时一致性守卫使每个拆分 pin 对其类别的所有会话具有权威性。每个固定场景携带两个生成的、换行规范化的 sidecar 文件。
系统提示词变更在每个受影响的组合类别中产生一个面向行的 Markdown diff工具描述变更在每个类别中产生一个结构化 JSON diff普通行为 fixture 不受影响。会话 fixture 对省略的内容显示 token,运行时一致性守卫使每个拆分固定场景对其类别的所有会话具有权威性。每个固定场景携带两个生成的、换行规范化的 sidecar 文件。

View File

@@ -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: 81714191a704af1a9ed029fb8004deac88e39427
2026-07-08-shared-acp-snapshot-package.zh.md: 2a7c3091c731ded61bed939c8ae0a323123daac9
2026-07-08-shared-acp-snapshot-package.zh.md: f80c8e49e80e9bf287c84c0ff4fb00377fad5175

View File

@@ -1,38 +1,38 @@
# RFC将 ACP 快照测试套件提取为支持包
Status: implemented
# RFC将 ACP 快照套件提取为支持包
[English](2026-07-08-shared-acp-snapshot-package.md) | 中文
Status: implemented
## 问题
ACP 快照层([快照 RFC](2026-06-19-acp-snapshot-tests.md))由三个位于某个示例测试目录内的模块构成:`snapshot-harness.ts`(启动真实 bin 子进程通过 ACP JSON-RPC 驱动它收集持久化日志)、`snapshot-normalize.ts`(纯粹的 golden 归一化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体 fixture测试前置数据守卫record/replay 模式、stdout-golden 与日志比对、pinned-header 一致性守卫、orphan/required-file/single-pin 元测试)。
ACP 快照层([快照 RFC](2026-06-19-acp-snapshot-tests.md))由位于某个示例测试目录中的三个模块构成:`snapshot-harness.ts`(启动真实 bin 子进程通过 ACP JSON-RPC 驱动它收集持久化日志)、`snapshot-normalize.ts`(纯粹的 golden 规范化器),以及 `acp.snapshot.ts` 中约 150 行的场景主体 fixture测试前置数据守卫record/replay 模式、stdout-golden 与日志比对、pinned-header 一致性守卫、orphan/required-file/single-pin 元测试)。
第二个 ACP 示例只能复制 record、归一化与收集逻辑,而这些逻辑必须保持一致。`examples/` 下的代码还处于package覆盖率门禁之外,且原 harness 只能取消权限请求。共享包使这些机制纳入度量,并允许场景脚本化地指定审批答案。
第二个 ACP 示例只能复制 record、规范化和收集逻辑,而这些逻辑必须保持一致。`examples/` 下的代码也不在package覆盖率门禁范围内,且原 harness 只能取消权限请求。共享包使这些机制纳入度量,并允许场景脚本化地提供审批答案。
## 决策
机制代码位于 [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md)`@deepseek-ai/dsh-acp-snapshot`);示例的 `*.snapshot.ts` 只包含场景表、agent 路径和一次工厂调用,配合自己的 `snapshots/` fixture 与 `cordis.snapshot.yml` 覆盖层[单源 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[单源 replay 配置](2026-07-04-single-source-acp-replay-config.md))。读取 `DSH_SNAPSHOT` 留在边缘层——库接收的是已解析的 `mode`
**`src/harness.ts`** 提供 `runScenario` 及其脚本/结果类型,以 agent 的 bin 路径和配置路径为参数。权限答案构成一个 FIFO 队列,稳定的 option kind而非随机的 option id索引。缺少答案时取消该请求;不可用的 kind 取消 agent 请求并使场景失败。
**`src/harness.ts`** 提供 `runScenario` 及其脚本/结果类型,以 agent 的 bin 和配置路径为参数。权限答案构成一个 FIFO 队列,稳定的 option kind而非随机的 option id为键。缺少答案时取消该请求;不可用的 kind 取消 agent 请求并使场景失败。
**`src/normalize.ts`**:纯归一化器,按策略不含钩子当未来事件携带新的易变字段(如审批耗时),共享归一化器在同一个变更中学会它,保持「归一化」的含义只有一个归属,而非各套件各自扩展清洗逻辑。
**`src/normalize.ts`** 是纯规范化器,按策略不含钩子当未来某个事件携带新的易变字段(如审批耗时),共享规范化器在同一个变更中学会它,保持「规范化」的含义只有一个归属,而非各套件各自扩展清洗逻辑。
**`src/suite.ts`**`Scenario` 类型与 `defineAcpSnapshotSuite(options)`注册逐场景比对、record/refresh 的 fixture 回写、header pin 及其实时一致性守卫,以及 fixture 守卫块(无 orphan 场景目录、必需文件齐全、每个 class 恰好一个 pin、每个 JSONL 是 `scrubSystemPrompts` 的不动点、非 pinning fixture 也是 `scrubRequestHeaders` 的不动点。pinned-header 契约([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件生效:每个 header class 恰好标记一个 `pinsHeader` 场景,其 `system-prompt.golden.md` 与 JSONL 工具列表将组合后的 header 拆分为可评审的产物;一致性守卫将二者与该 class 中每个实时 header 进行比对。纯辅助函数(`childFixturePaths``fixtureContext``normalizedHeaders``normalizedSystemPrompts``formatSystemPromptSnapshot``headerDeltaCount`)从模块导出,以便直接进行单元覆盖。
**`src/suite.ts`** 提供 `Scenario` 类型与 `defineAcpSnapshotSuite(options)`注册逐场景比对、record/refresh 的 fixture 回写、header pin 及其实时一致性守卫,以及 fixture 守卫块(无 orphan 场景目录、必需文件齐全、每个 class 恰好一个 pin、每个 JSONL 是 `scrubSystemPrompts` 的不动点、非 pinning fixture 也是 `scrubRequestHeaders` 的不动点。pinned-header 契约([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md))按套件划分:每个 header class 恰好标记一个 `pinsHeader` 场景,其 `system-prompt.golden.md` 与 JSONL 工具列表将组合后的 header 拆分为可评审的产物;一致性守卫将二者与该 class 中每个实时 header 进行比对。纯辅助函数(`childFixturePaths``fixtureContext``normalizedHeaders``normalizedSystemPrompts``formatSystemPromptSnapshot``headerDeltaCount`)从模块导出,以便直接进行单元覆盖。
## 曾考虑的替代方案
- **将模块复制到每个示例中**:正是本 RFC 要止的分叉。record/guard 逻辑恰恰是必须在各套件间逐字节一致的代码,而 examples 在覆盖率门禁之外,因此每份副本也无法被度量。
- **在 `examples/` 下建共享模块目录**:代码仍在覆盖率门禁之外,且需要跨示例边界的相对导入,违包名导入约定;`examples/` 的叶子节点按设计保持精简
- **`dsh-acp-demo` 中导出 `/testing` 子路径**:将测试基础设施耦合到产品包的公开接口与依赖集中;`packages/support/` 正是为真实但兼容性要求较低的开发/测试包而设`dsh-llm-replay` 是先例,本包是其补全
- **导出原始测试体函数而非套件工厂**:每个示例将重新拥有 `describe`/`it` 骨架(每套件约 80 行注册样板),却无灵活性收益;工厂消费方只需一张场景表加一次调用,导出的纯辅助函数在工厂设计内保留了单元测性。
- **可注入的 ACP `Client` 工厂取代声明式 `permissionAnswers`**:灵活性最大,但将 SDK 客户端构造泄给每个消费方,并在正被统一的层面重新引入逐示例漂移;声明式队列 `input.json` 保持为唯一的脚本化接口,且可被 golden 归一化。
- **泛化到 ACP 之外(传输无关的快照 harness**不存在第二种传输harness 端到端都是 ACP 形态SDK 客户端、JSON-RPC 帧、`session/update` 等待器),推测性的抽象会在没有消费方之前就拆出一个 seam。
- **将模块复制到每个示例中**:正是本 RFC 要止的 fork。record/守卫逻辑恰恰是必须在各套件间保持逐字节一致的代码,而示例不在覆盖率门禁范围内,因此每份副本也无法被度量。
- **在 `examples/` 下建共享模块目录**:代码仍在覆盖率门禁之外,且需要跨示例边界的相对导入,违包名导入约定;`examples/` 的叶子节点按设计保持轻薄
- **`dsh-acp-demo` `/testing` 子路径导出**:将测试基础设施耦合到产品包的对外服务接口与依赖集中;`packages/support/` 的存在正是为真实但兼容性承诺较低的开发/测试包,`dsh-llm-replay` 是先例,本包与之配套
- **导出原始测试体函数而非套件工厂**:每个示例将重新拥有 `describe`/`it` 骨架(每套件约 80 行注册样板),却无灵活性收益;工厂使消费方只需一张场景表加一次调用,导出的纯辅助函数在工厂设计内保留了单元测性。
- **可注入的 ACP `Client` 工厂,而非声明式 `permissionAnswers`**:灵活性最大,但将 SDK 客户端构造泄给每个消费方,并在正被统一的层面重新引入逐示例漂移;声明式队列使 `input.json` 为唯一的脚本化界面,且可被 golden 规范化。
- **泛化到 ACP 之外(传输无关的快照 harness**:不存在第二种传输方式harness 端到端都是 ACP 形态SDK 客户端、JSON-RPC 帧、`session/update` 等待器),推测性的抽象将是一个超前于任何消费方的 seam 拆分
## 测试
提取保留了所有既有 ACP golden 的每一个字节。包的 `src/` 通过脚本化的 ACP 子进程实现逐文件 100% 覆盖harness 测试覆盖每个步骤操作、两预期错误分支、权限选择/回退/不可能选项、环境变量转发、工作区种子注入收集排序/噪声/回退suite 测试对已提交的合成 fixture 执行 replay并对临时副本执行 record加上纯辅助函数的测试。两个结构上不可达的守卫保留了有理由的覆盖率排除。fake agent 将 `session/new` 的 cwd 替换日志,包括 Darwin 的 `/var` realpath 行为,与真实 bin 一致。
提取保留了所有既有 ACP golden 字节。包的 `src/` 通过脚本化的 ACP 子进程达到逐文件 100% 覆盖harness 测试覆盖每个步骤操作、两预期错误分支、权限选择/回退/不可能选项、环境变量转发、workspace 种子注入收集排序/噪声/回退suite 测试对已提交的合成 fixture 执行 replay并对临时副本执行 record同时覆盖纯辅助函数。两个结构上不可达的守卫保留了有理由的覆盖率排除。fake agent 将 `session/new` 的 cwd 替换日志,包括 Darwin 的 `/var` realpath 行为,与真实 bin 一致。
## 后果
新示例只需一张场景表加 fixture 即可获得完整快照层——sandbox 分支从 master 合后添加自己的套件(自己的 pin 场景、自己的覆盖层、通过 `test:snapshot:record` 生成 fixture、通过 `permissionAnswers` 指定审批答案)。代价:`suite.ts` 导入 vitest因此该包只能在 vitest 运行中导入——这是其他包没有的形态,已在其 README 中声明;每个套件 pin 自己约 8 KB header fixture真正不同的组合理应有自己的 pin相同的组合会被该套件的一致性守卫捕获e2e 启动器的重复仍然存在(`TODO(acp-test-harness)`——当该迁移落地时harness 提取目标。
新示例只需一张场景表加 fixture 即可获得完整快照层——sandbox 分支从 master 合后添加自己的套件(自己的 pin 场景、自己的 overlay、通过 `test:snapshot:record` 生成 fixture、通过 `permissionAnswers` 提供审批答案)。代价:`suite.ts` 导入 vitest因此该包只能在 vitest 运行中导入——这是其他包没有的形态,已在其 README 中声明;每个套件 pin 自己约 8 KB header fixture真正不同的组合值得拥有自己的 pin相同的组合会被该套件的一致性守卫捕获e2e launcher 的重复仍然存在(`TODO(acp-test-harness)`——当该迁移落地时harness 即为提取目标。