先想一个日常画面。你在一个客户端里发起任务,Codex 开始处理;另一个客户端连进来, 应该能看到当前 turn 已经开始、模型正在输出、某条命令在等审批、当前 diff 变成了什么。 过一会儿你关掉页面再回来,历史里还要能重建这轮发生过的关键事实。

如果只盯着界面,会很容易把这些现象看成一堆 UI 状态:聊天消息、进度条、审批弹窗、diff 面板、token 数字。 源码里的边界更清楚:先有 core 的运行时事实,再有 客户端视图。 这一篇就沿这条边界读。

这一章只需要先分清三个对象: Submission 是输入任务单; EventMsg 是运行时发出的事实; ServerNotification 是 app-server 给客户端看的通知。

证据边界。 OpenAI 的 App Server 文章提供产品层边界:App Server 位在客户端和 Codex harness 之间, 负责接住客户端请求,并把 harness event stream 整理成客户端通知。公开源码里能直接验证的是协议类型、 事件类型和映射函数。源码链接固定到同一个公开快照。文中把“任务单”“事实”“客户端投影”当作阅读辅助词, 对应的是源码里的 SubmissionEventMsg、app-server v2 notification 等结构。

这一篇只追五个问题:

  1. 客户端发起 turn 时,除了文本,还带了哪些运行边界?
  2. core 层为什么只暴露提交任务和接收事件两条队列?
  3. EventMsg 里哪些是生命周期,哪些是内容、工具、审批、用量和 diff?
  4. app-server v2 为什么要把 core event 再翻译成 turn、item、delta 和 request?
  5. 恢复历史时,为什么能够从 rollout 重新拼出 turn?

一、先从客户端看到的现象说起

读 protocol 最容易掉进枚举清单。更自然的入口,是先列出客户端要解决的问题。 一个 Codex turn 运行时,客户端至少要处理这些变化:

客户端看到 源码里要追问 对应边界
turn 开始、完成、被中断。 谁给这轮分配 id,谁宣布生命周期变化? Submission.idEventMsg::TurnStarted / TurnComplete
assistant 文本一边生成一边显示。 流式内容是完整消息,还是增量事件? AgentMessageAgentMessageContentDelta
命令、patch、MCP、动态工具进入进度列表。 工具执行是 UI 自己猜的,还是运行时发出的结构化事实? ItemStarted / ItemCompleted 与工具类事件。
命令或文件修改请求审批。 审批是普通通知,还是需要客户端回填决定? ExecApprovalRequest / ApplyPatchApprovalRequest 与后续 Op::*Approval
刷新后还能看到历史 turn。 页面状态从哪里重建? RolloutItemThreadHistoryBuilder

这张表能帮我们避开一个误读:protocol 不只是给前端准备接口。 它同时承担三件事:接住用户意图维持运行时事实让不同客户端用自己的视图消费这些事实

二、输入侧:客户端先交一张有类型的任务单

app-server v2 的客户端请求不是散装 JSON。源码通过宏生成 ClientRequest, 每个 request 都有固定的 paramsresponse 类型。其中 turn/start 对应 TurnStartParams

TurnStartParams 里最直观的是 thread_idinput,但它还带着很多“本轮怎么运行”的边界: client metadata、additional context、environment、cwd、workspace roots、approval policy、 sandbox policy、permissions、model 和 service tier。也就是说,发起 turn 的请求不是一句话, 更像是一张带运行约束的任务单。

进入 app-server 的 turn_start_inner 后,这张任务单会被整理成 core 能理解的 Op::UserInput。 源码先把 v2 input 转成 core input,解析 cwd 和环境选择,再把 approval、sandbox、permissions、 model 等字段收进 thread settings overrides;随后创建 Op::UserInput, 调用 submit_user_input_with_client_user_message_id

这一段最关键的细节是:turn/start 返回的 turn_id, 其实来自 core submission id。 源码注释直接写着“提交用户输入,并把 submission id 作为 turn_id 返回”。 这让后续事件可以用同一个 id 回到这轮任务上。

同一层还有一个很实用的设计: Op::ThreadSettings 不启动 turn,但走同一条 submission queue。源码注释说得很直接:这样 app-server 可以保留 thread settings mutation 和 turn start 之间的调用顺序。读到这里,就能理解为什么 settings 不是在旁边随手改全局变量,而是被放进同一条有序队列。

