补全章节说明:原教程(dg-ai-notes)插图版只覆盖到第 10 章。本章基于 Pi v0.81.1 官方仓库的 337 个测试文件(packages/{ai,agent,tui,coding-agent}/test/)与测试基础设施(test.sh、vitest.config.ts、test/suite/harness.ts)补全,行文风格与粒度对齐前 10 章。
上一章末尾留了一个问题:你写了个扩展,注册 tool_call 处理器返回 { block: true },怎么验证它真的拦住了?
把问题再放大一层:Pi 自己呢?Agent Loop 的循环逻辑、工具的并行调度、上下文压缩的切割点、TUI 的差分渲染——这些前 11 章讲过的机制,Pi 官方怎么保证改一行代码不把它们改坏?
答案藏在仓库里的 337 个测试文件、约 3600 个测试用例中。这一章不逐个讲测试(那没有意义),而是回答一个更值钱的问题:面对一个核心依赖"不确定输出"的系统,测试体系应该怎么设计?
传统软件测试的黄金公式是:给定输入 → 调用函数 → 断言输出。add(2, 3) 永远返回 5,断言写死就行。
但 Agent 系统的核心是 LLM,这个公式在它面前三处碰壁:
困境 1:输出不确定。 同一个 prompt 问两次,LLM 可能给出措辞完全不同的回答。你没法写 expect(answer).toBe("...")——今天绿的断言明天就红。
困境 2:调用要花钱。 每跑一次测试套件都真调 API,3600 个用例跑下来是一笔真金白银。CI 每次 push 都跑?没人付得起这个账单。
困境 3:依赖网络和密钥。 测试要 ANTHROPIC_API_KEY,意味着新贡献者 clone 下来跑不了测试;网络抖一下,CI 就假失败。
一个直觉的错误答案是:"那就少测点,靠人工验收。" Pi 的仓库证明了相反的路线——测试可以又多又稳又免费,前提是把系统"切"对地方。
先看 Pi 测试体系的全景:
| 包 | 测试文件数 | 测试框架 | 测什么 |
|---|---|---|---|
| ai | 112 | Vitest | 协议层:流式解析、缓存、思考块、工具调用归一化 + 真模型 e2e |
| agent | 18 | Vitest | Agent Loop 编排、事件序列、工具调度 |
| tui | 27 | Node 内置 node:test | 终端渲染、差分渲染、输入键位 |
| coding-agent | 180 | Vitest | 会话、扩展、工具、压缩的集成测试 |
有意思的第一个细节:tui 包用的不是 Vitest,而是 Node 内置的 node:test。UI 库零外部依赖的洁癖(第 1 章讲过它只依赖两个包),延伸到了测试框架的选择上——渲染测试不需要 Vitest 的任何高级特性,那就不引入。
下面按"从内核到外围"的顺序,拆 Pi 应对三重困境的四套打法。
第 3 章讲过,Agent Loop 的入口是:
注意最后一个参数 streamFn。整个 Agent Loop 与 LLM 的全部联系,就是这一个函数——传入上下文,返回一个 AssistantMessage 事件流。Loop 不关心流的背后是 Anthropic 的 SSE、还是 Google 的分块响应。
这个设计在第 4 章叫"协议 > 实现"。到了测试这一章,它兑现出第二重红利:streamFn 是可以造假的。
LLM 的不确定、花钱、依赖网络,全部被封在 streamFn 这一个边界后面。把它换成假的,边界内的一切——循环逻辑、工具调度、事件发射、消息组装——瞬间变成确定的、免费的、离线的普通代码。
packages/agent/test/agent-loop.test.ts 开头就定义了假流(agent-loop.test.ts:16):
配套一个假模型对象——baseUrl: "https://example.invalid"、cost 全 0,物理上保证绝不触网。
有了假流,测试 Agent Loop 的标准姿势是三步(agent-loop.test.ts:119-164):
那"模型调工具"这种多轮交互怎么造假?用闭包计数器状态机:假 streamFn 第 0 次被调用时吐一个 toolCall(stopReason: "toolUse"),第 1 次吐普通文本(stop)。回忆第 3 章的循环规则——"有 toolCall 就继续转"——这两条假响应恰好驱动 loop 走完"模型 → 工具 → 模型"的完整回合。测试脚本化的不是模型的智能,而是模型的输出模式。 loop 只认输出模式,这就够了。
你可能以为假流只能测"冒烟"级别的东西。看几个 agent-loop.test.ts 里的真实断言,它们全是第 3、5 章讲过的最微妙的语义:
其它同一模式覆盖的边界:被 length 截断的 toolCall 绝不执行、beforeToolCall 钩子改参后不重新校验、executionMode: "sequential" 强制串行、shouldStopAfterTurn 触发时精确断言 12 步事件序列(agent-loop.test.ts:1185-1198)。
并发时序这种最容易出 heisenbug 的地方,反而是假流测得最狠的地方——因为假流让时序可复现了。
上一节的代码里藏着本章第二个关键决策,值得单独拎出来。
传统测试断言"返回值"。但 Agent Loop 的"返回值"只是最终消息列表——中间过程全部丢失。而 Agent 系统的 bug 恰恰多发于过程:事件发早了、发漏了、顺序反了、流式中间状态错了。
Pi 的做法:把第 7 章的事件系统直接征用为测试的断言面。 Agent 每步都 emit 事件(这是产品需求,UI 靠它渲染),测试只需订阅并收集,就得到一份完整的"行为心电图":
对这份心电图断言,比对最终结果断言强在三处:
这里有一个值得回味的因果倒置:第 7 章说事件驱动是为了"UI 和内核解耦"。但从测试视角看,一个把每步都 emit 出来的系统,天然就是一个可测试的系统。可观测性和可测试性是同一枚硬币的两面。
MockAssistantStream 够测 Agent Loop,但再往上一层就不够用了。AgentSession(产品层)关心的是:流式 delta 逐 token 到达时 UI 状态对不对、Esc 中断时消息怎么收尾、token 用量怎么累计——这些行为依赖"流的真实质感",一次性 push 完整消息的手写 mock 模拟不出来。
Pi 的解法是把替身升级成一等公民:packages/ai/src/providers/faux.ts——一个内置在 ai 包源码(不是测试目录!)里的假 provider。
它可以像真 provider 一样注册进 ModelRegistry,然后把"预设消息"渲染成逐 token 的真实事件流:
它的拟真度清单(faux.ts:308-467):
这里的设计判断值得抄走:替身的价值不在于"省掉真依赖",而在于"够不够真,真到能测边界"。 一个空壳 mock 只能测 happy path;一个会逐 token 吐字、会被打断、会命中缓存的替身,才能测流式渲染、中断处理、并发时序——恰恰是 Agent 系统最容易出 bug 的三个地方。
全仓有 40 个测试文件直接建立在 faux provider 之上。
顺带说清一个容易误读的词:Pi 仓库里 agent/test/e2e.test.ts、test/suite/ 说的 "e2e" 是"端到端跑完整 Agent(用 faux)",不等于"调真模型"。真模型测试另有一套管理办法(见第七节)。
到 coding-agent 这一层(180 个测试文件,全仓一半),要测的是完整产品行为:会话持久化、扩展加载、压缩触发、工具读写文件。依赖多到手工装配会淹死人。
Pi 的答案是一个统一的测试装配函数 createHarness()(test/suite/harness.ts:100-189):
五个替身各堵一个"副作用出口":
| 副作用 | 替身 | 来源 |
|---|---|---|
| LLM 调用 | faux provider | 第 4 节 |
| 会话写盘 | SessionManager.inMemory() | 第 10 章讲过的 memory-storage 一族 |
| 设置读写 | SettingsManager.inMemory() | 同上 |
| 凭据读取 | AuthStorage.inMemory() + 假 key | 同上 |
| 文件系统 | mkdtemp 临时目录当 cwd,用完 rmSync | 工具读写全落在沙盒内 |
还记得第 10 章说 memory-storage.ts 时标注了一句"测试用"吗?现在看到了它的完整用途——Pi 在设计存储抽象的那一天,就同时给测试准备好了替身。接口化不只是"允许换数据库",也是"允许换成内存假货"。
典型用例长这样(suite/agent-session-prompt.test.ts:43-78):
四行脚本、两行断言,测完一个完整的"提问 → 调工具 → 回答"产品级回合。
回到上一章留下的问题。扩展 = 磁盘上的 TS 文件 + jiti 动态加载,测试怎么办?Pi 的做法朴素到出人意料——测试里现场把扩展源码写成文件,然后走真实加载路径(test/extensions-discovery.test.ts:14-52):
发现、jiti 转译、工厂执行——整条第 11 章第六节讲的旅程,一步不 mock。而对"事件拦截"类扩展逻辑,harness 提供 extensionFactories 选项直接注入内联工厂函数(不落盘),把 runner 接进 Agent 回调后,tool_call 拦截、context 注入、before_provider_request 改写全部可测。
上一章问"返回 { block: true } 怎么验证真的拦住了",现在有了完整答案:faux 模型吐一个 toolCall → 内联扩展返回 block → 断言事件序列里没有 tool_execution_start、且 toolResult 消息带着拦截 reason。全程零网络、毫秒级。
第 9 章的压缩算法怎么测?切割点选择依赖"一份足够长、结构足够刁钻的会话"。Pi 把这种会话固化成 fixture 文件:test/fixtures/large-session.jsonl、before-compaction.jsonl——测试读入、跑压缩、断言切点和摘要结构(compaction.test.ts:35-38)。JSONL 会话格式(第 10 章)在这里兑现了又一重红利:会话即文本文件,测试样本可以像代码一样被 review 和版本管理。
UI 测试是公认的老大难,终端 UI 更难——输出是一串 ANSI 转义序列,肉眼都难读,怎么断言?
Pi 的方案:用 xterm.js 的 headless 版(@xterm/headless)做虚拟终端(test/virtual-terminal.ts:11)。TUI 以为自己在往真终端写 ANSI 序列,实际写进了一个内存中的终端仿真器;测试再从仿真器的屏幕缓冲区读回渲染后的文本:
这就把"断言 ANSI 序列"这个不可读的问题,转换回了"断言屏幕上第 N 行是什么字"这个人类问题:
差分渲染(第 1 章提过的"每帧只重绘变化的单元格")的正确性分三个层次验证:
第 2 层最能体现"测 UI 库和测 UI 应用的区别":pi-tui 的卖点是差分渲染的效率,只断言最终画面等于没测卖点。断言 ANSI 写入串,才测到了"少写了多少字节"这个真正的承诺。
前六节的一切都不碰真 LLM。但协议层(ai 包)绕不开——SSE 解析、思考块方言、工具调用格式,这些代码的对手方是真实 API,用假流测它们是自己骗自己。所以 ai 包 112 个测试文件里有 30 个是真模型测试。三重困境的"花钱、要网络"在这里正面遭遇。Pi 的管理办法是三板斧:
全仓 379 处 .skipIf。每个 provider 各带各的 key gate(abort.test.ts 一个文件里按 provider 排了近 20 个 describe.skipIf),缺哪家的 key 就跳哪家的测试。新贡献者 clone 下来 ./test.sh,全绿——真模型层显示为 skipped,不是 failed。"跳过"是一种诚实:它告诉你这部分没验证,而不是假装验证过了。
npm test 直接跑 Vitest(有 key 就会真调 API)。而根目录 test.sh 是 CI 安全入口,核心是一行 env -i——清空全部环境变量,只放行白名单(test.sh:44-71):
白名单里没有任何 *_API_KEY,于是所有 skipIf 门自动关闭——不是靠纪律"别忘了 unset key",而是靠机制"key 根本进不来"。假 HOME 顺带解决了另一类脏测试:任何不小心读写 ~/.pi/ 的代码在测试里碰到的都是一次性目录。AGENTS.md 把这条写成了团队铁律:
"Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run ./test.sh."
被 key 门放行的测试,成本控制也有一套组合拳:
| 手段 | 做法 | 例子 |
|---|---|---|
| 确定性小题 | 让模型算一道固定算术题、按固定格式回答 | expect(text).toBe("sum=353418362; divisibleBy11=yes") |
| 封顶输出 | maxTokens: 1024 | 同一个 smoke 测试 |
| 容忍抖动 | { retry: 2 }——重试而不是放宽断言 | 真模型偶发波动不误报 |
| 超时上限 | 全局 testTimeout: 30000,注释直言 "for API calls" | vitest.config.ts |
注意"确定性小题"的巧思:它没有向不确定性投降(改成模糊断言),而是把题目设计到只有一个正确答案,硬是在真模型上保住了 toBe 级别的精确断言。
Pi 的测试体系没有用到任何新奇的测试技术——Vitest、mock、fixture、临时目录,全是家常菜。值钱的是五个结构性决策:
1. 把不确定性收敛到单一边界注入点。 整个 Agent Loop 对 LLM 的依赖只有一个 streamFn 函数参数。边界越窄,可替换性越强,边界内的可测面积越大。设计 Agent 系统时,第一天就把"LLM 调用"抽成可注入的函数——这个决定同时买到了多供应商支持(第 4 章)和可测试性(本章)。
2. 事件序列 = 主断言面。 不只断言最终结果,而是收集事件流断言其类型与顺序。可观测性和可测试性是同一件事:给系统定义稳定的事件协议,UI 和测试就有了共同的抓手。
3. 替身要真到能测边界。 faux provider 会逐 token 吐字、会被 abort、会模拟缓存命中。空壳 mock 只能测 happy path;拟真替身才能测流式、中断、并发这些最容易出 bug 的地方。替身的投入产出比,取决于它复刻了多少"边界行为"。
4. 内存替身 + 临时目录,双层隔离副作用。 存储接口全部提供 inMemory() 实现,文件操作全部指向 mktemp 沙盒,test.sh 再用假 HOME 兜底。每个测试拿到独立无菌环境——可并行、可重复、可随手删。
5. 真实 LLM 测试作为可选层,缺 key 优雅降级。 skipIf + 环境白名单让默认路径全绿且免费;真要跑时用确定性小题、封顶 token、retry 控制成本与抖动。分层的本质是给每类测试标好价格:免费层随手跑,付费层刻意跑。
最后回看第 5 章的一个伏笔。当时讲 Operations 抽象的好处,列了一条"测试可以 Mock"。现在你可以看到这不是孤例,而是 Pi 全库的系统性习惯:streamFn 可注入、SessionStorage 有内存实现、工具操作走接口、终端是个可替换的 Terminal 接口。可测试性不是测试阶段"加"上去的,而是每个抽象边界设计时"留"出来的。 337 个测试文件只是结果,原因分布在前 11 章讲过的每一个接口里。
到这一章为止,Pi 的机制拼图完整了:从 Agent Loop 到模型调用,从工具到消息,从事件到上下文,从会话到扩展,再到验证这一切的测试体系。
最后一章我们换一个视角——不再钻某一个子系统,而是把 12 章讲过的所有设计决策放在同一张桌子上,问三个收官问题:这些决策之间有什么共同的思维方式?哪些是 Pi 独有的语境产物,哪些可以搬进你自己的项目?以及,"做减法"作为一种工程哲学,边界到底在哪里?
本章关键源码索引(Pi v0.81.1):
- packages/agent/test/agent-loop.test.ts — MockAssistantStream(:16)、造假流→跑 loop→断言事件序列全模式(:119-164)、并行工具双顺序断言(:656-678)、12 步精确事件序列(:1185-1198)
- packages/ai/src/providers/faux.ts — 一等公民假 provider:fauxText/fauxToolCall 构造器(:49-94)、逐 token 流生成与 abort 模拟(:308-467)
- packages/coding-agent/test/suite/harness.ts:100-189 — createHarness 统一装配(faux + 三件套 inMemory + 临时 cwd + 事件收集)
- packages/coding-agent/test/extensions-discovery.test.ts:14-52 — 扩展源码写盘真加载测试
- packages/coding-agent/test/fixtures/ — large-session.jsonl 等压缩测试考卷
- packages/tui/test/virtual-terminal.ts — xterm headless 虚拟终端替身;tui-render.test.ts — 结果/ANSI 过程/fullRedraws 计数三级断言
- test.sh — env -i 白名单隔离 + mktemp 沙盒 + 防误删校验;AGENTS.md:30-32 — 测试分层铁律
数字速览:337 个测试文件(ai 112 / agent 18 / tui 27 / coding-agent 180),约 3600 个用例;379 处 .skipIf 门控;40 个文件基于 faux provider;89 个文件使用临时目录/内存替身隔离。
版本说明:本章基于 Pi v0.81.1 仓库撰写(前 10 章基于 v0.80.2)。