先看一个很熟悉的场景:Codex 正在回答,屏幕上有一段流式文本;它准备跑命令, 于是你看到一个 command item;命令需要审批,客户端弹出确认;最后 turn 结束, 状态从 running 变成 completed;过一会儿你恢复这个 thread,历史又能重新出现。

如果只盯着 UI,很容易以为这些都是各自实现的小功能。源码给出的答案更克制: core 负责产生和持久化事实,app-server 把事实变成 JSON-RPC 通知, TUI 把通知变成 history cell 和状态行,rollout 保留 resume 所需的可恢复记录。 这篇要讲的“客户端投影”,就是这几层之间的转换边界。

本文里的“投影”指一件具体的事:同一个 runtime 事实,在不同 owner 手里变成不同形状。 它可能是 EventMsg,也可能是 ServerNotificationThreadItem、TUI 的 history cell,或者 rollout 里的可恢复记录。 读源码时先问 owner,再问形状,最后再问 UI。

证据边界。 本文只描述 openai/codex 公开源码里可验证的事件类型、转换函数、通知结构、 TUI 处理分支和 rollout 恢复逻辑。文中提到“客户端”“界面”“状态”时,指这些公开代码暴露的 app-server 协议和 TUI 渲染路径,不推断私有产品客户端的页面实现。

这一篇按五个问题往下拆:

  1. 哪些词必须先分开,才不会把事实、通知、UI 和持久化混在一起?
  2. core 什么时候把 provider response 变成可展示的 turn item?
  3. app-server 怎样把 EventMsg 投影成 ServerNotification
  4. TUI 怎样把通知变成 streaming、history cell、status 和 replay?
  5. rollout 里保留的记录,怎样服务 resume,而不是复刻每一次 UI 变化?

一、先把五个形状分开

第三篇讲 protocol 时已经见过 EventMsg。这一篇往客户端走,会再碰到 TurnItemThreadItemServerNotification、 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/starteditem/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, 如果有结果,就发 ItemStartedItemCompleted

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_itemThreadItem::from 和客户端渲染逻辑。

三、app-server 把事实变成客户端通知

app-server 的入口在 apply_bespoke_event_handling。 它拿到 core 的 Event 后按 EventMsg 分支处理。 TurnStarted 会清理 pending server requests、标记 thread watch,然后发送 ServerNotification::TurnStartedTurnComplete 会中止本轮 pending request, 再把结果交给 turn complete 处理函数。

app-server 协议层定义了 ServerNotification 的 wire name: turn/startedturn/completeditem/starteditem/completed、delta 都在这里。对应的 Turn 结构有 itemsitems_viewstatuserror 和时间字段; TurnItemsView 还会告诉客户端:这次 payload 里的 items 是完整、摘要,还是根本没加载。

3.1 turn 状态是投影出来的

TurnStarted 发出的 turn 不是完整历史,只是一个当前状态快照。 源码里它会构造 TurnStatus::InProgress,并把 items_view 设成 NotLoaded。turn 完成时, handle_turn_complete 会根据 turn_summary.last_error 选择 CompletedFailed, 然后通过 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。 它把 ItemStartedItemCompleted 转成客户端通知,把 AgentMessageContentDelta 转成 AgentMessageDelta, 把 ExecCommandOutputDelta 转成 CommandExecutionOutputDelta

另一类需要 app-server 加一点状态。命令开始时, 源码会用 command_execution_started 去重, 并且对 unified exec interaction 抑制旧的 command item,避免客户端渲染两份等待状态。 这说明 projection 还承担客户端兼容和展示去重,并不只是在替换 JSON 字段名。

3.3 审批请求走 request 通道

权限篇讲过审批。到客户端投影这一层,ExecApprovalRequestApplyPatchApprovalRequest 会通过 app-server 的 server request 发给客户端, 因为这里需要客户端给出回答。 ApplyPatchApprovalRequest 会发送 FileChangeRequestApprovalExecApprovalRequest 会构造 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.rsTurnStartedon_task_started,打开 running 状态; TurnCompleted 走完成处理;ItemStartedItemCompleted 进入 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_startedon_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 记录保留下来。

写入路径在 RolloutRecorderrecord_canonical_items 只是把 items 排队给 writer; persistflush 负责 materialize 与等待落盘。 读取时, load_rollout_itemsget_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? AgentMessageDeltaflush_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 后,最终仍然要回答同一个问题:事实由谁拥有,客户端看到什么, 哪些记录能在下一次恢复时重新成立。

参考源码