三、core 边界:Codex 是一对队列

app-server 把请求整理成 Op 之后,真正进入 core 的边界非常小。 Codex 结构体的注释说它是一个 queue pair:发送 submission,接收 event。结构体里对应两根管道: tx_sub: Sender<Submission>rx_event: Receiver<Event>

submit 会生成一个 UUID,把 Op 包成 Submission, 再送进 tx_subSubmission 自己只有几件事:唯一 id、payload op、 可选的 client user message id、可选 trace。 next_event 则从 rx_event 取出下一条 Event

core 类型 人话职责 为什么重要
Op 用户或客户端想让运行时做什么。 输入不再只是文本,还包括中断、审批回复、compact、rollback、review 等操作。
Submission 带 id 的任务单。 给后续事件提供关联 id,也让 trace 和 client message id 有位置可放。
Event 带 id 的运行时事实。 事件里的 id 指回对应 submission,让客户端知道这条事实属于哪一轮。
EventMsg 事实的具体类型。 把生命周期、文本、工具、审批、diff、token、compact、rollback 分成可匹配的类型。

这个边界小,但后面的设计都靠它对齐: Event 也带一个 id,注释写的是“与 submission id 关联”。这就是客户端看到“第几轮 turn 在更新”的基础。

四、输出侧:EventMsg 记录运行时发生了什么

接下来读 EventMsg。 它不是一个小枚举,而是一份很长的运行时事实清单,并且用 #[serde(tag = "type", rename_all = "snake_case")] 固定 wire shape。也就是说,事件不是靠字符串约定偷偷传,而是以 tagged enum 的形式进入序列化边界。

事件类别 代表类型 说明
turn 生命周期 TurnStartedTurnCompleteTurnAborted 告诉客户端这一轮什么时候开始、结束或中断。
模型内容 UserMessageAgentMessageAgentReasoning、各种 delta 既能保存完整消息,也能发送流式增量。
工具与文件变化 ExecCommand*PatchApply*ItemStartedItemCompleted 把命令、patch、工具调用放进结构化生命周期,而不是只输出日志文本。
审批与交互 ExecApprovalRequestApplyPatchApprovalRequestRequestUserInput 运行时暂停在需要人或客户端决定的位置,后续再由对应 Op 回填。
状态补充 TokenCountTurnDiffContextCompactedThreadRolledBack 让客户端显示用量、diff、压缩和回滚状态,也给恢复链路留下可用信息。

这里要注意一件事:EventMsg 的粒度比最终 UI 更底层。 比如 TurnDiffEvent 只关心统一 diff 文本;TokenCountEvent 只携带 token usage 和 rate limit 快照。客户端要怎样排版、折叠、突出显示,留给投影层处理。

五、app-server v2:把运行时事实翻译成客户端视图

到 app-server 这一层,协议开始面向多客户端。OpenAI 的 App Server 文章 给了产品层边界:client 通过 JSON-RPC 接入 Codex harness,服务端把请求转成 core operation, 也把 harness event stream 整理成客户端稳定消费的通知。 源码里,这件事首先表现为用宏生成 ServerNotification, 并显式列出 wire method:turn/startedturn/completeditem/starteditem/completedturn/diff/updatedthread/tokenUsage/updated 等。

v2 视图里最重要的两个容器是 TurnThreadItemTurnStatus 只暴露客户端关心的状态:completed、interrupted、failed、in progress。 ThreadItem 则把用户消息、assistant 消息、plan、reasoning、工具调用等整理成客户端列表项。

真正的翻译发生在两处。第一处是 apply_bespoke_event_handling: 它拿到 core Event { id, msg } 后,按 EventMsg 分支处理。 TurnStarted 会变成 TurnStartedNotificationTurnComplete 会收尾并发送完成通知;审批请求会变成需要客户端响应的 server request; token 和 diff 则各自走专门处理函数。

第二处是 item_event_to_server_notification。 这个 helper 只覆盖“单个 core event 到单个 v2 notification”的无状态投影, 例如 assistant 文本 delta、plan delta、reasoning delta、item started/completed、 exec output delta 等。需要检查状态、清理 pending request 或抑制 legacy event 的场景, 仍然留在 bespoke handler 里。

