Internal · Context Sync

给 Eyrie 的「测试片场」
搭一套替身与挂点

这篇把 eyrie 测试基础设施(test harness / toolkit)该怎么设计、以及为什么这么设计讲清楚。假定你完全没接触过「测试 harness」这个概念——所以我们从一个比方讲起,不省略前因后果。

读者:没参与设计的同事 原文:eyrie-inhouse/test-harness-design.md 真相源:eyrie/ 仓库里的 seam & toolkit 代码
怎么读这篇

如果你赶时间:读「前传」+ 第 7 节「缝的阶梯」就够建立直觉了。其余章节是细节展开,需要时按左侧目录跳。下面所有 API 签名都是设计草图,不是最终接口——「怎么写」以 eyrie/ 代码为准,本文只讲「为什么」。

5 分钟前传:这篇到底在说什么#

如果你之前没听过「测试 harness」,这一节专门为你写。读完再往下,后面就都顺了。

先打个比方:harness = 特技片场

拍动作片,主角要从三楼跳下去。导演不会真把演员扔下来——他搭一个片场:替身演员、威亚、气垫、绿幕。这套「假但完全可控」的设施,让你把危险、昂贵、不可重复的镜头,安全地拍一百遍,每一遍都一模一样。

核心比喻

软件测试里的 harness 就是这个片场。我们要测的「危险镜头」是:调用真的 AI 模型、起真的子进程、连真的网络。这些动作要么烧钱(每次调模型都花 token),要么在自动化流水线里根本跑不起来。harness 提供「替身演员」(fake)和「吊威亚的挂点」(注入点 / seam),让测试既贴近真实、又安全、又快、又稳定。

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 自己要有测试(协议翻译对不对);② 抽象层要有测试(事件归一化、落库、重放等逻辑);③ 带前端交互的集成测试要有。

  1. 测行为,不测实现。断言落在「外部能观察到的东西」——归一化后的事件流、最终落库的投影、UI 渲染出的结果——而不是「某个内部方法被调了几次」。前者重构不动,后者一改实现就红。
  2. 两种测试,没有中间态。要么快、确定、全 fake(CI 默认套件,毫秒级,零网络零 token),要么慢、打真实 CLI 的冒烟(排期/手动跑)。中间地带——半真半假、偶尔联网、又慢又不稳——是技术债,不要造。
  3. flaky 是 bug,不是重试。间歇性红的测试,是测试本身有缺陷(用了 sleep、依赖时间/顺序/真实时钟、没等条件成立就断言),按 bug 修,不靠 retry 掩盖。
  4. 确定性默认开。时间、id、事件顺序这些非确定性来源,由测试基底默认钉死——测试什么都不做就已经确定,要测真实时间才显式打开。不是每个测试「记得注入才有」的 opt-in。
  5. fake 优于 mock。用完整 fake 实现替换依赖,不用 vi.mock 替换我们自己的模块(它绑死文件路径、让「替换了什么」变隐式、且不推动生产代码改善可测试性)。vi.mock 只允许用在无法注入的第三方硬边界。
  6. 不烧 token、能上 CI。默认套件全 fake,零 token、零网络、确定性。真实 provider 测试拆成独立 vitest project,CI 不加载。
  7. 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 二进制:

AgentService
CAS 状态机(idle→active→idle→closed)、校验、teardown 顺序
RunnerManager
getOrCreate 去重、崩溃/dispose 善后、绑 SessionSink
AgentRunner
驱动这个 turn(由 registry.getProvider(id).createRunner() 产出)
SpawnClaudeProcess → 子进程
真 claude 二进制(stdio + stream-JSON + 双向 control protocol)
SessionSink
事件落库 + 广播(broadcaster)

记住这五个名字和它们的高低位置——第 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 日志(模拟从旧库恢复)。
FakeClaudeProcessClaude 专属「假子进程」:stdout/stderr 异步队列、stdin 捕获、kill/interrupt、以及各种失败旋钮(writeError / 退出码…)。lines.* 拼 Claude stream-JSON。
FakeRunner / FakeRunnerManagerrunner-manager 缝的 fake。注意:目前在三个测试文件里各手写了一遍——正是第 6 节要收编进 toolkit 的味道。

