没有证据的“跑通”不算跑通
一次命令返回 0 只说明某个观察面没有报错。本章把 source、确定性测试、真实本地二进制、rollout、trace、OTel 与终端投影放进同一张证据账本,也把外部服务、审批沙箱和平台跳过明确留在账本之外。
前面三十五章已经把入口、Turn、工具、会话、Thread、Realtime 和 Hook 拆开了。到这里,最容易出现一种假完成:命令返回了 0,页面也出现了文字,于是我们说“整个 Agent 跑通了”。
这句话缺少三个东西:跑通的是哪一个 claim,谁拥有这个 claim,观察结果落在哪一层。没有这三项,passed 只是一个没有上下文的布尔值。
flowchart LR
accTitle: 从源码主张到可复核证据
accDescr: 每个主张先绑定固定源码,再用确定性测试或真实本地任务产生观察物;观察物只能覆盖明确的层,剩余边界必须单独列为未证明。
CLAIM["claim: 这件事应该成立"] --> SOURCE["pinned source: owner 与 contract"]
SOURCE --> TEST["deterministic test or trace"]
TEST --> OBS["observed artifact: request / file / rollout / screen"]
OBS --> LAYER["observed layer"]
LAYER --> BOUNDARY["unproven boundary"]
SOURCE -. "不能代替运行" .-> OBS
OBS -. "不能自动扩大范围" .-> BOUNDARY
先把“跑通”写成一张账本
我用下面这张表作为本章的阅读规则。每一行都必须能回答五个问题:主张是什么,源码 owner 在哪里,本次有没有确定性测试或 trace,实际看到了什么,还有哪一段没有被覆盖。没有运行证据时必须明确写成 source-known,不能用预期测试或可用 backend 冒充本次观察。
| claim | source owner | deterministic test / trace | observed layer | unproven boundary |
|---|---|---|---|---|
-c 的 openai_base_url 真正进入 exec binary | TestCodexExecBuilder::cmd_with_server 与 CLI 的 config path | 本地 codex-exec 命名 E2E | 进程启动、Wiremock request | 真实 provider 的认证、路由和配额 |
模型能看到可调用的 exec contract | TestCodexExecBuilder 之外的 Responses Lite request 构造与 fixture assertion | 首个 captured request 检查 additional_tools | wire body 与 tool schema | 随机模型是否会选择它 |
nested ApplyPatch 的副作用与 owner | code-mode runtime 与 rollout-trace reducer | 文件 SHA-256 与 trace replay | 本地文件、reduced tool graph | approval/sandbox 真实策略 |
rollout 保存顶层 exec、JS source 与 output | exec suite 的 rollout lookup helper 与 JSONL writer | 对同一 rollout JSONL 做 substring 检查 | durable text record | structured nested ApplyPatch owner |
resume --last 续写同一个会话 | extract_conversation_id 对 SessionMeta 的读取 | 第二次命令与路径/UUID 比较 | rollout identity、append size | 跨版本迁移和用户选择其他 rollout |
| trace graph 能还原 inference、code cell、tool owner | ThreadTraceContext、RolloutTrace、replay_bundle | replay 每个 bundle 并检查 status/owner | reduced graph | trace 初始化失败时的完整性 |
| GuardianWarning 从 core 到终端每层有各自契约 | core emitter、app-server protocol、TUI target、ChatWidget | 四个独立 named tests | event queue、JSON、target、history cell | emitter-to-wire-to-render 的单次真实链路 |
| OpenTelemetry(OTel)能观察成功/失败和敏感字段策略 | SessionTelemetry | source-known;本次未运行 instrumentation test 或现场采样 | 本次无 telemetry 观察物 | telemetry 丢失不等于业务没有执行 |
这张表故意把“源码解释”和“运行观察”分开。SourceEvidence 能证明固定版本里存在某个 owner 和分支,但它不声称这条分支在本次运行被走到;测试能证明一个输入被处理,却不自动证明上游发出了这个输入。后文引用的 VT100-compatible terminal backend 也只说明仓库提供了可观察终端内容的测试能力;本次没有把它登记成已运行截图或端到端终端采样。
本节源码依据(3 处)
一条本地任务,必须把事实串起来
本章的主实验不是把十几个孤立测试排成列表,而是让一个真实编译出来的 codex-exec 连续完成一件受控工作。固定源码基线是 openai/codex 的 rust-v0.144.6,commit 为 5d1fbf26c43abc65a203928b2e31561cb039e06d。runner 先确认 tag peel 和 HEAD 都指向这个 commit,再从它创建 detached 临时 worktree;fixture 只改两个文件。每次运行都在同一个 scratch root 下另建唯一的 CARGO_TARGET_DIR,它是 source worktree 的同级目录,不会让两个实验共享旧编译产物。
这个 tag 的 workspace manifest 已是 0.144.6,锁文件里的 132 个 local package 仍是 0.0.0。runner 因而先在临时 worktree 执行 cargo update --workspace --offline,只允许 132 组本地版本替换,以及 fixture 新增的两条 dependency edge。无依赖 metadata 核对 package 数量和版本后,还要用 --locked --all-features --filter-platform <host> 解析一次完整依赖图;两层校验都通过,才进入 just test。
运行命令固定为:
: "${BLOG_ROOT:?先执行第六部导读的准备脚本,或把 BLOG_ROOT 指向博客仓库根目录}"
: "${SOURCE_ROOT:?先执行第六部导读的准备脚本,或把 SOURCE_ROOT 指向固定 Codex checkout}"
test -f "$BLOG_ROOT/package.json"
test -f "$BLOG_ROOT/scripts/verify-codex-handbook-e2e.ts"
cd "$BLOG_ROOT"
pnpm verify:handbook:e2e -- --source-dir "$SOURCE_ROOT"
fixture 的 SHA-256 是 3c8149b62ba0f2428307c6f99d12351a8766a1bc649d3442150b10ff777a662f。这不是装饰性版本号:runner 在实验开始前记录固定 source checkout 的 status、diff、untracked fingerprint 和 worktree 列表;patch 只应用到临时 worktree。finally 移除临时 worktree 后,runner 再捕获一次固定 checkout 状态并与起点比较,要求它保持原样。
任务的六个观察点
- 测试通过真实
-c路径注入openai_base_url="{wiremock}/v1",而不是在 core helper 里直接替换 provider。 - fake Responses provider 返回固定 SSE:第一条 response 是顶层
execcustom tool call,第二条是工具输出后的完成消息,第三条供resume --last使用。 - 第一份 captured request 必须同时包含用户 marker 和
additional_tools中的execcontract;contract 的format是 Lark grammar,描述里明确出现apply_patch。 exec的 JavaScript 运行时执行tools.apply_patch,写入handbook-e2e-side-effect.txt,内容固定为handbook e2e side effect\n;SHA-256 必须是a907eb01bd391443cf4b48174c04ec6155d728d3d59aa29d99f08b8559e9908e。- 测试从 sessions 目录找到包含初始 marker 的 rollout JSONL,对 prompt、顶层
execcall id、JavaScript 字面量tools.apply_patch、custom tool output 和 completed response 做子串检查;随后运行codex exec resume --last,要求同一路径、同一 conversation UUID 且文件长度增加。 - 两次运行产生的 trace bundle 都交给
codex_rollout_trace::replay_bundle。每个 bundle 的 rollout status 必须是Completed,总计三次 completed inference、一个 code cell 和一个成功的 nestedApplyPatch;tool requester 必须是code_cell:handbook-e2e-exec。
tests/fixtures/codex-handbook-e2e.patch:200 里的 rollout JSONL 校验只是 substring 子串检查:它证明顶层 exec 标识、JavaScript 中的字面量 tools.apply_patch、custom tool output 与完成标记被持久化,不能单独证明 structured nested ApplyPatch 的 owner。tests/fixtures/codex-handbook-e2e.patch:254 才对每个 bundle 做 trace replay,用 ToolCallKind::ApplyPatch、执行状态和 ToolCallRequester::CodeCell 证明 nested ApplyPatch 的 requester。
这里有一个值得单独记下的名称差异:模型看到的是顶层 exec,apply_patch 是 code-mode JavaScript 里嵌套的 runtime tool。把它们写成“模型直接调用 apply_patch”,会把模型可见 owner 和运行时 owner 合并掉,trace 的 requester assertion 也就失去意义。
本节源码依据(4 处)
实验结果:通过的是一条受控证据链
runner 通过 just test 得到的 nextest 输出是:
PASS [ 0.42s] suite::resume::handbook_full_task_config_request_patch_rollout_resume_trace
Summary: 1 tests run: 1 passed, 68 skipped
runner 从测试输出中的唯一 HANDBOOK_E2E_SUMMARY 解析出下面的结果:
| 观察项 | 结果 | 它确实说明了什么 |
|---|---|---|
| Responses request count | 3 | 初始请求、tool follow-up、resume 请求都被 fake provider 收到 |
| changed file | handbook-e2e-side-effect.txt | 本地工作目录发生了预期文件副作用 |
| side-effect SHA-256 | a907eb01bd391443cf4b48174c04ec6155d728d3d59aa29d99f08b8559e9908e | 副作用内容是固定字节,而不是只检查文件存在 |
| trace bundle count | 2 | 初始运行和 resume 各留下一个可 replay bundle |
| completed inferences | 3 | reducer 图里三次 inference 都收到了 Completed 状态 |
| code cell count | 1 | 所有 bundle 合并后只有 code_cell:handbook-e2e-exec |
| completed nested apply_patch | 1 | 一个 ApplyPatch 调用成功,且 requester 是上述 code cell |
| resume same rollout | true | 第二次 prompt 追加到同一 rollout identity |
| rollout identity | 本次运行动态生成的 UUID | runner 只验证 UUID 格式,以及 resume 前后仍是同一个 identity |
trace event classes 至少包括 rollout_started、inference_started、inference_completed、code_cell_started、code_cell_initial_response、code_cell_ended、tool_call_started、tool_call_ended 和 rollout_ended。事件类名可以帮助定位缺哪一层,但它们本身不等于 payload 已经被正确关联,所以 runner 还要 replay graph 并检查 code-cell、tool requester、execution status 和 raw payload references。
这条链的强度来自交叉对账:request 里的 marker 要能在 rollout 找到,rollout 里的 tool call 要能在 trace graph 找到,文件 hash 要与 tool result 相符,resume 的 prompt 要落在同一 SessionMeta identity 下。任何一项孤立地通过,都不能推出其余项。
本节源码依据(4 处)
四个 GuardianWarning 绿灯,为什么仍然不是 E2E
第 31 章已经用 GuardianWarning 说明了 TUI 的 thread routing。这里把同一事件横着再看一遍,是为了给“相邻测试不能自动拼成端到端”一个具体例子。
固定 commit 导出的测试副本中有四个都能独立通过的测试:
| 层 | named test | 测试主动构造了什么 | 绿灯只覆盖到哪里 |
|---|---|---|---|
| core emitter | guardian_review_surfaces_responses_api_errors_in_rejection_reason | mock Responses API 返回 400,再读取 Session event queue | GuardianWarning 包含底层 API error;不证明 app-server 接收或投影 |
| app-server wire | verify_guardian_warning_notification_serialization | 直接构造 ServerNotification::GuardianWarning | JSON-RPC method、threadId、message 的序列化;不触发 core emitter |
| TUI routing | guardian_warning_notifications_route_to_threads | 直接构造带 ThreadId 的 notification | target classifier 返回对应 Thread;不证明 app-server 发过这条消息 |
| TUI render | live_app_server_guardian_warning_notification_renders_message | 手工把 notification 送进 ChatWidget | history cell 显示文字;不证明 routing、wire 或 core 发生 |
四条命令可以这样运行:
: "${ARCHIVE_CODEX_RS:?先执行第六部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-core guardian_review_surfaces_responses_api_errors_in_rejection_reason
just test --locked -p codex-app-server verify_guardian_warning_notification_serialization
just test --locked -p codex-tui guardian_warning_notifications_route_to_threads
just test --locked -p codex-tui live_app_server_guardian_warning_notification_renders_message
固定 commit 导出的测试副本的实际结果是四条命令各执行 1 test、各 1 passed。nextest 同时报告 core 2968 skipped、app-server 946 skipped、两次 TUI 各 2991 skipped;这些数字只是被名称过滤掉的 suite inventory,不是额外通过的业务合同,也不能填补四层之间的交接。
它们分别是四种 layer test,不是一个共享 fixture 的 emitter-to-wire-to-render 旅程。要把它们升级成 E2E,必须让 core 真的发出 event,让 app-server 的 listener 真的选择 projection,让客户端真的收到 JSON,并在正确 Thread 的 widget 中渲染;当前四个测试没有共同的运行实例,也没有共同的 event id 可对账。
本节源码依据(4 处)
本节源码依据(4 处)
这四层之间还有一个容易漏掉的 replay 边界:TUI 的 event_is_notice 把 GuardianWarning 识别为可显示 notice,但这只是 buffered UI event 的筛选规则;它不从 rollout JSONL 重新生成事件,也不保证被淘汰的 buffer 还能恢复。UI replay 和 rollout replay 是两套 owner。
本节源码依据(2 处)
证据强度:不是一条自动升级的直线
不同证据不是从“弱”线性升级到“强”。它们回答的问题不同:unit test 适合验证折叠规则,wire fixture 适合验证字段名,snapshot 适合验证 projection,local binary 适合验证本机跨模块路径;真实外部服务、OTel、trace 和截图则各自增加另一种观察面。
| 证据面 | 能回答 | 不能回答 |
|---|---|---|
source / SourceEvidence | 固定版本里谁拥有状态、分支和 contract | 本次运行是否走到该分支 |
| unit test | 一个函数或 reducer 在给定输入下的局部语义 | 上游是否真的调用它 |
| wire fixture | JSON-RPC method、字段名、枚举和 schema | listener 是否选择了这条消息 |
| snapshot / VT100 | 当前 projection 产生了哪些 cell 或终端文字 | emitter、wire、工具副作用 |
| local binary | 本机真实 CLI、配置、进程、文件与持久化能否闭合 | 外部账号、服务端行为、所有平台 |
| real external service | 认证、网络、服务端路由与真实响应 | 随机模型输出的稳定性和可重复性 |
| OTel | span/log/metric 是否记录成功、失败、token 和脱敏后的 prompt | 业务副作用一定发生,或 telemetry 一定送达 |
| rollout trace | 事件能否严格还原成带 owner 的 reduced graph | 没有 trace 时业务一定没发生 |
| screenshot | 某一 viewport 的最终视觉投影 | 隐藏状态、请求体、磁盘内容和跨平台行为 |
| skipped test | 当前环境明确没有执行某个分支 | 该分支通过了 |
“Skipped” 要单独放在账本里。它不是失败,也不是通过;它是一个待补证据。把 skip_if_no_network!、skip_if_sandbox! 或 skip_if_wine_exec! 后面的 test count 当作 pass,会把环境前提误写成业务结论。
本节源码依据(3 处)
OpenTelemetry(OTel)与 rollout trace:两个观察面,不是一份日志
OTel 记录“发生过什么类型的观测”
SessionTelemetry 的 metadata 包含 conversation id、auth mode、originator、session source、model、terminal type 和是否记录用户 prompt。Responses event 会把 function call 的 tool name、completed token usage 和 SSE 成功/失败分别写入 instrumentation;prompt 默认可以只记录长度和计数,具体文本由 log_user_prompts 控制,关闭时写成 [REDACTED]。
这对排查“服务端返回了什么、某次 SSE 是否失败”很有用,但 OTel 仍是旁观者。一个 codex.sse_event success=true 不拥有文件写入;一个 telemetry exporter 失败也不代表 Turn 没有完成。业务 owner 仍然是 core/session、tool runtime 或 rollout writer。
本节源码依据(5 处)
rollout trace 记录“能否被严格还原”
ThreadTraceContext::start_root_or_disabled 从 CODEX_ROLLOUT_TRACE_ROOT 启动 bundle,且 trace 初始化失败时只记录 warning 并禁用 trace;这说明 trace 是诊断证据,不是 session 可用性的前置条件。启用后,context 为 code cell、tool dispatch 和 inference attempt 分别创建 handle。reducer 读取 manifest 和 raw event log,遇到不符合 owner 顺序的事件就失败,而不是用当前 active thread 猜一个归属。
本实验因此同时检查两件事:trace bundle 存在,以及 replay 后的 graph 关系正确。只 ls trace.jsonl 不能证明 ApplyPatch 属于 code cell,也不能证明 inference status 是 Completed。
本节源码依据(3 处)
这次 E2E 明确没有覆盖什么
把边界写出来,结论才不会膨胀成宣传语。
| 未证明边界 | 原因 | 下次需要的证据 |
|---|---|---|
| 真实 OpenAI Responses API | 测试把 base URL 指向本地 Wiremock,SSE 序列完全固定 | 带隔离账号、配额和网络审计的外部服务实验 |
| 随机模型的规划与工具选择 | fake provider 直接返回 exec call,不让模型自行决定 | 版本化 prompt/response 采样和人工审阅;不能拿随机输出当稳定 contract |
| approval 与 sandbox | 命令使用 --dangerously-bypass-approvals-and-sandbox,只为闭合受控副作用 | 独立 approval UI、Permission Profile、sandbox policy 的 integration/E2E |
| TUI、Realtime、Hooks、多 Agent | fixture 只穿过 exec、code cell、apply_patch、rollout、resume、trace | 各自 owner 的跨层任务,并为事件/Thread/transport 建立共同 correlation id |
| 发布产物 | 运行的是本机 checkout 编译的 codex-exec | npm、Homebrew、桌面包和目标平台的 artifact verification |
| Windows、Wine 和无网络环境 | 某些 upstream test 在这些条件下显式 skip | 在对应 runner 上真正执行,而不是把 skip 计作 pass |
| Trace 可用性在故障时的保证 | trace 初始化是 best-effort;失败会禁用诊断而不阻止 session | 专门测试磁盘满、权限错误和 partial bundle 的可观测性 contract |
固定源码里还有一个很直观的 skipped-test 例子:Guardian 的 Responses API 错误测试先调用 skip_if_no_network!,真实 host-native denial suite 又同时检查 sandbox 与 Wine 前提。它们说明测试作者知道外部条件是边界;它们没有说明边界已经被本地运行覆盖。
本节源码依据(1 处)
读完整本书时,最后只保留这条纪律
读源码时先写 claim,再找 owner;跑测试时先看实际执行了几个 test,再看 passed;看日志时把 request、side effect、rollout、trace 和 UI projection 分开;遇到 skip 就把它标成缺口;遇到 fake provider、bypass flag 或平台限制,就在结论里原样保留。
这本小册最后留下的不是“Codex 已经被完全证明”这句话,而是一套可以继续复用的账本格式:
claim
-> pinned source owner
-> named deterministic test / trace
-> observed artifact
-> observed layer
-> unproven boundary
第 31 至 35 章的 TUI、Thread、MultiAgentV2、Realtime 和 Hooks,已经分别给出了自己的状态与生命周期;本章把它们放回证据边界,不把它们拼成一条不存在的线性流水线。到这里全书结束。后续如果源码版本变化,应该新建一次带新 commit、新 fixture 和新实验记录的账本,而不是把这次本地通过结果延伸成永久保证。