先看一个很熟悉的场景:Codex 正在回答,屏幕上有一段流式文本;它准备跑命令, 于是你看到一个 command item;命令需要审批,客户端弹出确认;最后 turn 结束, 状态从 running 变成 completed;过一会儿你恢复这个 thread,历史又能重新出现。
如果只盯着 UI,很容易以为这些都是各自实现的小功能。源码给出的答案更克制: core 负责产生和持久化事实,app-server 把事实变成 JSON-RPC 通知, TUI 把通知变成 history cell 和状态行,rollout 保留 resume 所需的可恢复记录。 这篇要讲的“客户端投影”,就是这几层之间的转换边界。
本文里的“投影”指一件具体的事:同一个 runtime 事实,在不同 owner 手里变成不同形状。
它可能是 EventMsg,也可能是 ServerNotification、
ThreadItem、TUI 的 history cell,或者 rollout 里的可恢复记录。
读源码时先问 owner,再问形状,最后再问 UI。
证据边界。 本文只描述 openai/codex 公开源码里可验证的事件类型、转换函数、通知结构、 TUI 处理分支和 rollout 恢复逻辑。文中提到“客户端”“界面”“状态”时,指这些公开代码暴露的 app-server 协议和 TUI 渲染路径,不推断私有产品客户端的页面实现。
这一篇按五个问题往下拆:
- 哪些词必须先分开,才不会把事实、通知、UI 和持久化混在一起?
- core 什么时候把 provider response 变成可展示的 turn item?
- app-server 怎样把
EventMsg投影成ServerNotification? - TUI 怎样把通知变成 streaming、history cell、status 和 replay?
- rollout 里保留的记录,怎样服务 resume,而不是复刻每一次 UI 变化?
一、先把五个形状分开
第三篇讲 protocol 时已经见过 EventMsg。这一篇往客户端走,会再碰到
TurnItem、ThreadItem、ServerNotification、
history cell 和 rollout record。它们名字都像“历史”或“事件”,实际 owner 不一样。
| 形状 | owner | 它回答的问题 |
|---|---|---|
EventMsg |
core protocol | 运行时刚发生了什么事实:turn started、item completed、delta、approval request。 |
TurnItem |
core item stream | 哪些 provider response item 值得作为一轮里的可展示条目。 |
ThreadItem |
app-server v2 protocol | 客户端协议里如何表达用户消息、assistant 消息、命令、文件修改、MCP 调用等条目。 |
ServerNotification |
app-server JSON-RPC | 客户端应该收到什么通知:turn/started、item/completed、delta。 |
| history cell | TUI | 终端上最终怎么显示:流式尾巴、命令块、审批记录、分隔线、状态行。 |
| rollout record | persistence / resume | 恢复时需要哪些事实重新构造上下文、turn 状态和可见历史。 |
这张表是后面阅读的护栏。EventMsg::AgentMessageContentDelta
可以让 TUI 立刻追加一段流式文本;等最终的 assistant message 完成后,
同一段内容又会以 ThreadItem::AgentMessage 的形式进入稳定历史。
这两个动作服务的是不同 owner:前者服务“正在发生”,后者服务“已经完成且可重放”。
二、core 先确定哪些内容能成为 turn item
客户端能看到什么,不是 app-server 临时从 provider payload 里挑出来的。
core 在更早的位置就做了一次窄化:
parse_turn_item
会把 ResponseItem::Message、reasoning、web search、image generation
等 provider response item 转成 TurnItem。它还会过滤一些只给 runtime 用的上下文碎片,
比如带有权限、skills、collaboration mode 等前缀的 contextual developer/user 内容。
完成的 response item 会先进入会话历史和 rollout,然后再发出可展示条目。
record_response_item_and_emit_turn_item
的顺序很直接:先 record_conversation_items,再尝试 parse_turn_item,
如果有结果,就发 ItemStarted 和 ItemCompleted。
pub(crate) async fn record_response_item_and_emit_turn_item(
&self,
turn_context: &TurnContext,
response_item: ResponseItem,
) {
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
if let Some(item) = parse_turn_item(&response_item) {
self.emit_turn_item_started(turn_context, &item).await;
self.emit_turn_item_completed(turn_context, item).await;
}
}
这段源码的教学价值不在函数名,而在顺序:先把 provider response 作为 conversation item 记账,再决定它能不能成为客户端可见的 turn item。 所以 app-server 和 TUI 后面看到的是已经被 core 窄化过的事实,不是随手从 provider 原始返回里挑字段。
而 record_conversation_items
自身还做了三件事:更新内存里的 history,把 response item 写入 rollout,并发送
RawResponseItem 事件。这个顺序说明了一件事:raw item、turn item、UI item
是不同层面的投影。raw item 可以服务内部观察和云端处理;turn item 服务可展示的对话条目;
UI 最终怎么呈现,还要看后面的客户端。
读到 RawResponseItem 时不要急着把它理解成“用户看到的消息”。
它保存的是原始 response item;真正进入客户端 transcript 的,通常要经过
parse_turn_item、ThreadItem::from 和客户端渲染逻辑。
三、app-server 把事实变成客户端通知
app-server 的入口在
apply_bespoke_event_handling。
它拿到 core 的 Event 后按 EventMsg 分支处理。
TurnStarted 会清理 pending server requests、标记 thread watch,然后发送
ServerNotification::TurnStarted;TurnComplete 会中止本轮 pending request,
再把结果交给 turn complete 处理函数。
app-server 协议层定义了 ServerNotification 的 wire name:
turn/started、turn/completed、item/started、item/completed、delta
都在这里。对应的 Turn 结构有 items、items_view、
status、error 和时间字段;
TurnItemsView
还会告诉客户端:这次 payload 里的 items 是完整、摘要,还是根本没加载。
3.1 turn 状态是投影出来的
TurnStarted 发出的 turn 不是完整历史,只是一个当前状态快照。
源码里它会构造 TurnStatus::InProgress,并把 items_view 设成
NotLoaded。turn 完成时,
handle_turn_complete
会根据 turn_summary.last_error 选择 Completed 或 Failed,
然后通过
emit_turn_completed_with_status
发送 TurnCompletedNotification。
这里真正要看的,是 owner 的变化:core 说“turn complete 事件发生了”, app-server 结合自己的 turn summary,给客户端一个“这个 turn 已完成或失败”的状态。 这个状态来自客户端协议视角,而非 provider 原始 payload 的字段。
3.2 item 和 delta 走不同通道
对 item 生命周期,app-server 有两类处理。简单的一对一映射交给
item_event_to_server_notification。
它把 ItemStarted、ItemCompleted 转成客户端通知,把
AgentMessageContentDelta 转成 AgentMessageDelta,
把 ExecCommandOutputDelta 转成 CommandExecutionOutputDelta。
另一类需要 app-server 加一点状态。命令开始时,
源码会用 command_execution_started 去重,
并且对 unified exec interaction 抑制旧的 command item,避免客户端渲染两份等待状态。
这说明 projection 还承担客户端兼容和展示去重,并不只是在替换 JSON 字段名。
3.3 审批请求走 request 通道
权限篇讲过审批。到客户端投影这一层,ExecApprovalRequest 和
ApplyPatchApprovalRequest 会通过 app-server 的 server request 发给客户端,
因为这里需要客户端给出回答。
ApplyPatchApprovalRequest
会发送 FileChangeRequestApproval;
ExecApprovalRequest
会构造 command approval 的展示信息,再等待客户端响应。
这也是为什么审批弹窗不应该和普通 history item 混为一谈。它是一个需要客户端回答的 request, 不是只要显示出来就结束的通知。
四、TUI 再把通知变成终端体验
TUI 自己也不是直接渲染所有 app-server notification。
handle_app_server_event
先区分 notification、request、disconnect;
notification 会根据 thread target 进入当前 thread 或后台 thread。
对当前 thread,
handle_thread_event_now
再把它交给 ChatWidget::handle_server_notification。
真正的 UI 决策在
chatwidget/protocol.rs。
TurnStarted 调 on_task_started,打开 running 状态;
TurnCompleted 走完成处理;ItemStarted 和
ItemCompleted 进入 item 分发;agent message delta 走 streaming;
command output delta 只追加到正在活动的 exec cell。
4.1 streaming 是临时尾巴,完成后会合并
流式文本进入 TUI 时,on_agent_message_delta 会追加 delta;
handle_streaming_delta
会创建或更新 StreamController,并启动 commit animation。等完整 assistant item 到来,
flush_answer_stream_with_separator
会把这一串临时的 streaming cell 合并成稳定的 markdown cell,方便 resize 和 replay。
所以屏幕上的“正在打字”和 transcript 里的“最终消息”不是两份事实。 它们是同一条 assistant 内容在不同生命周期里的两个投影:一个追求即时反馈,一个追求稳定重绘。
4.2 item 类型决定 history cell
ItemStarted 到达后,
handle_item_started_notification
会按 ThreadItem 类型分发:命令进入 command lifecycle,文件修改进入 patch cell,
MCP 调用进入 MCP cell,web search 进入 active search cell,image generation 只刷新可见活动。
ItemCompleted 则交给
handle_thread_item,
由同一套函数处理 live 和 replay。
命令是一个很好的例子。
on_command_execution_started 与 on_exec_command_output_delta
负责创建 active exec cell、追加输出、请求重绘;
on_command_execution_completed
再结束这一组活动。app-server 只说“命令 item 开始/输出/完成”,TUI 决定它在终端里是一个活动块、
输出增量,还是一条最终历史。
4.3 turn 状态会同步到底部状态和最终分隔线
on_task_started
会重置 turn flags、打开 running 状态、设置 status header、显示 interrupt hint。
turn 完成时,
on_task_complete
会 flush streaming、flush unified exec、补 final separator、清理 running commands,
并决定是否发桌面通知或提交 queued follow-up。
这一步解释了一个常见现象:turn complete 不只是让 UI 上的 spinner 停下。 对 TUI 来说,它还是“把所有临时可见状态收拢成稳定 transcript”的时间点。
五、rollout 保存的是恢复线索
现在回到持久化。core 写 rollout 时,不会把每一个临时 UI delta 都原样保存。
rollout policy
会先筛选哪些 RolloutItem 需要持久化;ResponseItem 只保留有恢复价值的类型,
EventMsg 也只保留用户消息、assistant 消息、reasoning、turn started/complete、
compaction、rollback、web search end、image generation end 等关键事件。
大量中间 delta、approval request、exec output delta、hook started/completed
都不会作为普通 rollout 记录保留下来。
写入路径在
RolloutRecorder:
record_canonical_items 只是把 items 排队给 writer;
persist 和 flush 负责 materialize 与等待落盘。
读取时,
load_rollout_items 与 get_rollout_history
会把 jsonl 行重新解析成 InitialHistory::Resumed。
5.1 resume 是重建,不是回放 UI 帧
恢复时,core 用
reconstruct_history_from_rollout
从后往前扫描 rollout,找 compaction replacement history、previous turn settings、
reference context item、rollback 边界和 turn started/complete 段落。
接着再把 surviving suffix 往前重放,形成新的 ContextManager history。
到这里,rollout 的职责就清楚了:它不是终端 scrollback 的录像。
它保存足够的事实,让 runtime 能重建模型历史、turn metadata、上下文 baseline 和客户端可见 items。
TUI 的
replay_thread_turns
也遵循这个原则:恢复时只渲染安全可重放的 items,并用 replay 标记避免触发 live-only 副作用。
六、读客户端投影时的四条规则
这一篇把链路拉通之后,可以用四条规则收尾。
| 看到的现象 | 先问什么 | 对应源码层 |
|---|---|---|
| 屏幕上正在流式输出。 | 这是 delta 还是完成后的 item? | AgentMessageDelta 与 flush_answer_stream_with_separator。 |
| 命令卡片开始、追加输出、结束。 | 这是 item lifecycle,还是 exec output delta? | item_event_to_server_notification 与 command lifecycle。 |
| 审批弹窗出现。 | 客户端要不要回一个 response? | ServerRequestPayload,不是普通 notification。 |
| resume 后历史重新出现。 | 这是从 rollout 重建的事实,还是 live event? | InitialHistory::Resumed、rollout reconstruction、TUI replay。 |
所以,客户端投影这一层真正保护的是可理解性和可恢复性:core 只产生事实; app-server 负责协议边界;TUI 负责即时体验和稳定 transcript;rollout 负责 resume。 这些层都在讲同一轮工作,但每一层只保存自己需要承担的那一份形状。
后面继续写扩展和多 agent 时,这个分层还会继续派上用场。skills、plugins、MCP、 subagents 进入 runtime 后,最终仍然要回答同一个问题:事实由谁拥有,客户端看到什么, 哪些记录能在下一次恢复时重新成立。
参考源码
- openai/codex 固定源码快照
- EventMsg turn、item、tool、delta 事件
- Core TurnItem
- parse_turn_item 与 contextual message 过滤
- record_conversation_items 更新 history、rollout 与 RawResponseItem
- record_response_item_and_emit_turn_item
- app-server apply_bespoke_event_handling turn 分支
- approval request 投影成 server request
- command execution notification 去重与 unified exec 抑制
- emit_turn_completed_with_status
- handle_turn_complete
- ServerNotification wire names
- item_event_to_server_notification 说明
- delta、ItemStarted、ItemCompleted、exec event 到 ServerNotification
- ThreadItem 主要变体
- CoreTurnItem 到 ThreadItem 的转换
- Turn 与 TurnItemsView
- TUI handle_app_server_event
- TUI handle_thread_event_now
- ChatWidget handle_server_notification
- ChatWidget item notification 分发
- flush_answer_stream_with_separator
- handle_streaming_delta
- command start 与 output delta
- command completed
- on_task_started
- on_task_complete
- TUI replay_thread_turns 与 handle_thread_item
- rollout 持久化筛选策略
- RolloutRecorder 写入与读取
- rollout reconstruction