core 事件 v2 输出 读源码时的判断
EventMsg::TurnStarted TurnStartedNotification 打开一个客户端可见的 turn snapshot。
AgentMessageContentDelta AgentMessageDeltaNotification 流式文本增量直接投影给客户端。
ItemStarted / ItemCompleted ItemStartedNotification / ItemCompletedNotification 核心工具事实进入 v2 的列表项生命周期。
ExecApprovalRequest / ApplyPatchApprovalRequest server request 这类事件需要客户端返回决定,不能只当普通 notification 展示。
TokenCount ThreadTokenUsageUpdatedNotification usage 从 core 事件转成 thread 级客户端状态。
TurnDiff TurnDiffUpdatedNotification 统一 diff 作为 turn 级视图更新发给客户端。

把它连成一条链路,一次需要审批的命令大致会这样穿过边界:

turn/start request
  -> EventMsg::TurnStarted -> turn/started notification
  -> EventMsg::AgentMessageContentDelta -> item delta
  -> EventMsg::ExecApprovalRequest -> server request
  -> Op::ExecApproval -> core continues
  -> EventMsg::ItemCompleted -> item/completed notification
  -> EventMsg::TurnComplete -> turn/completed notification

这一层还有很多“克制”的细节。例如 ContextCompacted 在 v2 里被压下,因为 v2 客户端会收到 canonical 的 context compaction item; PatchApplyBegin/End 和部分 exec legacy item 也会为了避免重复渲染而被抑制。也就是说,客户端投影层不是“有事件就全发”,它会维护客户端视图的一致性。

六、rollout 与恢复:保存能重建的事实

到这里,实时客户端已经能持续看到 turn 状态。但刷新、恢复、fork 又从哪里来? 答案在 rollout。protocol 里 RolloutItem 可以保存 SessionMetaResponseItemCompactedTurnContextEventMsg。它保存的不是屏幕长什么样,而是足够重建历史的材料。

这也是为什么 InitialHistory 提供 get_event_msgs():恢复和 fork 时,可以从 rollout 里筛出 RolloutItem::EventMsg。 core 层的 record_initial_history 会应用 rollout reconstruction,并从最后持久化的 TokenCount 事件种下 usage 状态, 让 UI 在恢复后能立刻看到 token 快照。

app-server 还有一层专门的历史 reducer: build_turns_from_rollout_items 会把持久化的 RolloutItem 转成一组 Turn。内部的 ThreadHistoryBuilder 逐条处理 EventMsg、compacted item、response item 和 turn context; 遇到 TurnStarted 会打开新 turn,遇到 TurnComplete 会关闭它。

形状上可以这样理解:

rollout items:
  EventMsg(TurnStarted)
  EventMsg(ItemStarted(exec))
  EventMsg(ItemCompleted(exec))
  EventMsg(TurnComplete)

ThreadHistoryBuilder:
  -> Turn { status: completed, items: [...] }

所以恢复历史时,app-server 不是在“复原某个前端页面”,而是在 用持久化的运行时事实重新归约出 turn 和 item。 这也把两条恢复线分开了:上下文恢复保证下一次模型调用还能继续工作, protocol/event 恢复保证人和客户端能看懂运行时发生过什么。

七、带走一张判断表

读 Codex protocol 时,可以用下面这张表快速定位自己看到的类型属于哪一层。

看到的类型 先问什么 常见误读 更稳的读法
TurnStartParams 客户端这次 turn 带了哪些边界? 只看成用户消息。 把 input、cwd、approval、sandbox、model、metadata 一起看。
Op 这是哪种运行时操作? 只追 UserInput 把 interrupt、approval response、compact、rollback、review 都纳入同一输入面。
EventMsg 这条事实由运行时哪一步发出? 直接等同于 UI 组件。 先判定它是生命周期、内容、工具、审批、diff、token 还是恢复相关事件。
ServerNotification 这是哪个客户端视图需要的投影? 以为 core event 会原样发给所有客户端。 看 bespoke handler 和 event mapping 是否做了转换、抑制或拆分。
RolloutItem 这条记录能帮助恢复什么? 当成屏幕快照。 把它读成可重放、可归约的历史材料。

回到开头的问题:客户端之所以能共享同一套事实,是因为 Codex 把输入、运行、展示和恢复分成了四层。 读工具系统时也沿这条线追问: 模型提出 tool call 之后,Codex 怎样把它路由到具体工具、处理并发、归档结果,并把副作用关进权限边界里?

参考源码与文档