给 Eyrie 的「测试片场」
搭一套替身与挂点
这篇把 eyrie 测试基础设施(test harness / toolkit)该怎么设计、以及为什么这么设计讲清楚。假定你完全没接触过「测试 harness」这个概念——所以我们从一个比方讲起,不省略前因后果。
★ 5 分钟前传:这篇到底在说什么#
如果你之前没听过「测试 harness」,这一节专门为你写。读完再往下,后面就都顺了。
先打个比方:harness = 特技片场
拍动作片,主角要从三楼跳下去。导演不会真把演员扔下来——他搭一个片场:替身演员、威亚、气垫、绿幕。这套「假但完全可控」的设施,让你把危险、昂贵、不可重复的镜头,安全地拍一百遍,每一遍都一模一样。
Eyrie 是什么(够用就好)
Eyrie 是面向 AI 编程时代的工作台:它把 Claude Code、Codex 这些不同的 AI agent「接进来」,归一化成同一套接口,让你在一个界面里规划任务、调度 agent、审查它们改的代码。
对这篇文档,你只要记住一条链路:
用户发一句话 → eyrie 起一个 agent → agent 一边干活一边吐出一串「事件」(我读了文件 / 我要改这行 / 我跑了命令…) → eyrie 把事件存进库、显示到界面上
这条链路,就是我们要反复测的东西。本文大半篇幅在讲:怎么在不动真 agent 的前提下,把这条链路的每一段都测扎实。
会反复出现的 9 个词
不用背,混个脸熟即可,正文里再遇到回来查。
provider / runner
provider = 某种 agent 的「接入驱动」(Claude 一个、Codex 一个);runner = 驱动「这一次对话回合」的执行器。
turn / session
你发一句、agent 干一轮 = 一个 turn(回合);多个 turn 串起来 = 一个 session(会话)。
event(事件)
agent 干活时吐出的最小动作单位。一个 turn 会吐几十上百个细粒度事件,不是「一条大消息」。
fake / stub / mock
三种替身。fake=能用的假实现(假 agent,按剧本吐事件);stub=占位空壳,被调到就报错;mock=监视「某方法被调几次」的探针。我们偏好 fake。
注入点 / seam(接缝)
代码里预留的「挂点」,让测试能把真零件换成替身。像墙里预埋的螺丝孔——平时看不见,要挂东西时正好有。
CI
持续集成:每次提交代码,GitHub 上自动跑全套测试的流水线。它不能烧钱、也不能依赖你本机装了什么。
确定性 / flaky
每次跑结果都一样 = 确定性;时灵时不灵 = flaky(最讨厌,等于没测,本文把它当 bug 治)。
事件溯源
不存「现在长啥样」,而是把发生过的每件事按顺序记成流水账,需要时重放这本账还原出现状。eyrie 的命门。
tRPC / WebSocket
前端和本地后台(daemon)之间通信的方式:所有请求走一条 WebSocket,用 tRPC 做类型安全的调用与订阅。
一句话总纲 ▸ 全套设计都为两条硬约束服务:① 不能烧 token(不能每跑一次测试就真打一遍模型)、② 不能依赖真实环境(否则上不了 GitHub CI)。所有「替身」和「挂点」,都是为了在满足这两条的前提下,仍然把链路测真测全。
0 测试哲学 · 七条立身之本#
整套 harness 的所有设计都从这七条推出来。后面任何一个工具件、任何一层分层,都是为了让这七条成立。
三层测试诉求
eyrie 是多 runtime 的 agent 工作台——要把 Claude Code、Codex 等不同 runtime 归一化成统一接口。围绕这个目标,测试有三层诉求:① 每个 provider/adapter 自己要有测试(协议翻译对不对);② 抽象层要有测试(事件归一化、落库、重放等逻辑);③ 带前端交互的集成测试要有。
- 测行为,不测实现。断言落在「外部能观察到的东西」——归一化后的事件流、最终落库的投影、UI 渲染出的结果——而不是「某个内部方法被调了几次」。前者重构不动,后者一改实现就红。
- 两种测试,没有中间态。要么快、确定、全 fake(CI 默认套件,毫秒级,零网络零 token),要么慢、打真实 CLI 的冒烟(排期/手动跑)。中间地带——半真半假、偶尔联网、又慢又不稳——是技术债,不要造。
- flaky 是 bug,不是重试。间歇性红的测试,是测试本身有缺陷(用了 sleep、依赖时间/顺序/真实时钟、没等条件成立就断言),按 bug 修,不靠 retry 掩盖。
- 确定性默认开。时间、id、事件顺序这些非确定性来源,由测试基底默认钉死——测试什么都不做就已经确定,要测真实时间才显式打开。不是每个测试「记得注入才有」的 opt-in。
- fake 优于 mock。用完整 fake 实现替换依赖,不用
vi.mock替换我们自己的模块(它绑死文件路径、让「替换了什么」变隐式、且不推动生产代码改善可测试性)。vi.mock只允许用在无法注入的第三方硬边界。 - 不烧 token、能上 CI。默认套件全 fake,零 token、零网络、确定性。真实 provider 测试拆成独立 vitest project,CI 不加载。
- toolkit 而非 god harness。抽一组小而专、可组合的件,每个测试按需取用;不造一个号称包办一切的巨型对象。
借鉴对象:ref/paseo 在这块做得很好,本设计大量借鉴它,并在几处做了改进(见第 15 节)。
1 现状盘点 · 设计的起点#
设计不是凭空起的——它建立在一个最小端到端 session 闭环(PR #66)之上。下面是「我们已经有什么」。
本设计建立在 feat/agent-session-loop-e2e(PR #66)之上——它把 agent 控制(createSession / startTurn / interrupt / close)接上了 tRPC、注册了内建 Claude provider、落地了统一的 agent_sessions 模型。该分支撰写时尚未合并、可能还会变,新变更要按本文的决策有选择地并进来,不机械照搬。
1.1 一个 turn 从上到下经过的链条
这是理解后面「在哪一层换假」的前提。一次对话回合,会顺着这条链往下走,最底层才碰到真的 claude 二进制:
记住这五个名字和它们的高低位置——第 7 节会沿着这条链,告诉你「测哪一层,就在它正下方换假」。
1.2 传输与事件流:tRPC over 单条 WebSocket
- renderer / CLI 通过
@eyrie/client连本地 daemon,所有业务请求走一条 WebSocket 上的 tRPC(query / mutation / subscription)。 - agent 控制在 tRPC
agent路由上:createSession/startTurn/interrupt/close(mutation)+agent.events(订阅)。 - 事件流 = tRPC 订阅:
agent.events(某 session 的事件时间线)、board.deltas(看板增量)。重放(已落库事件)+ 实时合并,断线按事件游标续传。 - HTTP 面只剩三条:health、shutdown、blob(二进制上传/下载)。
1.3 依赖注入:factory + 构造注入,无容器
createApp(deps) 装配 HTTP app;wireServices(...) 装配 tRPC 服务树。两者依赖都从参数进来,各 Service 继续往下注入(如 new AgentService(registry, repo, broadcaster, runnerManager))。组装根天然存在:生产的 index.ts 和测试基底的 testing/index.ts。这是「能把任何依赖换成 fake」的结构基础。
1.4 已经有的测试基底(部分)
| 件 | 是什么 |
|---|---|
startWebSocketDaemonFixture() | 真起一个 loopback daemon(127.0.0.1 随机端口),已接真 AgentService,能跑 session CRUD。缺口:registry 是硬编码的真实实现,没有「换成 fake registry」的注入口——这正是第 4 节要补的。 |
AgentLogSeam | 事件溯源测试的注入缝:seedRun 铺行、publishEvent 走真持久化游标落一条并广播、rollBackEvents 回滚 durable 日志(模拟从旧库恢复)。 |
FakeClaudeProcess 等 | Claude 专属「假子进程」:stdout/stderr 异步队列、stdin 捕获、kill/interrupt、以及各种失败旋钮(writeError / 退出码…)。lines.* 拼 Claude stream-JSON。 |
FakeRunner / FakeRunnerManager | runner-manager 缝的 fake。注意:目前在三个测试文件里各手写了一遍——正是第 6 节要收编进 toolkit 的味道。 |
1.5 前端怎么连 daemon
前端 DI seam 已存在:ClientProvider 有 value? prop——生产不传、真造连接;测试直接传一个 prebuilt context,整棵组件树就用它。这是前端版「组装根 + 注入」。传输层也有注入缝(createConnectionSession 能注入 WebSocket / 内存假传输)。
2 核心原则#
把哲学落成几条可执行的纪律。
2.1 不上 DI 框架
在 TS/Node、在我们这个规模,最佳实践就是不上 DI 容器。装饰器容器伤 tree-shaking、运行时容器把编译期装配换成运行时(漏注册要跑起来才抛)。createApp(deps) + 构造注入已把容器能给的拿到了,还是编译期查漏。
2.2 组装根唯一
只有组装根能 new 碰外部世界的实现、或 import 单例;其它模块依赖从参数进来。前端组件永远只通过 Context(useClient)拿 client,不直接 import 单例。守住它,任何依赖都能换 fake。
2.3 fake 优于 mock
vi.mock 只用在无法注入的第三方硬边界。fake provider 接受声明式脚本(这次 turn 该吐哪些事件),而不是靠猜 prompt 内容伪造行为。
2.4 确定性默认开
时间和 id 是两大非确定性来源。处理方式不是「每个测试自己记得注入 clock」,而是测试基底默认就装好确定性时钟 + 自增 id。要测真实时间才显式打开。
2.5 toolkit 不做 god harness
抽成一组各司其职、能拼装的件,不是一个包办一切的对象。用哪几件取决于测哪一层。
2.6 每层一个标准 fixture
避免「装配方式各写各的」。三层各有且只有一个规范入口:纯 unit 直接 new;daemon 集成走真 WS fixture;前端集成同一个 fixture + ClientProvider value=。
3 注入点 Seams#
原则:凡是会触达外部世界、或引入非确定性的东西(provider 进程、git、时钟、id、文件系统根、传输 socket),都必须有注入点。
| Seam | 默认实现 | 要做的事 |
|---|---|---|
registry: AgentRegistry | 硬编码真实 DbAgentRegistry + 内建 Claude | 给 startWebSocketDaemonFixture 加可选 registry 覆盖,让测试塞装了 scripted provider 的 registry。只差这一个口子,就能在不打真二进制的前提下、在 CI 里端到端驱动一个 turn。 |
clock: () => number | Date.now | 新增。落库代码里 ~28 处直接 Date.now() 全换成 deps.clock(),由 fixture 默认装确定性时钟。 |
idgen: () => string | createId | 新增。行 id / runId / toolCallId 生成点换成 deps.idgen(),fixture 默认装自增 id。 |
| provider 进程 / 传输 | 真 spawn | 每个 provider 在自己的 spawn 缝注入假进程。Claude 已有:SpawnClaudeProcess → 假货 FakeClaudeProcess。 |
gitRunner / filesystemRoots / 前端 WS | 真实现 / 临时目录 / 真连接 | 已有,作为范式。 |
4 测试基底 · 三个标准 fixture#
每层一个入口,写测试时不再纠结「怎么拼 app」。
4.1 纯 unit
测单个类自身逻辑(事件投影、config 归一化、协议翻译、AgentService 编排)。直接 new 被测类,喂 fake 协作者。
4.2 daemon 集成
startWebSocketDaemonFixture():真起 loopback WS daemon,注入装了 scripted provider 的 registry,只有 provider 是假的。真路由、真 tRPC、真订阅、真 SQLite、真 RunnerManager/sink/投影。
4.3 前端集成
同一个 WS fixture + ClientProvider value=。renderer 真连真 socket,链路里只有 provider 是假的。验证「发消息→事件流进来→UI 渲染 tool 卡片 / approval 弹窗 / diff」。
这条链(WS + 订阅)正是要测的东西——用内存假传输测,等于把它绕过去了。只有不碰传输的纯缓存/dispatcher 逻辑(失效 key、乐观更新回滚、resync)才走 createConnectionSession({ 内存假 link })。
4.4 out-of-band 状态铺设
集成 / E2E 里别用 UI 去搭前置状态(慢且脆)。用 fixture 的 services / AgentLogSeam 在测试代码里直接把后端状态摆好,再走 UI 验证。注意 session 模型已统一:铺一条 session 的前置状态要先铺它所属的 task(project 由 task 派生、workingDir 在 task-create 绑定),不是单独插一行 session。
5 Test Toolkit 清单#
一组小而专的件。每件标注「是什么 / 为什么」。代码落在 eyrie/ 内(英文),签名是草图。
5.1 createScriptedProvider + scenarios.*
给上层 / 前端当替身的可编程 fake provider——把整个 provider 换成假货。它接受一个声明式脚本(这次 turn 该按顺序吐哪些事件),不靠猜 prompt 伪造行为:
type ScriptedTurn = {
match?: (input: AgentInput) => boolean // 可选:按输入选脚本;默认按调用顺序消费
events: AgentProviderEvent[] // 这次 turn 确定性吐出的事件序列(细粒度)
closeWith?: 'completed' | 'crashed' | 'killed'
}
function createScriptedProvider(opts: {
id?: string
kind?: AgentProviderKind
capabilities?: AgentCapability[] // fake 也声明能力,留住将来补降级测试的接缝
turns: ScriptedTurn[]
}): AgentProvider
- 事件用真类型
AgentProviderEvent——脚本本身就是契约的活文档,契约变了脚本编译就报错。 - 可观测:内部用
vi.fn记录startTurn/interrupt/respondToApproval调用,供断言。 - 预置场景
scenarios.*(happyPath / needsApproval / inputRequest / interleavedStreaming / longStream / 各种失败场景)。 - 另配
createStuckProvider()(方法永不 resolve)专测超时 UI(转圈→还在处理→超时重试)。
5.2 收编 FakeRunner / FakeRunnerManager
当前 PR 里 FakeRunner 在三个文件各手写一遍——收进 toolkit 一份,定一条 review 规则:测试文件里不许手搓 fake。形状沿用现有实现(错误注入旋钮 + 调用记录),配 DisposeRecordingRunnerManager 记录 teardown 顺序。
5.3 per-provider 进程 fake(FakeClaudeProcess 是范式,不通用化)
给真实 provider adapter 测试用的假「子进程 / 传输」。每个 provider 自己养一个——它们脚下不共享传输形状(Claude 是 stdio + stream-JSON + control protocol,Codex 是另一套),硬抽「通用 fake transport」必然漏抽象。范式:在真实 runner 的 spawn 缝注入假进程,喂手写字节,失败做成一等旋钮(spawn 失败、流截断、垃圾/半截字节、事件乱序、退出码)。
喂的字节是手写的(凭文档和观察),不是从真 CLI 录的——录制 fixture 不做(见第 13 节)。
5.4 runProviderConformance(name, setup)
各真实 provider 共享的行为契约断言套件(类比给 Go io.Reader 写一组通用测试)。不变式从「类型契约 + 事件词汇」推导,绝不从某个 provider 的行为反推——这样只有一家时写也不跑偏。
runProviderConformance('claude-code', async () => {
const process = new FakeClaudeProcess()
return { provider: makeClaudeProviderOver(process), drive: process, cleanup }
})
锁定的不变式(随契约增补):任何 message 事件前必须先吐 run.started;startTurn 一定走到终态(completed / crashed / killed),不会永远挂着;interrupt() 一定导致终态;respondToApproval() 能解开卡住的 turn;describePersistence() 吐的 resume handle 能被 createRunner({ resume }) 吃回去。
5.5 createStub<T>()
给边角依赖(接口大、测试只关心两三个方法)用的 typed stub。消灭满地 as unknown as T:
export function createStub<T extends object>(stubs: { [K in keyof T]?: unknown }): T {
return new Proxy(stubs, {
get(target, prop, recv) {
if (Reflect.has(target, prop)) return Reflect.get(target, prop, recv)
if (typeof prop === 'symbol') return undefined
return () => { throw new Error(`createStub: "${String(prop)}" was called but not stubbed`) }
},
}) as T
}
分工:scripted provider / FakeRunner 给「我关心、要建模其行为」的核心依赖;createStub 给「我不关心、只是接口要求得传」的边角协作对象。
5.6 collectEvents / waitFor
核心目的:用「等条件成立」替掉「sleep 一会儿」(sleep 是 flaky 头号来源)。
const events = await collectEvents(stream, { until: e => e.type === 'run.completed' })
expect(events.map(e => e.type)).toEqual([...])
await waitFor(() => screen.queryByText('done') !== null) // 内部 flush microtask / 推进 fake timer
5.7 buildSession / buildRun / buildEvent / buildTask
给字段多的行类型一套合理默认值,测试只覆盖它关心的字段。可读、抗变更(schema 加字段只改 builder 一处)。因为 session 由 task 派生,铺 session 的 builder 要连带铺好 task(带绝对路径 workingDir)。
5.8 确定性 clock / idgen
不是独立工具,而是 §4 fixture 的默认行为:基底装配时把 clock / idgen 设成确定性实现,所有走基底的测试开箱即确定(细节见第 11 节)。
★ Fake 缝的阶梯(全文最关键一节)#
「假掉 agent 执行」不是一个缝,而是沿着第 1.1 节那条链好几个不同高度的缝。
下面每条「泳道」是一种测试目标。■ 绿色 = 留真的、被测的部分;■ 红色 = 换成假货;✂ 标出在哪一层下刀。
两个最容易混的
缝 1 vs 缝 3
缝 1 把整个 provider 换成假货(测它上面的流水线);缝 3 留真 adapter、只换它脚下的进程(测 adapter 自己的协议翻译)。
缝 2 vs 缝 1
缝 2 把 RunnerManager 整套换掉(测 AgentService 自己);缝 1 留真 RunnerManager。永远别用缝 2 测事件流/重放——产生事件的机器(sink / broadcaster / 崩溃善后)正好被缝 2 假掉了。
7 测试分层 · 金字塔#
越往下越多、越快、越便宜;越往上越少、越贵、越接近真实。
前端另有最轻的档 a:纯组件 + stub client,不起 daemon,测单组件渲染/交互。所有集成 / E2E 的前置状态都走 §4.4 的 out-of-band 铺设,不用 UI 搭。
8 Agent 平台特有的三类必测#
这三类是 agent 工作台 bug 的真正藏身处,每一类都要有自己的一等位置。
8.1 流式真实度
一个 turn 不是「一条消息」一个事件,而是一串小事件:助手回复逐 token 来(message.delta 一片片),中间还插 tool 事件:
UI 的 bug 几乎都藏在这里(delta 能否攒成一个气泡、插在中间的 tool 卡片位置、乱序处理)。这是一条 API 设计约束(事件接口必须细粒度,别图省事做成「吐整条消息」的粗接口)+ 一个预置场景 scenarios.interleavedStreaming。
8.2 失败 / 对抗注入:跨层共享场景目录
整个产品的卖点就是「优雅处理 agent 的丑陋失败」(spawn ENOENT、跑到一半 auth 过期、断流、半截/畸形 JSON、事件违规乱序、crash mid-turn)。所以失败不是某个工具的旁支旋钮,而是一份跨层共享的场景目录 scenarios.*,同一个失败场景定义一次,三层各跑一遍:
adapter 层
用进程 fake 的失败旋钮,验证协议翻译在垃圾输入下不崩、吐对终态。
抽象层
用 scripted provider 的 closeWith + error 事件,验证落库/投影/订阅在 crash 下一致。
前端层
同一个 crash 场景,验证 UI「转圈 → 报错 → 可重试」对。
设计 scripted provider 和进程 fake 时一开始就留失败口子,不要先做 happy path 再回来补。
8.3 事件溯源不变式(核心高危类别)
eyrie 的命门是「事件日志 + 重放 + resume + 自愈」。
底座是 AgentLogSeam(seedRun / publishEvent / rollBackEvents),驱动事件用缝 1 的 scripted provider。要钉死三条不变式:
- 重放 == 实时同一串事件,重放出来的投影和实时跑出来的逐字段相等。
- resume 无缝从游标 N resume + 后续新事件 == 不中断跑完的结果,不丢不重。
- 游标单调
sessionSeq严格递增、不跳号、不重号;durable 高水位回滚后重新落库仍然 gapless。
9 非确定性的处理 · clock 与 idgen#
为什么脚本(fake)盖不住时间和 id?因为它们有两个来源,脚本只能控一个。
✓ 脚本能控
provider 吐出来的事件内容——这是 fake 自己生成的,想吐什么吐什么。
✗ 脚本碰不到
eyrie 自己的代码盖的戳/id——fake 吐一个事件进来,落库时是 eyrie 的持久化代码调 Date.now() 盖 createdAt、调 createId() 生成行 id,在 fake 边界之外。
后果:任何断言落库时间戳、按时间排序、或断言生成 id 的测试都非确定性。处理:
- clock:
deps.clock?: () => number,默认Date.now,把落库代码里的Date.now()全换成deps.clock()。 - idgen:
deps.idgen?: () => string,默认createId。eyrie 是事件溯源,事件靠runId/toolCallId/itemId互相引用,确定性 id 让expect(event.toolCallId).toBe('tool-1')这种断言可写,不必捕获随机串回填。 - 默认装进 fixture:基底装配时把这两个 seam 设成确定性实现,测试开箱即确定;要测真实时间行为才显式打开。
10 CI 与真实 provider 测试策略#
默认套件全 fake 进 CI;真二进制 e2e 拆成独立 vitest project,CI 不加载。
为什么用「独立 project 排除」而不是「在默认套件里 skipIf 跳过」
① skipIf 的文件仍会被 import、求值
describe.skipIf 只跳测试体,文件顶层的 import(真 adapter)、env 读取、版本探测照样跑。真二进制文件最容易在模块加载期碰环境,一旦 top-level 抛错会把整个 CI 套件带红。独立 project 根本不加载,意外归零。
② pool 和 timeout 该分家
真二进制要 forks pool + 120s 超时;单测要短超时好暴露 hang。混在一个 run 里只能全局将就。
③ 「永不进 CI」是一处配置事实
不是散在每个文件的运行时判断——单一真相源,新加的 e2e 文件天然落在外面。
④ 干掉 skip 噪音
默认 run 干净「all passed」,不让真正该跑却被错误 gate 的测试藏在 skip 计数里。
精确形态(混合,不是二选一)
- project 级排除管「进不进 CI」:默认 config 不含
*-e2e.test.ts;单独vitest.e2e.config.ts(forks + 长超时)+test:e2e脚本跑它。 - env 的 skipIf 不删,降级成「选哪个二进制」:在 e2e project 内部,
EYRIE_CLAUDE_E2E/EYRIE_CODEX_E2E决定跑 claude 还是 codex,版本/走读轨迹也由 env 控制。
两点澄清:① 类型不会因此烂掉——typecheck / build 覆盖所有文件,与 vitest 跑不跑哪个 project 无关。② 它跟「真二进制上定时任务」配套——CI-exclude 唯一损失是「日常 run 里少了这些 e2e 的提醒」,正确补法是挂 nightly schedule 跑 test:e2e 对表(本期先把排除做掉,定时那半后补)。
11 范围与非目标#
明确「不做什么」,以及什么条件下重新考虑。
| 项 | 结论 | 理由 | 重新考虑的触发条件 |
|---|---|---|---|
通用 createFakeTransport(一个件覆盖所有 provider 传输) | 不做 | provider 脚下不共享传输形状,硬抽是漏抽象;每家自养进程 fake(§5.3)。 | —— |
| 从真 CLI 录制 golden fixture 喂 conformance/进程 fake | 不做 | 用户 agent 版本不一,注定跑不全;走手写字节,对不上再改。 | adapter 协议频繁漂移、多次踩到 fixture 与现实不符。 |
| capability 分歧穷尽测试(不支持某能力时的全覆盖优雅降级) | 不做 | 穷尽不了「不支持」的所有情况;dogfood 时能发现。createScriptedProvider 仍留 capabilities 参数,seam 已就位。 | 对外用户变多、能力差异成常见报障。 |
| 引入 DI 容器框架 | 不做 | factory + 构造注入已是该规模最佳实践,容器是降级(§2.1)。 | 依赖图大到手工组装根失控(基本不会发生)。 |
12 落地顺序#
从「接缝改造」打地基,到「真二进制 e2e 拆 project」收尾。
- seams(接缝改造,不改行为)
clock/idgen注入点 + 默认装进 fixture(确定性默认开);给startWebSocketDaemonFixture加 fake-registry 注入口——只差这一口子就能在 CI 里驱动 turn。 - toolkit 地基
createScriptedProvider+scenarios+ 收编FakeRunner/FakeRunnerManager+createStub+build*+collectEvents/waitFor;消除FakeRunner三处重复。 - 用 toolkit 重写现有手写 fake
重写
agent-integration.test.ts等,验证 toolkit 够用。 - 事件溯源不变式套件(§8.3)
基于
AgentLogSeam+ 缝 1 scripted provider,钉死 重放==实时 / resume 无缝 / 游标单调。 - 前端三档脚手架
重点把档 b(
ClientProvider value=+ 真 WS fixture + fake registry)串通。 - conformance 套件(§5.4)
从契约推不变式,Claude 先接入。
- 失败场景目录(§8.2)
三层各接一遍。
- 真二进制 e2e 拆独立 project(§10)
默认 config 排除
*-e2e.test.ts+vitest.e2e.config.ts+test:e2e,env 降级为二进制选择。
13 借鉴来源(paseo)与我们的改进#
本设计大量借鉴 ref/paseo(完整 fake provider、永不 resolve 的 slow provider、进程级 daemon harness、typed Proxy stub、前端 E2E 隔离 + out-of-band DSL、测试哲学文档),并在五处做了改进:
1 · 可编程脚本 fake,而非 prompt-sniffing
让测试声明事件序列,不靠猜 prompt 伪造 tool_call,更可控、更好维护。
2 · fake 缝的阶梯(§6)
把「在哪个高度换假测哪一层」讲成一张明确的表,并把 conformance 做成从契约推导的共享套件。
3 · 事件溯源不变式独立成章(§8.3)
把「重放==实时 / resume 无缝 / 游标单调」当一等测试类别,配 AgentLogSeam 专门守。
4 · 确定性默认开(§2.4 / §9)
clock / idgen 做成一等注入点并由 fixture 默认装,不散落各处调 Date.now(),也不靠每个测试 opt-in。
5 · 真二进制 e2e 独立 project、CI 不加载(§10)
用配置级排除替运行时 skip,避免加载副作用、分离 pool/timeout、单一真相源、零 skip 噪音。