第六部:Agent runtime 怎样被承载与验证
这一部从 app-server 事件进入 TUI,沿独立 Thread 与 MultiAgentV2 追踪协作,再分别检查 Realtime、Hooks 和证据系统,说明 Agent runtime 的关键承载面,以及每个结论实际被验证到哪一层。

展开阅读路线与实验入口
读这一部之前,需要知道什么
第五部留下的不是一段“聊天记录”,而是一条有身份的 Thread。它在运行时有 live History,在磁盘上有 append-only rollout,在 SQLite / ThreadStore 里还有用于查找和列表的投影;resume、fork、Memory 与 Goal 又各自补上不同的连续性。app-server 能把这些状态变成协议对象,但它不是这些状态的最终 owner。
第六部从这条边界继续。现在交到手里的材料应该至少包含 ThreadId、当前 session/turn/item 状态和一条带 thread identity 的 typed notification。TUI 收到通知后,先判断它属于主 Thread、某个 side Thread、app scope 还是 global scope,再决定进入哪个 buffer 和哪个 ChatWidget。所以“终端里出现了一行字”只能证明最后一个投影结果,不能反推 Core 一定发过正确事件,更不能反推 rollout 已经写入。
固定版本的 TUI 会在 ChatWidget 之前完成 thread targeting。thread-scoped notification 进入主 Thread 或 side Thread 的队列;非法 thread_id 被忽略,app-scoped MCP startup notification 也不会落进当前对话。只有 global notification 才直接交给当前 widget。
ThreadEventStore 保存 session snapshot、turns、bounded buffer、pending interactive replay、active turn 和 composer input state。这是一份 UI ownership,不是第五部 rollout 的另一种叫法。切换 Thread 时发生的是有容量上限、有过滤规则的界面重放。
这一部负责讲清什么
前五章处理 runtime 的承载与协作面,第 36 章单独处理验证面。它们会互相产生事件、交接 identity 或留下观察结果,但没有一个总状态机能替代各自的 owner。
| 章节 | 主要 owner / 承载面 | 本章固定的问题 | 不能顺手外推成什么 |
|---|---|---|---|
| 第 31 章 TUI | ServerNotificationThreadTarget、ThreadEventStore | 一个 typed notification 怎样进入正确 Thread,并经 buffer/replay 渲染 | 当前 ChatWidget 是所有事件的 global sink;TUI replay 是 rollout replay |
| 第 32 章 Subagent | AgentControl、AgentRegistry、AgentGraphStore | spawn 怎样得到独立 ThreadId、AgentPath、history 与 parent edge | 每个 Thread 都是 Agent;每个 Agent 都是一个 OS 进程 |
| 第 33 章 协作 | MultiAgentV2 handlers、InputQueue、live agent tree | message、follow-up、wait、interrupt 与 subtree shutdown 怎样分责 | wait_agent 是 join;interrupt 会删除 durable graph |
| 第 34 章 Realtime | RealtimeConversationManager、ConversationState | audio/text queue、start/close 与 handoff 怎样形成独立会话 | 普通 Responses WebSocket 就是 Realtime |
| 第 35 章 Hooks | hook discovery、dispatcher 与十种 event-specific parser | scope、matcher、改写、阻断、additional context 与错误策略怎样变化 | 十种 Hook 按固定顺序穿过同一条 callback pipeline |
| 第 36 章 Evidence | exact tests、wire fixtures、OpenTelemetry(OTel)、rollout trace、VT100-compatible terminal backend | 一条 claim 到底被哪层证据证明,还剩什么没有证明 | 多个相邻绿色测试自动组成 E2E;截图或 telemetry 能证明因果 |
flowchart TB
accTitle: Agent runtime 的承载、协作与证据边界
accDescr: app-server 与 TUI、parent/child Threads、MultiAgentV2、Realtime、Hooks 和 evidence sinks 各有独立状态与生命周期,它们共同承载 Agent,但不构成一条单线流水线
subgraph UI["UI projection"]
APP["app-server\nthread-scoped notification"]
TUI["TUI\nThreadEventStore / ChatWidget"]
APP -->|"typed notification"| TUI
end
subgraph AGENTS["Agent graph and activity"]
PARENT["parent Thread"]
CHILD["child Thread"]
MULTI["MultiAgentV2\nmailbox / steer / shutdown"]
PARENT -->|"thread-spawn edge"| CHILD
MULTI -->|"queue / trigger / activity"| PARENT
MULTI -->|"queue / trigger / activity"| CHILD
PARENT -.->|"communication"| MULTI
CHILD -.->|"communication"| MULTI
end
subgraph REALTIME_STATE["Separate Realtime state"]
REALTIME["RealtimeConversationManager\nConversationState / audio-text queues"]
end
HOOKS["Hooks\nten lifecycle contracts"]
subgraph EVIDENCE["Evidence sinks"]
WIRE["wire fixture / exact test"]
TRACE["rollout-trace replay"]
OTEL["OTel SessionTelemetry"]
SCREEN["VT100 / terminal capture"]
end
PARENT -.->|"Core event"| APP
CHILD -.->|"Core event"| APP
REALTIME -.->|"handoff / text routing -> Op::UserInput"| PARENT
HOOKS -.->|"session / turn / tool / compact / stop"| PARENT
HOOKS -.->|"subagent lifecycle"| CHILD
APP -.-> WIRE
TUI -.-> SCREEN
PARENT -.-> TRACE
CHILD -.-> TRACE
PARENT -.-> OTEL
REALTIME -.-> OTEL
HOOKS -.-> WIRE
图里唯一明确的 UI 实线是 app-server -> TUI。parent/child 实线表示 spawn topology,不表示 parent 要同步等待 child;MultiAgentV2 的双向活动也没有被画成 join。Realtime 位于单独的状态框里,只在源码已经定义的 handoff/text-routing 边界与普通 Thread 接触;steer 留在 MultiAgentV2 的 mailbox 语义里。Hooks 用虚线横切若干生命周期,但不会因此拥有 Thread、Realtime 或 TUI 状态。
最下面四个 evidence sink 也不是 runtime 的下一阶段。wire fixture、trace、telemetry 和终端投影从不同位置观察已经发生的行为;任何一条线都只能支持与它的采样位置相称的 claim。
六章共用哪套实验
第 31 至 35 章先按各自机制积累局部证据:TUI 使用相邻的路由与渲染测试,Subagent 保留身份表,MultiAgentV2 保留活动时间线,Realtime 区分两套 session,Hooks 使用逐事件矩阵。第 36 章才把这些局部证据统一收进证据账本(evidence ledger)。它先用 GuardianWarning 做最小样本,围绕同一事件类型和字段合同登记 Core emitter、app-server JSON-RPC serialization、TUI thread target、buffer/replay 规则和最后的 history cell。各项证据来自相邻但独立的 fixture,不能假装它们共享同一个运行时 thread_id 或同一条 message 实例。统一账本的格式是:
claim -> owner -> source range -> exact test or fixture -> observed sink -> unproven boundary
GuardianWarning 只在这里充当账本样例,不在导言重复做完整审计:Core emitter、app-server serialization、TUI target 和 ChatWidget render 各有独立 fixture,没有共享 event id 或 message 实例。四层测试、源码范围和中间缺失的 E2E 边界都留给第 36 章逐项对账。
第 32、33 章提供的是第 36 章随后要归档的 parent/child Thread 证据:parent identity、child ThreadId、AgentPath、history mode、persisted edge 和 mailbox activity。这里最容易写错的是 wait_agent。它订阅 InputQueueActivity,结果只有 mailbox、steer 或 timeout;child completion 只是可能进入 mailbox 的一类通信。
第 34 章保留普通 Responses 与 Realtime 两份记录。普通路径里,ModelClientSession 每 Turn 新建,只让本 Turn 持有新的 turn_state;它会从 session-scoped ModelClient 取走缓存的 WebsocketSession,销毁时再归还。因此,未关闭的连接和上一份 request/response 状态可以跨 Turn 借还,sticky token 不会跨 Turn。Realtime manager 另持有 audio/text channels、handoff state、tasks、active flag 和 cancellation token。两套状态只在源码明确的 handoff/text-routing 处关联,不能因为都使用 WebSocket 就合成同一个 session。
第 35 章先用十行 Hook matrix 分别登记 scope、matcher、payload、parser、mutation、block、additional context 和 error behavior。第 36 章再第一次把前五章的局部记录汇总成 evidence ledger,并尝试一个更强的本地任务:真实 codex exec binary 对 fake Responses provider 发请求,经顶层 exec code cell 调用嵌套的 tools.apply_patch,再检查文件副作用、rollout、resume --last 与 rollout-trace。
导读只规定这项实验必须交出的证据:固定 source 与 fixture identity,确认命名测试确实执行,区分 rollout JSONL 的文本观察和 trace replay 的结构化归因,并在结束后核对权威 checkout 没被测试污染。运行结果、请求与 trace 计数、内容 hash,以及这些记录究竟能证明到哪一步,都留在第 36 章现场核对。这里不先给出答案。
哪些机制暂时不讲
第六部已经是全书最后一部。“暂时不讲”不再表示后面还有一章会补完,而是明确列出仓库和本地 fixture 到不了的边界。
- 不用随机 live-model 输出做稳定合同。fake Responses server 可以固定 request、tool call 和 completed response,不能证明生产服务的可用性、配额、路由或模型质量。
- 不把 loopback Realtime 当成真实音频设备测试。固定版本的 WebRTC native 实现只在 macOS 编译;fake WebSocket round trip 也没有覆盖麦克风、扬声器、系统权限和真实网络抖动。
- 不把 Hook parser 通过当成外部脚本可靠。真实 command handler 仍受文件权限、shell、timeout、信任来源和脚本自身副作用影响;
PostToolUse看到的是已发生的工具结果,不能自动回滚副作用。 - 不把 persisted agent graph 当成 live process table。ephemeral child 可以保持 live 却不写 durable edge;interrupt、close 和 subtree shutdown 也有不同的状态与清理范围。
- 不把 OTel、rollout-trace 或截图当成彼此替代品。telemetry 可以关闭且默认隐藏 prompt;trace 只有在 producer 写出足够事件时才可 replay;VT100 和真实终端截图都只观察 projection。
- 不声称本册覆盖所有 client、shell、终端、操作系统和外部 provider 组合。平台 skip、
running 0 tests、fixture 未构建和默认线程栈溢出都必须在实验记录里单独标出,不能算绿色证据。
WebRTC 的平台边界在 crate 入口处写得很直接:native module 只为 macOS 编译,其他平台调用 start 或 apply answer 都返回 UnsupportedPlatform。
读完这一部,你应该能做什么
给你一条“子 Agent 已经完成,但主界面没有反应”的报告,你应该能先问清是哪一种反应:child 是否发出 completion communication,parent mailbox 是否收到 activity,wait_agent 是否因 mailbox/steer/timeout 返回,app-server notification 是否带对 thread_id,TUI 是否路由到当前 Thread,还是消息只存在于另一个 side Thread 的 buffer。排查不再从截图猜调用链。
面对 Realtime 问题,你能先判断是普通 Responses transport reuse、Realtime conversation queue、WebRTC call、handoff 还是 app-server projection,再选择对应的 exact test。面对 Hook 问题,你能从十种 event-specific contract 中找到当前 parser 和 block/mutation 能力,而不是在一个想象的 middleware stack 里找顺序。
更重要的是,你应该能为一个完整本地 Codex 任务建立证据账本:
- 固定 source identity 和实验输入;
- 捕获真实配置入口与模型请求;
- 观察工具调用和文件副作用;
- 在 rollout 中找到 prompt、tool call、tool output 与 completed response;
- 用相同 rollout identity 验证 resume append;
- replay trace,确认 inference 与 tool-dispatch 的可归因关系;
- 对每条 claim 写下尚未证明的外部边界。
这就是本部最后要建立的“分层系统验证能力”:不是收集尽可能多的绿色命令,而是知道某个行为由谁承载、证据在哪一层生成、哪一层仍然是空白。第 36 章会以这份账本收束全书,不再往后交给一个更大的抽象。
源码工作台
核心目录与 owning types
| 目录 / 文件 | 先抓住的 owner / type | 只用它回答什么 |
|---|---|---|
codex-rs/app-server、app-server-protocol | BespokeEventHandler、OutgoingMessage、typed notifications | Core event 怎样变成带 Thread identity 的 JSON-RPC request / notification |
codex-rs/tui | ServerNotificationThreadTarget、ThreadEventStore、ChatWidget | 通知先落到哪个 Thread,哪些状态可 buffer/replay,最后怎样形成终端 projection |
codex-rs/core/src/agent | AgentControl、AgentRegistry、AgentMetadata | live Agent 怎样取得 slot、identity、path、Thread,并发送 completion / shutdown |
codex-rs/agent-graph-store | AgentGraphStore、ThreadSpawnEdgeStatus | parent/child topology 怎样持久化、查询和关闭 |
codex-rs/core/src/tools/handlers/multi_agents_v2 | MessageDeliveryMode、WaitOutcome、tool handlers | queue-only、trigger-turn、mailbox/steer/timeout 与 interrupt 怎样分责 |
codex-rs/core/src/realtime*、codex-rs/realtime-webrtc | RealtimeConversationManager、ConversationState | Realtime 的独立 queue、task、handoff、stop 与 WebRTC 平台边界 |
codex-rs/hooks | HookEventName、discovery、dispatcher、event-specific parsers | 十种 Hook 各自能匹配、改写、阻断或追加什么 |
codex-rs/otel | SessionTelemetry、SessionTelemetryMetadata | metrics / logs / traces 能观察哪些 metadata,以及禁用与脱敏时会缺什么 |
codex-rs/rollout-trace | ThreadTraceContext、TraceWriter、RolloutTrace、replay_bundle | raw events 怎样成为可重放 graph,identity 与 interaction edge 怎样校验 |
codex-rs/core/tests/common、app-server/tests/common、TUI test backend | TestCodex、TestCodexExecBuilder、TestAppServer、VT100Backend | in-process Core、真实本地 binary、协议子进程和终端 projection 分别怎样取证 |
AgentRegistry 是 live session 共享的容量与 metadata owner;AgentGraphStore 是持久 parent/child topology 的 storage-neutral boundary。把两者拆开,才能解释“ephemeral child 仍然活着,但 durable child list 为空”。
MultiAgentV2 的 message tools 共用 submission path,差别只在 trigger_turn。send_message 使用 QueueOnly,followup_task 使用 TriggerTurn;两者都不是“同步执行对方并返回结果”。
Hooks 的公共 enum 恰好有十个 variant,但公共名称不等于公共 output contract。PreToolUse、PreCompact 和 Stop 的三个命名测试已经给出三个不同例子:deny blocks tool processing、continue:false stops before compaction、block reason becomes a continuation prompt。
OTel 与 rollout-trace 也不能合并。SessionTelemetry 允许 metrics exporter 缺席,user prompt 默认可被替换成 [REDACTED];RolloutTrace 则保存 thread、turn、conversation item、inference、tool、terminal、compaction、interaction edge 与 raw payload identity,交给严格 reducer 重放。
第 31 至 35 章选取的命名测试与证据范围
下面只列第 31 至 35 章使用的局部测试。它是一张索引,不代表下面的命令会一次执行所有条目;Guardian 四层审计、rollout-trace 和本册 local E2E 在第 36 章单独列出和运行。证明范围 比“测试通过”更重要;同名 filter 如果显示 running 0 tests,或 test body 因 gate 提前返回,都没有提供表中证据。
| 证据面 | exact test | 证明范围 |
|---|---|---|
| TUI routing | guardian_warning_notifications_route_to_threads | 有效 thread_id 被解析成指定 Thread target |
| TUI rendering | live_app_server_guardian_warning_notification_renders_message | 直接注入的 notification 形成一条 warning history cell |
| Agent spawn | spawn_agent_creates_thread_and_sends_prompt | spawn 注册独立 Thread,并向它提交初始 prompt |
| Fork history | spawn_agent_fork_flushes_parent_rollout_before_loading_history | full-history fork 在加载 child history 前先 flush parent rollout |
| Ephemeral graph | ephemeral_spawn_does_not_persist_agent_graph_edge | ephemeral child 可保持 live,同时没有 persisted spawn edge |
| Follow-up | multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn | 该 fixture 的两轮 trigger-turn completion 均成功进入 parent mailbox |
| Wait | multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity | mailbox activity 会唤醒 wait 并返回 summary,不证明 child joined |
| Tree shutdown | shutdown_agent_tree_closes_live_descendants | 显式 tree shutdown 会关闭 live child 与 grandchild |
| Responses WS | responses_websocket_v2_incremental_requests_are_reused_across_turns | 普通 Responses transport 在受控 reconstructed history 下复用 incremental request |
| Realtime | conversation_start_audio_text_close_round_trip | fake WebSocket 上 start、audio、text、event 与 close 的一次往返 |
| PreToolUse Hook | permission_decision_deny_blocks_processing | deny decision 变成 blocked status 与 feedback |
| PreCompact Hook | continue_false_stops_before_compaction | continue:false 在 compaction 前停止,并保留 reason |
| Stop Hook | block_decision_with_reason_sets_continuation_prompt | block reason 形成 continuation fragment |
spawn、fork 与 ephemeral 三条测试把 live Thread 和 durable graph 分开写进断言。尤其 ephemeral case 明确检查 persisted children 为空,同时 get_thread(child_thread_id) 仍成功。
Realtime 的两条测试都使用本机 fake WebSocket server,而且 test body 先调用 skip_if_no_network!。看到 Cargo 报 ok 还不够,必须确认 server handshake、response.create、session update、audio append 和 close 断言真正执行。
三条 Hook exact test 不能互相替代,因为各自 parser 的输出结构和允许动作不同。
实验准备与当前校准
固定 checkout 只负责 source identity。Rust 测试应在 disposable archive 或 detached worktree 中运行,使用外部 CARGO_TARGET_DIR,并记录固定 checkout 测试前后的 git status --porcelain 完全相同。这个 tag 的 workspace version 与 lockfile 需要在副本里离线校准;不要为了跑测试直接修改权威 checkout,也不要给 fake server 配真实凭据。
第六部使用自己的独立 bash 或 zsh,并要求 shell 支持 pipefail;不要沿用前五部的 ARCHIVE_DIR 或覆盖它们的 EXIT trap。请从博客仓库根目录运行下面的准备脚本,或预先把 BLOG_ROOT 指向该目录;脚本会在切入 Codex archive 前保存这个位置。第 31 至 35 章的局部 Rust 测试使用 archive,第 36 章的 handbook E2E runner 则显式切回 $BLOG_ROOT,再通过 --source-dir 创建 detached worktree。
set -euo pipefail
export BLOG_ROOT="${BLOG_ROOT:-$PWD}"
export SOURCE_ROOT="${CODEX_SOURCE_DIR:-/tmp/codex-handbook-final-rust-v0.144.6}"
export COMMIT=5d1fbf26c43abc65a203928b2e31561cb039e06d
export ARCHIVE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-part6.XXXXXX")"
export ARCHIVE_CODEX_RS="$ARCHIVE_DIR/codex-rs"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
trap 'rm -rf "$ARCHIVE_DIR"' EXIT
test -f "$BLOG_ROOT/package.json"
test -f "$BLOG_ROOT/scripts/verify-codex-handbook-e2e.ts"
test "$(git -C "$SOURCE_ROOT" rev-parse HEAD)" = "$COMMIT"
test "$(git -C "$SOURCE_ROOT" describe --tags --exact-match HEAD)" = rust-v0.144.6
test -z "$(git -C "$SOURCE_ROOT" status --porcelain)"
git -C "$SOURCE_ROOT" archive "$COMMIT" | tar -x -C "$ARCHIVE_DIR"
cd "$ARCHIVE_CODEX_RS"
cargo update --workspace --offline
cargo metadata --locked --format-version 1 --no-deps >/dev/null
命令按证据面分组,而不是拼成一条“全系统测试”:
第 31 至 35 章选取的命名测试命令
: "${ARCHIVE_CODEX_RS:?先执行本部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-tui -E \
'test(guardian_warning_notifications_route_to_threads) | test(live_app_server_guardian_warning_notification_renders_message)'
just test --locked -p codex-core spawn_agent_creates_thread_and_sends_prompt
just test --locked -p codex-core spawn_agent_fork_flushes_parent_rollout_before_loading_history
just test --locked -p codex-core ephemeral_spawn_does_not_persist_agent_graph_edge
just test --locked -p codex-core -E \
'test(multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn) | test(multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity) | test(shutdown_agent_tree_closes_live_descendants)'
if [[ -n "${CODEX_SANDBOX_NETWORK_DISABLED+x}" ]]; then
printf '%s\n' '拒绝运行:网络禁用标记会让 Realtime 测试提前返回' >&2
exit 1
fi
just test --locked -p codex-core -E \
'test(responses_websocket_v2_incremental_requests_are_reused_across_turns) | test(conversation_start_audio_text_close_round_trip)' \
--no-capture
just test --locked -p codex-hooks -E \
'test(permission_decision_deny_blocks_processing) | test(continue_false_stops_before_compaction) | test(block_decision_with_reason_sets_continuation_prompt)'
source_status="$(git -C "$SOURCE_ROOT" status --porcelain)"
test -z "$source_status"2026-07-22 的本机校准结果如下:TUI 两条 exact test 各实际运行并通过;三条 Agent spawn/graph test 由项目 just test recipe 设置 8 MiB worker stack 后通过;MultiAgentV2 三条和 Hooks 三条也都由 just 实际运行并通过。Realtime 两条在运行前确认 sandbox network-disable marker 不存在,随后跑过 fake loopback;日志确认了真实 handshake / message activity,不是 test-level skip。第 36 章另行记录 Guardian、rollout-trace 和 local E2E,不把它们算进这段分章校准。
第 36 章的本地任务不并入这段分章校准。它需要单独核对命名测试、summary、rollout、trace 和 checkout 清理,再报告自己的结果;不能从上面这些相邻测试已经通过,提前推出完整任务也通过。
裸 cargo test 的默认线程栈下,部分 Codex Core exact test 会在 harness 中 stack overflow。项目 just test recipe 会设置 RUST_MIN_STACK=8388608 再调用 nextest;使用这个标准入口只是在本机排除测试环境噪声,不是业务修复,也不能省略测试名、running N tests、pass count 与 skip 日志。独立 CARGO_TARGET_DIR 仍可能因为随机临时 worktree 路径触发较大范围的 Rust 重编译;编译耗时不是 runtime 行为证据。
Feature 成熟度
| 能力 | 固定版本 catalog | 导读采用的解释边界 |
|---|---|---|
CodexHooks | Stable,默认开启 | catalog 只说明 feature gate;每种 Hook 的支持 handler 与 parser 仍要查源码 |
Collab | Stable,默认开启 | 不能拿它替代本部作为主线的 MultiAgentV2 contract |
MultiAgentV2 | UnderDevelopment,默认关闭 | 实验必须显式启用;V1 只留兼容说明,不能把 V2 写成稳定公共协议 |
RealtimeConversation | UnderDevelopment,默认关闭 | loopback 测试通过也不改变成熟度,app-server 入口仍属于 experimental API |
TuiAppServer | Removed compatibility flag,默认值仍为 true | Removed flag 不等于当前 TUI 不走 app-server;行为要回到实际调用路径证明 |
ResponsesWebsocketsV2 | Removed compatibility flag,默认关闭 | 普通 Responses WS transport 仍可由 provider capability 选择;flag stage 不是 transport state |
固定版本把 CodexHooks 标成 Stable/default-on,把 MultiAgentV2 与 RealtimeConversation 标成 UnderDevelopment/default-off。两个后者即使在测试里完整走通,也不能写成稳定、默认可用的对外合同。
平台、fixture 与观察限制
| 证据面 | 固定版本或本机限制 | 不能外推的结论 |
|---|---|---|
| TUI | bounded buffer、replay filter、VT100 固定尺寸和合成输入 | render test 证明 emitter、wire、真实字体/颜色和所有终端都正确 |
| Agent graph | live registry 与 persisted edge 分离;ephemeral 不持久化 | durable graph 是完整 live process table |
| MultiAgentV2 | UnderDevelopment;Core harness 在默认栈可能 overflow | 增大栈证明生产并发没有资源问题;wait_agent 等于 join |
| Realtime WS | loopback 仍受 skip_if_no_network! 门禁;fake server 没有真实设备 | test ok 一定执行过;一次 round trip 代表生产音频会话 |
| WebRTC | native crate 只支持 macOS;architecture=avas 是固定版本 WebRTC/v1 conversational call 的内部 query 标记,还要求 realtime v1 + conversational mode | 非 macOS 已覆盖;WebSocket 通过就证明 WebRTC 通过 |
| Hooks | 三条锚点主要校准 event-specific parser;真实 command 受外部脚本和 OS 影响 | 一个 Hook 的 block/mutation 能力适用于另外九种;PostToolUse 可回滚 |
| OTel | exporter 可以关闭,metrics snapshot 可缺失,prompt 默认脱敏 | telemetry 缺席表示行为没发生;telemetry 顺序可用于严格因果 replay |
| rollout-trace | 依赖 producer 写出 raw events;reducer 对 identity/content mismatch 严格报错 | trace 是完整机器状态、外部服务真值或 UI 真值 |
| local E2E | fake provider、临时 home/detached worktree、受控 patch;显式绕过 approval/sandbox | 本地 binary 通过就证明真实 OpenAI service、账号、生产网络、随机模型输出或安全隔离 |
工作台到这里停止。先从第 31 章:TUI 如何把事件流还原成一个稳定界面检查一条 thread-scoped notification;读到第 36 章:没有证据的“跑通”不算跑通时,再用 evidence ledger 审核全书最后那条完整本地任务。
TUI 如何把事件流还原成一个稳定界面
沿固定版本的 app-server event target、每 Thread 有界 store/channel、thread switch snapshot、pending interaction filter 与 ChatWidget replay,拆开一条 thread-scoped 通知怎样变成可切换且不误重放副作用的终端界面。
这里的 primary Thread 与 side Thread 只是 TUI 的路由分类:它们决定 notification 进哪个 store、channel 和 widget,不把普通界面 Thread 自动提升为 Agent。只有第 32 章出现的 subagent spawn 路径才建立独立 Agent Thread。
第 30 章留下一个协议事实:GuardianWarning notification 携带 threadId = T。事件进入 TUI 后,不能直接调用当前 ChatWidget::on_warning。用户可能正在看主 Thread,而 T 是后台子 Agent;也可能刚刚切换,旧 Thread 的 receiver 还在交接;这条 warning 还可能与未完成的 approval 一起进入重放。
固定版本把问题拆成三层:先提取 thread target,再写每 Thread store/channel,最后由 active ChatWidget 处理 live event 或带 marker 的 replay。真正要稳定的不是一行文字,而是“哪一份界面状态有权消费它”。
flowchart LR
accTitle: TUI 从 app-server event 到稳定界面的投影链
accDescr: event 先按 thread identity 分类,进入对应有界 store;只有 active Thread 同时收到 channel delivery。切换时读取 snapshot,用 ReplayKind 重放 turns 和仍有效的 buffered events。
STREAM["AppServerEvent stream"] --> TARGET["thread target extraction"]
TARGET -->|"Thread(T)"| STORE["ThreadEventStore(T)"]
TARGET -->|"global"| CURRENT["current ChatWidget"]
TARGET -->|"app-scoped"| APP["app-level handling or ignore"]
STORE --> BUFFER["bounded buffer + pending state"]
STORE -->|"active"| CHANNEL["ThreadEventChannel"]
CHANNEL --> LIVE["live handler"]
SWITCH["thread switch"] --> SNAPSHOT["turns + events + input state"]
BUFFER --> SNAPSHOT
SNAPSHOT --> REPLAY["ReplayKind::ThreadSnapshot"]
LIVE --> WIDGET["ChatWidget projection"]
REPLAY --> WIDGET
路由发生在 ChatWidget 之前
notification 与 server request 分别提取 Thread
不同 ServerNotification 的 Thread identity 位置并不完全相同:大多数类型有 thread_id,ThreadStarted 从 notification.thread.id 取值,Warning 和 MCP startup 的 id 还是 optional。server_notification_thread_target 把差异收进一个穷举 match,结果分成 Thread(ThreadId)、InvalidThreadId(String)、AppScoped 与 Global。
server request 另走 server_request_thread_id。approval、user input、elicitation、permissions 和 dynamic tool call 能提取 Thread;旧的 threadless approval/auth request 返回 None,TUI 不会猜测当前 Thread。invalid id 也不会降级成 global event。
本节源码依据(2 处)
只有 global 事件直接落到当前 widget
handle_server_notification_event 先处理 resolved request、账户、rate limit 与 connector list 等 app-level state,再调用 target classifier。Thread target 进入 primary 或 side-thread enqueue;invalid id 被丢弃;app-scoped MCP update 当前没有 TUI app target,也被明确忽略。只有 Global 才交给当前 ChatWidget。
这条顺序避免后台 Thread 的 delta、token usage 或 warning 修改当前 active turn。server request 也先登记 pending state,再按 id 路由;unsupported request 被拒绝,threadless request 被忽略。
本节源码依据(2 处)
每个 Thread 拥有一份有界 UI state
ThreadEventStore 把会影响切换体验的状态放在一起:session snapshot、协议 turns、有界 VecDeque<ThreadBufferedEvent>、pending interactive replay、active turn id、composer input state、capacity 和 active 标记。buffer 里的事件也不只有 notification,还包括 server request、history lookup response 与 feedback result。
store 收到 TurnStarted、TurnCompleted、ThreadClosed 时更新 active_turn_id;收到 request/notification 时同时更新 pending interaction state。超过 capacity 后淘汰最旧事件,若被淘汰的是 request,还要通知 pending tracker,让后续 snapshot 不会从已经消失的 buffer 虚构可交互请求。
这是一个界面投影 store。它可以保住有限窗口内的切换状态,却没有 rollout 的 append-only 完整性,也没有 SQLite 的查询语义。
本节源码依据(3 处)
session refresh 是逐类型白名单
刷新 session/turn snapshot 时,store 不保留整个旧 buffer。event_survives_session_refresh 只留下 server request、hook started/completed、MCP startup update 和 feedback result,其他 transcript event 由新 turns 取代;新 notification 若需要跨 refresh 生存,必须显式加入白名单。
本节源码依据(1 处)
active Thread 才有 live delivery
每个 ThreadEventChannel 同时拥有 bounded mpsc channel 和共享 store。enqueue 总是先写 store;只有 guard.active 为真,才尝试把事件发进 channel。后台 Thread 因此继续积累 snapshot,但不会修改当前 widget。
try_send 遇到 full 时,会为每个 event 独立 tokio::spawn 一个 task,再等待 sender.send(event);遇到 closed 才记录 warning。channel full 表示 live delivery 被延后,不表示 store 事件消失;但源码和本章测试都没有保证多个异步补投在饱和路径上仍按原顺序交付。store 自身也受 capacity 限制,旧事件可以被淘汰。激活时取走唯一 receiver,离开时保存 composer state 并把 receiver 放回原 channel。
本节源码依据(3 处)
Thread switch 是一次受约束的 replay
ThreadStarted 可以先推断 session
子 Thread 的第一条可见通知可能先于显式 session load。infer_session_for_thread_notification 只对 ThreadStarted 生效:它复制 primary session 的基础形状,替换 thread_id、名称、provider、cwd 和 rollout path,再尝试读取 model。message history 被清空,agent picker 登记新 Thread;这不表示父子共用 identity 或 history。
本节源码依据(1 处)
snapshot 先恢复结构,再恢复交互
切换目标 Thread 时,replay_thread_snapshot 开启 replay buffer,恢复 session 与 composer state,暂停 queued input autosend,用 ReplayKind::ThreadSnapshot 重放 turns,再重放 buffered events,最后才恢复 autosend 与 initial submission。若 snapshot 还有 pending interactive request,warning/GuardianWarning/config warning 等 notice 会被抑制,避免遮住真正需要操作的审批 UI。
本节源码依据(2 处)
已解决的 request 不应重新出现
buffer 里有 request,不足以证明它仍待处理。PendingInteractiveReplayState 按 approval id、item id 或 elicitation key 记录未解决交互;snapshot() 只保留 should_replay_snapshot_request 为 true 的请求。已响应的 approval/user-input request 不会在每次切换时重新触发副作用。
本节源码依据(2 处)
live 与 replay 共用 widget,但 marker 不同
handle_thread_event_now 传 replay_kind = None;handle_thread_event_replay 传 Some(ReplayKind::ThreadSnapshot)。共享 downstream handler 避免两套渲染逻辑,但 handler 可据 marker 跳过 live-only 动作:resume replay 不重复 stream error,ThreadClosed replay 不触发 shutdown completion,GuardianWarning 则仍按 warning path 投影。
ChatWidget 还有一条窄 MCP status mismatch guard;它只能挡住一种误路由,不能替代 app 层统一 target classifier。
本节源码依据(3 处)
replay 只近似 live event flow
ChatWidget::replay_thread_turns 根据 turn status 合成 start/completion handling,再把每个 ThreadItem 交给 replay renderer。源码把它定义为 conservative approximation:event id 设为 None,只渲染适合重放的 item,避免重新触发真实执行。bounded store 淘汰、session refresh 白名单和 replay no-op 都说明它不是 rollout 的逐事件重建。
本节源码依据(2 处)
失败边界:稳定不等于无损
app-server client 的 receiver 可以 lag;AppServerEvent::Lagged { skipped } 明确承认部分 event 已丢弃,TUI 只能做窄 MCP recovery。Disconnected 会显示 error 并请求 fatal exit。active 非 primary Thread 意外 ThreadClosed 时,TUI 尝试切回 primary;这保住导航,不会恢复已关闭 Agent。
| 现象 | 源码允许的结论 | 不能外推的结论 |
|---|---|---|
| route test 通过 | notification 被分到目标 Thread | emitter 一定构造正确 JSON |
| render test 通过 | live notification 能产生 widget message | background route/replay 全部正确 |
| channel full | live delivery 被延后,store 已先写入 | 饱和路径仍保序,或所有 event 永久无损 |
| store 有 snapshot | 有界 UI 状态可用于切换 | 等价于 rollout 或完整 transcript |
| lag handler 运行 | TUI 承认 skipped count | 被跳过事件已经恢复 |
本节源码依据(2 处)
实验:把“路由正确”和“能渲染”分开测
: "${ARCHIVE_CODEX_RS:?先执行第六部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
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
两条命令本次都得到 1 test run: 1 passed; 2991 skipped。第一条独立构造 notification,验证 GuardianWarning 的 thread_id 被分类为目标 Thread;第二条另行把 notification 送入 active widget,验证 warning message 出现在 history。它们是两个相邻的 layer test,没有共享 fixture,也没有可对账的 event id,因此不能拼成 route → render 闭环。buffer 淘汰、switch replay、lag、wire serialization 和 app-server emitter 也都不在覆盖范围内;没有真正执行目标测试就不能算通过。
本节源码依据(2 处)
下一章:子 Agent 为什么需要独立 Thread
TUI 已经按 ThreadId 隔离 store、channel、composer 和 replay。子 Agent 为什么拿到新的 ThreadId,它和父 Agent 的 history 是复制还是共享,AgentPath、parent_thread_id、forked_from_id 又分别记录什么,交给第 32 章:一个子 Agent 为什么需要独立 Thread。
本章只交出两项客户端事实:每个 Thread 有独立 UI projection;TUI 的 bounded replay snapshot 不能用来证明 Agent 的执行身份或持久谱系。