1.5 前端怎么连 daemon

前端 DI seam 已存在ClientProvidervalue? 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 + 内建 ClaudestartWebSocketDaemonFixture 加可选 registry 覆盖,让测试塞装了 scripted provider 的 registry。只差这一个口子,就能在不打真二进制的前提下、在 CI 里端到端驱动一个 turn。
clock: () => numberDate.now新增。落库代码里 ~28 处直接 Date.now() 全换成 deps.clock(),由 fixture 默认装确定性时钟。
idgen: () => stringcreateId新增。行 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」。

为什么前端集成要连真 socket

这条链(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 createScriptedProviderscenarios.*

上层 / 前端当替身的可编程 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.startedstartTurn 一定走到终态(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 节那条链好几个不同高度的缝

一句话规则

要测哪一层,就在它正下方那个缝换假,上面留真。

在错误的高度换假,等于把要测的机器拆了——比如想测「事件流对不对」,却把产生事件的机器整个换成假货,那就什么都没测到。

下面每条「泳道」是一种测试目标。■ 绿色 = 留真的、被测的部分;■ 红色 = 换成假货;✂ 标出在哪一层下刀。

缝 2AgentService 编排(CAS 状态转换、interrupt→dispose→close 顺序、createSession 校验、closed-resume)
[断言] 真 AgentService ✂ 在 runner-manager 换假 FakeRunnerManager + FakeRunner
缝 1RunnerManager / sink / 投影 / 事件流 / 重放(去重、崩溃善后、落库与游标、事件溯源不变式)
[断言] 真 AgentService 真 RunnerManager + sink + 投影 ✂ 在 provider 换假 createScriptedProvider
缝 3provider adapter 协议翻译(stream-JSON 翻译、control protocol、失败注入下不崩)
[conformance 断言] 真 provider adapter ✂ 在进程/传输换假 FakeClaudeProcess(手写字节 / 失败注入)
缝 1 / WS前端 / 端到端 turn(发消息→事件流→UI 渲染)
[真 UI] 真 client 真 daemon(WS + tRPC) ✂ 在 provider 换假 createScriptedProvider
真二进制真 CLI 还对得上吗(排期/手动跑,不进 CI)
[断言] 全真(不换任何假) 真 claude / codex 二进制

两个最容易混的

缝 1 vs 缝 3

缝 1 把整个 provider 换成假货(测它上面的流水线);缝 3 留真 adapter、只换它脚下的进程(测 adapter 自己的协议翻译)。

缝 2 vs 缝 1

缝 2 把 RunnerManager 整套换掉(测 AgentService 自己);缝 1 留真 RunnerManager。永远别用缝 2 测事件流/重放——产生事件的机器(sink / broadcaster / 崩溃善后)正好被缝 2 假掉了。


7 测试分层 · 金字塔#

越往下越多、越快、越便宜;越往上越少、越贵、越接近真实。

档 c · Playwright E2E真 Electron + 真 daemon 进程(注入 fake registry)。只覆盖最核心 happy path + 1~2 个 approval/crash 流,因为贵
档 b(主力)· 组件 + 真 WS daemon + fake provider§4.3。快、稳、CI 友好——前端集成的主力
抽象层 / daemon 集成缝 1-over-WS:RunnerManager 生命周期、SessionSink 落库、event 投影、订阅重放与续传
provider 层 · runProviderConformance + 进程 fake(缝 3)每个真实 adapter 跑共享契约 + 各自特例
unit 层 · AgentService 编排走缝 2,单个类直接 new最快、最多,金字塔底座

前端另有最轻的档 a:纯组件 + stub client,不起 daemon,测单组件渲染/交互。所有集成 / E2E 的前置状态都走 §4.4 的 out-of-band 铺设,不用 UI 搭。


8 Agent 平台特有的三类必测#

这三类是 agent 工作台 bug 的真正藏身处,每一类都要有自己的一等位置。

8.1 流式真实度

一个 turn 不是「一条消息」一个事件,而是一串小事件:助手回复逐 token 来(message.delta 一片片),中间还插 tool 事件:

run.started
message.delta"好的"
message.delta",我来"
tool.started(Read file.ts) ← 插在消息中间
tool.completed(Read file.ts)
message.delta"看一下"
message.completed
run.completed

UI 的 bug 几乎都藏在这里(delta 能否攒成一个气泡、插在中间的 tool 卡片位置、乱序处理)。这是一条 API 设计约束(事件接口必须细粒度,别图省事做成「吐整条消息」的粗接口)+ 一个预置场景 scenarios.interleavedStreaming

8.2 失败 / 对抗注入:跨层共享场景目录

整个产品的卖点就是「优雅处理 agent 的丑陋失败」(spawn ENOENT、跑到一半 auth 过期、断流、半截/畸形 JSON、事件违规乱序、crash mid-turn)。所以失败不是某个工具的旁支旋钮,而是一份跨层共享的场景目录 scenarios.*同一个失败场景定义一次,三层各跑一遍

缝 3
adapter 层

用进程 fake 的失败旋钮,验证协议翻译在垃圾输入下不崩、吐对终态。

缝 1
抽象层

用 scripted provider 的 closeWith + error 事件,验证落库/投影/订阅在 crash 下一致。

档 b
前端层

同一个 crash 场景,验证 UI「转圈 → 报错 → 可重试」对。

设计 scripted provider 和进程 fake 时一开始就留失败口子,不要先做 happy path 再回来补。

8.3 事件溯源不变式(核心高危类别)

eyrie 的命门是「事件日志 + 重放 + resume + 自愈」。

录像带比喻

把它想成一盘录像带:不管你是实时看、还是倒回去重放、还是从中间某帧接着看,画面必须一模一样。这类 bug 最隐蔽、最伤,要用 property 式断言(对任意脚本事件序列都成立)专门守。

实时看事件来一条画一条
倒回重放从头重放已落库事件
中途接着看从游标 N resume + 后续新事件
三种方式 ═══ 同一个画面(投影逐字段相等)

底座是 AgentLogSeamseedRun / publishEvent / rollBackEvents),驱动事件用缝 1 的 scripted provider。要钉死三条不变式:

  1. 重放 == 实时同一串事件,重放出来的投影和实时跑出来的逐字段相等
  2. resume 无缝从游标 N resume + 后续新事件 == 不中断跑完的结果,不丢不重
  3. 游标单调sessionSeq 严格递增、不跳号、不重号;durable 高水位回滚后重新落库仍然 gapless。

9 非确定性的处理 · clock 与 idgen#

为什么脚本(fake)盖不住时间和 id?因为它们有两个来源,脚本只能控一个。

✓ 脚本能控

provider 吐出来的事件内容——这是 fake 自己生成的,想吐什么吐什么。

✗ 脚本碰不到

eyrie 自己的代码盖的戳/id——fake 吐一个事件进来,落库时是 eyrie 的持久化代码调 Date.now()createdAt、调 createId() 生成行 id,在 fake 边界之外

后果:任何断言落库时间戳、按时间排序、或断言生成 id 的测试都非确定性。处理:

  • clockdeps.clock?: () => number,默认 Date.now,把落库代码里的 Date.now() 全换成 deps.clock()
  • idgendeps.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」收尾。

  1. seams(接缝改造,不改行为)

    clock / idgen 注入点 + 默认装进 fixture(确定性默认开);给 startWebSocketDaemonFixture 加 fake-registry 注入口——只差这一口子就能在 CI 里驱动 turn。

  2. toolkit 地基

    createScriptedProvider + scenarios + 收编 FakeRunner/FakeRunnerManager + createStub + build* + collectEvents/waitFor;消除 FakeRunner 三处重复。

  3. 用 toolkit 重写现有手写 fake

    重写 agent-integration.test.ts 等,验证 toolkit 够用。

  4. 事件溯源不变式套件(§8.3)

    基于 AgentLogSeam + 缝 1 scripted provider,钉死 重放==实时 / resume 无缝 / 游标单调。

  5. 前端三档脚手架

    重点把档 b(ClientProvider value= + 真 WS fixture + fake registry)串通。

  6. conformance 套件(§5.4)

    从契约推不变式,Claude 先接入。

  7. 失败场景目录(§8.2)

    三层各接一遍。

  8. 真二进制 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 噪音。