DeepSeek Harness 快照测试 Fixture 精简:以session.jsonl作为唯一快照会话日志产物
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
本文讲解 DeepSeek Harness(dsh)ACP 快照测试体系中一次关键的测试基建精简决策:移除
session.expected.jsonl这一冗余产物,让每个快照场景只保留一个会话日志 artifact ——session.jsonl,使其同时充当 replay 数据源与期望输出。读完本文,你将理解三类快照场景(录制型、作者化 override 型、无模型型)下session.jsonl的不同语义、replay.override.json的三种ReplayEntry写法,以及"各侧独立上下文归一化"这一保证比较稳定性的核心机制,并掌握如何用源码与真实 fixture 验证这套约定。
背景:一个场景为何会同时携带两份会话日志
在 DeepSeek Harness 的快照测试体系中,每个场景目录提交一组 fixture,其中会话日志是核心部分。本次决策之前,模型驱动的 ACP 快照场景同时提交两份日志:
session.jsonl—— 对普通录制型场景而言,它是从真实运行中采集(harvest)的 replay fixture,即模型流的原始档案;session.expected.jsonl—— replay 测试把新持久化的日志归一化后,与这份"期望日志"进行比较。
问题在于:对于普通录制型场景,两个文件经过归一化之后字节级完全一致。也就是说,session.expected.jsonl是一个纯粹的冗余副本,白白增加了一次评审、维护与匹配的成本。
而作者化(authored)override 场景(如error-finish、cancel)当时采用的是另一套更复杂的布局:用replay.override.json驱动模型行为,session.jsonl退化为一份最小的占位(dummy)fixture,真正的期望持久化日志放在session.expected.jsonl里。实际上这份拆分同样没有必要——当 override sidecar 存在时,llm-replay会用 override 替换由session.jsonl推导出的模型脚本,根本不需要从session.jsonl里读取模型 chunk。因此session.jsonl完全可以同时扮演"期望会话日志"的角色。
决策:每个场景至多提交一份会话日志
决策很直接:彻底移除session.expected.jsonl概念。从此以后,每个场景至多提交一个会话日志 artifactsession.jsonl,其语义按场景类型划分:
| 场景类型 | session.jsonl的角色 | 模型行为来源 | 比较方式 |
|---|---|---|---|
| 录制型(recorded) | 原始采集日志(raw harvested log) | replay 从session.jsonl中的assistant/chunk事件推导模型脚本 | replay 运行归一化后的持久化日志 与 归一化后的session.jsonl比较 |
| 作者化 override 型(authored) | 期望产生的会话日志(expected produced log) | replay.override.json驱动模型行为;存在 override 时 replay 适配器忽略 fixture 中的模型 chunk | 同上,replay 归一化结果与session.jsonl比较 |
| 无模型型(no-model) | 启动llm-replay所需的最小 fixture | 无(不调用模型) | 除非场景创建了持久化会话,否则无需会话日志比较 |
stdout 期望输出保持不变。决策原文明确说明:stdout 期望输出是面向编辑器(editor-facing)的投影,与会话 fixture 并不冗余,两者互补——stdout 覆盖自动化最小传输线(ACP JSON-RPC 帧),JSONL 覆盖循环、工具与边界结构。
源码级验证一:fixture 命名约束与孤儿守卫
sessionFixtureNames()是这套约定在代码中的第一道硬约束,位于 packages/test-support/session-snapshot/src/suite.ts:
- 主 fixture 必须叫
session.jsonl(缺失直接报missing session.jsonl); - 子代理(subagent)会话采用连续编号
session.1.jsonl、session.2.jsonl…,编号不连续或形如session.xxx.jsonl的其他后缀会 fail loud; - 一个目录是事实来源(source of truth),场景表无需重复声明子会话数量,避免两者漂移。
session.expected.jsonl这个名字在快照 harness、fixtures、孤儿守卫与文档中已无任何踪迹,可在仓库中全局搜索验证(命中结果仅剩个别 e2e 测试脚本文件名层面的.expected命名,属于另一层级的产物命名,不参与快照 fixture 体系)。
配合这套命名的是两类守卫测试(同样位于 suite.ts):
- no-orphans 检查:
snapshots/<dir>下每个目录必须出现在场景表中,重命名/删除场景后遗留的过期目录会直接报错; - 必需文件检查:每个场景必须存在
input.json、stdout.expected.jsonl、session.jsonl,且replay.override.json的存在与否必须与场景声明的overridden标志精确匹配——因为 harness 仅凭文件存在性就转发 override(见 harness.ts 中DSH_SNAPSHOT_OVERRIDE环境变量的注入),一个未注册的游离 sidecar 会悄悄改变推导出的脚本,所以守卫必须双向 fail loud。
源码级验证二:同一份session.jsonl身兼二职
在 suite.ts 中可以看到,replay 模式把session.jsonl作为fixtureFile传给 harness(replay 从中推导模型脚本),同时在同一测试末尾(L1378-L1393)把它作为期望输出进行比较:
const harvested = result.sessionLogs.map(log => log.content) const fixtures = await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) const fixtureContexts = fixtures.map(fixtureContext) const fixtureCtx: NormalizeContext = { sessionIds: fixtureContexts.flatMap(context => context.sessionIds), cwd: (fixtureContexts[0] as NormalizeContext).cwd, } const actualSnapshots = normalizeSessionSnapshots(harvested, ctx) const expectedSnapshots = normalizeSessionSnapshots(fixtures, fixtureCtx) for (const [index, actual] of actualSnapshots.entries()) { expect(actual, `${fixtureFiles[index]} mismatch`).toEqual(expectedSnapshots[index]) }注意这里的比较用toEqual(纯值相等)而非toMatchFileSnapshot——这正是实现说明里"session logs use plain equality rather than file-snapshot updates, so comparison never rewrites fixtures"的落点:stdout 期望输出用toMatchFileSnapshot(vitest 在--update下可重写),而会话日志的等值比较永远不会改写 fixture,从而保证评审时可追溯、CI 无副作用。
核心机制:各侧独立上下文归一化与幂等性
既然 replay 运行与录制运行在会话 id、cwd、时间戳上必然不同,比较前必须归一化。关键设计是每一侧都用自己的 header 推导归一化上下文:
- 运行侧(actual)使用本次运行真实产生的
sessionId、cwd与cwdAliases(见 harness.ts 的ctx构造); - fixture 侧(expected)使用
fixtureContext(fixture),它从session.jsonl的第一行 header 读取id与cwd构造上下文(suite.ts)。
export function fixtureContext(fixture: string): NormalizeContext { const firstLine = fixture.split('\n').find(line => line.trim().length > 0) ?? '{}' const header = JSON.parse(firstLine) as { id?: unknown; cwd?: unknown } return { sessionIds: typeof header.id === 'string' ? [header.id] : [], cwd: typeof header.cwd === 'string' ? header.cwd : '\0no-cwd\0', } }这个设计有两个重要推论:
- 幂等性:
session.jsonl中的值已经是归一化后的 token(如{{session:1}}、{{cwd}}),再次对已归一化的 fixture 执行归一化不会产生变化——所以用 fixture 自身的 header 推导上下文是安全的; - 为什么要独立上下文而非共享上下文:这就是"备选方案"被拒绝的根本原因。
normalizeSessionLog对 cwd 的擦除采用精确字符串匹配(normalize.ts 的replaceCwdSpelling用indexOf逐段匹配)。如果两侧共用一个 replay 运行上下文,fixture 中记录的旧 cwd 会因为"与当前值不同"而幸存于擦除,导致每次比较必然失败。因此只能各侧使用自己 header 推导的上下文。
归一化器本身负责擦除会话 id、运行 cwd、RPC id、时间戳、goal 生命周期时钟与 hook 时长,同时保留语义负载值(normalize.ts 的模块说明)。此外还包含针对 macOS/private/tmp软链的cwdAliases处理、快照 spill 路径 token 化({{spillLocator:name}})等平台细节。
replay.override.json的三种 ReplayEntry
对于assistant/chunk无法表达的模型行为(抛错、挂起),需要手写replay.override.json。其元素类型定义在 packages/test-support/llm-replay/src/index.ts:
export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; accepted?: boolean } | { kind: 'hang'; readyFile?: string }chunks:普通成功流。录制型场景由deriveReplayScript()从assistant/chunk事件自动推导(按终态finishchunk 切分每次调用),一般无需手写;throw:模拟流中/流前失败。chunks可携带前缀 chunk 模拟中途失败(mid-stream failure),message/code描述错误,accepted控制是否被重试策略接受。例如error-finish场景(snapshots/session/error-finish/replay.override.json):
[ { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH" } ]hang:模拟永不返回的挂起流,用于取消(cancel)场景。readyFile为可选标记,在前缀 chunk 消费完后、流进入等待取消之前写入,供 harness 的waitForFile步骤感知就绪。例如cancel场景(snapshots/acp/cancel/replay.override.json):
[ { "kind": "hang", "readyFile": ".dsh-snapshot-stream-ready" } ]加载逻辑见loadReplayScript()(llm-replay/src/index.ts):当overrideFile存在(或环境变量DSH_SNAPSHOT_OVERRIDE指向它)时,sidecar 文档整体替换推导脚本;sidecar 还支持{ patches: [...] }增补形式,按调用索引替换/追加单个 entry。override 优先是单向的——覆盖后session.jsonl中的模型 chunk 不再参与模型驱动,所以同一份session.jsonl可以毫无歧义地作为期望日志存在。
真实 fixture 解剖:cancel 场景
snapshots/acp/cancel/目录是本次决策后"作者化 override 型"场景的标准形态(snapshot.yml):
version: 1 scenario: cancel profile: acp composition: acp-default recording: authored # 绝不参与 live 重录 header: class: acp-default replay: override: true # 声明必须存在 replay.override.json其 session.jsonl 完整记录了被取消回合的期望持久化形态:session头部(id/cwd 均为 token)、permission/preset、sandbox/mode、agent/inbox/spliced用户消息、turn/start、step/start、系统提示注入、request/header(system/tools 均为 token)、流中被打断的assistant/chunk与assistant/message("interrupted":true),最终以turn/end+reason.kind: aborted收束。这恰好演示了"同一文件既是被 replay 忽略模型 chunk 的 fixture,又是被逐事件比较的期望日志"。
对照地,普通录制型场景(如 snapshots/acp/escalation-rejected)目录中没有replay.override.json,session.jsonl保持原始采集日志形态,模型 chunk 由deriveReplayScript消费并重放。
备选方案回顾与取舍
决策记录中明确评估过一个替代设计:
将两侧都按共享(replay 运行)上下文归一化—— 被拒绝。
拒绝理由正是上一节提到的精确字符串匹配问题:normalizeSessionLog对 cwd 的擦除是 exact string match,共享上下文下 fixture 里记录的 cwd 无法被擦除,每次比较都会失败。相比之下,"各侧用自己的 header 推导上下文"不仅让已归一化的 fixture 幂等,也让录制与重放各自的易变值(ids、paths、timestamps)天然对号入座。这一取舍体现了快照测试的一条通用原则:归一化上下文必须与数据的产生方绑定,而不是与比较的发起方绑定。
影响与展望
移除session.expected.jsonl后,评审者失去的只是"一个把期望日志与 replay fixture 视觉分开的文件名";得到的是一套更简单的规则——一个场景、一个会话日志、双重职责。回归保护并未削弱:
- stdout 期望输出继续守护编辑器会话记录(editor transcript);
- replay 输出与
session.jsonl的比较完整保留了循环(agent loop)与持久化(persistence)回归检查,只是不再复制文件。
对于想要在 DeepSeek Harness 中新增快照场景的开发者,本决策后的 fixture 清单是确定性的:input.json+stdout.expected.jsonl+session.jsonl(+ 视情况replay.override.json、workspace/、snapshot.yml),所有文件必须与场景表声明一一对应,否则守卫测试会在收集期直接失败。完整的录制/重放机制背景可进一步参阅 ACP 快照测试 Agent Note 与 harness.ts 的实现注释。
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考