Mock LLM 和 Snapshot 测试
你写agent测试就是mock LLM返回固定字符串,然后断言函数返回值。错。ACP Snapshot Harness启动真实agent bin子进程,22种预设LLM故障行为,normalizers去除非确定性内容。
给 agent 写测试时,mock LLM client 返回固定 response,再断言结果字符串包含预期内容,只能覆盖很薄的一层。
这种测试验证的是“当 LLM 返回 X 时,函数返回 Y”,但不会碰到真实子进程启动、stdio 传输、JSON-RPC 序列化、Turn 调度、事件持久化、stdout 渲染——而真正容易出 bug 的地方,往往就在这些边界上。
Harness 的测试哲学是:能跑真实进程就不 mock 内部函数。ACP Snapshot Harness 启动真实的 agent bin 子进程,通过 ACP JSON-RPC over stdio 驱动它,对比真实 stdout 和 session log 的 snapshot。这样测出来的才是集成路径。
ACP Snapshot Harness:启动真实子进程
ACP Snapshot Harness不是在进程内mock对象——它spawn真实的agent二进制子进程,通过stdio与子进程进行ACP(Agent Communication Protocol)JSON-RPC通信。这意味着:
- Cordis Loader真正地组装所有模块
- Session真正地创建并持久化到临时目录
- 事件真正地append到JSONL
- stdout真正地渲染终端输出
- 子进程真正地退出、清理资源
它有两种模式:
Record模式: 用真实LLM API(或llm-mock-server)运行测试,收获三份snapshot:stdout.expected.jsonl、session.jsonl、以及任何pin的内容(如system-prompt.expected.md)。
Replay模式: 用llm-replay适配器重放记录好的LLM响应,不调用真实API,对比实际输出和snapshot是否一致。如果不一致,测试失败。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
ls "$repo/packages/test-support/acp-snapshot/src/"
你会看到harness.ts、launcher.ts、suite.ts这些文件——harness负责进程管理和协议驱动,suite负责snapshot对比。
InputStep:可脚本化的交互步骤
你不需要写代码模拟用户输入——InputStep是一个声明式脚本,支持多种步骤类型:
- `initialize:初始化ACP连接
- `newSession:创建新会话
- `prompt:发送一条用户消息
- `promptAndCancel:发送消息然后立即取消(测试中断路径)
- `waitForTurnStart:等待Turn开始
- `waitForTurnEnd:等待Turn结束
- `waitForFile:等待某个文件出现在workspace
- `promptAndWaitForAgentMessage:发送消息并等待Agent回复完成
脚本支持{{sessionId}}变量替换,在步骤间传递会话ID。Permission answers也可以队列化脚本化——你预先定义好哪些tool调用approve/deny,不需要测试时人工交互。
flowchart TD
A[Test Suite] --> B{模式?}
B -->|Record| C[启动真实agent + LLM Mock/真实API]
B -->|Replay| D[启动真实agent + llm-replay适配器]
C --> E[执行InputStep脚本]
D --> E
E --> F[Permission answers队列自动应答]
F --> G[收集stdout + session log]
G -->|Record| H[写入*.expected.jsonl snapshot]
G -->|Replay| I[Normalize去除非确定性]
I --> J[与expected snapshot diff]
J -->|一致| K[测试通过]
J -->|不一致| L[测试失败, 输出diff]
style H fill:#2e7d32,stroke:#fff,color:#fff
style L fill:#8b0000,stroke:#fff,color:#fff
Normalizers:去除非确定性内容
Snapshot测试最大的敌人是非确定性——时间戳、随机ID、临时路径、进程ID这些每次运行都变,你不能直接raw diff。Normalizers负责在对比前把这些”会变但不重要”的内容归一化。
Normalizers处理的内容包括:
- 时间戳(Unix时间戳、ISO日期字符串)→ 替换为固定占位符
- 随机生成的ID(UUID、短ID)→
替换为$SESSION_ID、$EVENT_ID等占位符 - 临时目录路径 → 替换为
$TMPDIR - 进程ID、端口号 → 替换为占位符
- 性能数据(token/sec、耗时)→ 归一化
Session log收获:子进程关闭后,harness读取所有持久化的JSONL文件,按parentSession排序,然后normalize后与snapshot对比。
LLM Mock Server:22种预设故障行为
你需要测试各种LLM故障场景:连接重置、流中断、空响应、超时、格式错误JSON、rate limit、server error、auth error、context overflow、max tokens截断……LLM Mock Server提供22种预设行为,覆盖你能想到的所有故障:
- connection_reset:TCP连接重置
- stream_disconnect:SSE流中途断开
- empty:返回空响应
- stall:永远不返回(测试超时)
- malformed_json:返回无效JSON
- rate_limit:429 rate limit
- server_error:500 server error
- auth_error:401 auth error
- context_overflow:context length exceeded
- success:正常返回
- tool_call_success:正常返回tool call
- max_tokens:触发max tokens截断
- random:按权重随机选择行为
Mock Server是序列驱动的:你给它一个行为序列,它按顺序执行每个行为;序列耗尽后复用最后一个。random行为支持权重配置。它是OpenAI-compatible的HTTP/SSE服务器——你的agent不用改代码,只需要把baseURL指向mock server就行。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "behavior\|preset\|connection_reset\|malformed_json" "$repo/packages/test-support/llm-mock-server/src/index.ts" | head -30
你会看到预设行为的枚举和分发逻辑。
Invariant检查:每个包自带守卫
最后一道防线在代码里:每个包必须有一个src/invariant.ts companion文件——这是CI gate强制的,不是可选的。
Invariant分两阶段执行:
- pre-commit validation:在事件apply到Session前,纯函数验证事件本身合法性(internal/dispatch阶段)
- post-commit application:事件apply后,验证transition后的状态合法性
检查的内容包括:
- seq严格递增(不能跳号、不能重复)
- turn/step编号连续配对
- 不能在开放turn外嵌套另一个turn
- step必须在开放turn内
- tool/result必须有先验的tool/call(repair合成的除外)
- request/header等事件必须在turn内
Seed validation在Session创建时对seed events做同样检查——不是只检查append的事件,初始seed也要合法。
容易踩的坑
坑一:测试只mock内部函数不跑真实进程。 你测了函数返回值,但stdio序列化、进程生命周期、事件持久化这些真正容易出问题的路径完全没覆盖。ACP Snapshot Harness就是为了解决这个问题。
坑二:不做normalizer直接raw diff。 时间戳、随机ID每次都变,raw diff会导致测试永远失败。Normalizers是snapshot测试的前提,不是可选优化。
坑三:以为单元测试覆盖集成路径。 单元测试测的是模块内部逻辑,集成bug出现在模块边界——Cordis组装、服务依赖、事件流转、子进程通信。必须有真实进程级别的snapshot测试。
坑四:Invariant是”额外的检查”可以关。 Invariant不是调试用的assert,是正确性保证。CI强制每个包必须有invariant.ts,就是为了不让你在生产环境关掉它们。
坑五:Record模式用真实API直接提交snapshot。 Record完要人工review snapshot内容——真实API可能返回你没预期的工具调用,直接提交会把不稳定行为固化成snapshot。
测试能证明”在记录的场景下它按预期运行”,但每个包自己的守卫是什么?CI怎么强制不变量检查?下一章讲Invariant Catalog和生成式文档防漂移。