先想一个日常画面。你在一个客户端里发起任务,Codex 开始处理;另一个客户端连进来, 应该能看到当前 turn 已经开始、模型正在输出、某条命令在等审批、当前 diff 变成了什么。 过一会儿你关掉页面再回来,历史里还要能重建这轮发生过的关键事实。
如果只盯着界面,会很容易把这些现象看成一堆 UI 状态:聊天消息、进度条、审批弹窗、diff 面板、token 数字。 源码里的顺序更清楚:先有 core 的运行时事件,再由客户端决定怎样显示。 这一篇就沿这个顺序读。
这一章只需要先分清三个对象: Submission 是输入任务单; EventMsg 是运行时发出的事实; ServerNotification 是 app-server 给客户端看的通知。
取材范围。 源码快照:5c5308fc9a9e。
OpenAI 的 App Server 文章说明产品关系:App Server 位在客户端和 Codex harness 之间,
负责接住客户端请求,并把 harness event stream 整理成客户端通知。公开源码里能直接验证的是协议类型、
事件类型和映射函数。源码链接固定到同一个公开快照。文中把“任务单”“运行时事件”“客户端通知”当作阅读辅助词,
对应的是源码里的 Submission、EventMsg、app-server v2 notification 等结构。
这一篇只追五个问题:
- 客户端发起 turn 时,除了文本,还带了哪些运行参数?
- core 层为什么只暴露提交任务和接收事件两条队列?
EventMsg里哪些是生命周期,哪些是内容、工具、审批、用量和 diff?- app-server v2 为什么要把 core event 再翻译成 turn、item、delta 和 request?
- 恢复历史时,为什么能够从 rollout 重新拼出 turn?
一、先从客户端看到的现象说起
读 protocol 最容易掉进枚举清单。更自然的入口,是先列出客户端要解决的问题。 一个 Codex turn 运行时,客户端至少要处理这些变化:
| 客户端看到 | 源码里要追问 | 对应类型 |
|---|---|---|
| turn 开始、完成、被中断。 | 谁给这轮分配 id,谁宣布生命周期变化? | Submission.id 与 EventMsg::TurnStarted / TurnComplete。 |
| assistant 文本一边生成一边显示。 | 流式内容是完整消息,还是增量事件? | AgentMessage 与 AgentMessageContentDelta。 |
| 命令、patch、MCP、动态工具进入进度列表。 | 工具执行是 UI 自己猜的,还是运行时发出的结构化事实? | ItemStarted / ItemCompleted 与工具类事件。 |
| 命令或文件修改请求审批。 | 审批是普通通知,还是需要客户端回填决定? | ExecApprovalRequest / ApplyPatchApprovalRequest 与后续 Op::*Approval。 |
| 刷新后还能看到历史 turn。 | 页面状态从哪里重建? | RolloutItem 与 ThreadHistoryBuilder。 |
这张表能帮我们避开一个误读:protocol 不只是给前端准备接口。 它同时承担三件事:接住用户意图、维持运行时事实、 让不同客户端用自己的视图消费这些事实。
二、输入侧:客户端先交一张有类型的任务单
app-server v2 的客户端请求不是散装 JSON。源码通过宏生成
ClientRequest,
每个 request 都有固定的 params 和 response 类型。其中
turn/start
对应 TurnStartParams。
TurnStartParams
里最直观的是 thread_id 和 input,但它还带着很多“本轮怎么运行”的参数:
client metadata、additional context、environment、cwd、workspace roots、approval policy、
sandbox policy、permissions、model 和 service tier。也就是说,发起 turn 的请求不是一句话,
更像是一张带运行约束的任务单。
进入 app-server 的 turn_start_inner 后,v2 input 被转换成 TurnInput,运行参数整理成 settings overrides,再封装为 TurnInputRequest。start_or_steer_turn 把决定交给 core:空闲时启动新 turn,已有可接收输入的 turn 时补充到该 turn;不能提交时返回拒绝原因。客户端不需要先读状态再猜测该走哪个分支。
turn/start 的成功响应表示 core 已接受输入,并不表示执行完成,也不保证创建了新 turn。新建分支返回新 turn id;补充分支返回正在运行的 turn id。后续通知应关联响应里的 turn.id,不能把每个 JSON-RPC request id 或 submission id 都当成一轮新执行。
同一层还有一个很实用的设计:
Op::ThreadSettings
不启动 turn,但走同一条 submission queue。源码注释说得很直接:这样 app-server 可以保留
thread settings mutation 和 turn start 之间的调用顺序。读到这里,就能理解为什么 settings
不是在旁边随手改全局变量,而是被放进同一条有序队列。
三、core 对外只暴露一对队列
目前对外的线程对象是 CodexThread;它把运行状态留给 Session,把提交和接收端点放进 SessionIo。其中仍有两根管道:tx_sub: Sender<Submission> 和 rx_event: Receiver<Event>。分开端点与状态,使发送端全部释放后可以结束 session loop,并让调用方等待统一的关闭结果。
普通 submit 给 Op 分配唯一 id,包成 Submission 后送入队列;任务单现在保存 id、op、trace,以及多 agent 因果关联的 parent_turn_id / root_turn_id。用户消息 id 属于 TurnInput::UserInput,不再是任务单上的独立字段。submit_turn_input 还携带一次性回复通道,只等待 core 的启动、补充或拒绝决定;它不等待模型完成。next_event 则继续读取异步运行事件。
| core 类型 | 人话职责 | 为什么重要 |
|---|---|---|
Op |
用户或客户端想让运行时做什么。 | 输入不再只是文本,还包括中断、审批回复、compact、review 等操作。 |
Submission |
带 id 的任务单。 | 给后续事件提供关联 id,也携带 trace 和父级、根 turn 的因果关联。 |
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 生命周期 | TurnStarted、TurnComplete、TurnAborted |
告诉客户端这一轮什么时候开始、结束或中断。 |
| 模型内容 | UserMessage、AgentMessage、AgentReasoning、各种 delta |
既能保存完整消息,也能发送流式增量。 |
| 工具与文件变化 | ExecCommand*、PatchApply*、ItemStarted、ItemCompleted |
把命令、patch、工具调用放进结构化生命周期,而不是只输出日志文本。 |
| 审批与交互 | ExecApprovalRequest、ApplyPatchApprovalRequest、RequestUserInput |
运行时暂停在需要人或客户端决定的位置,后续再由对应 Op 回填。 |
| 状态补充 | TokenCount、TurnDiff、ContextCompacted、ThreadRolledBack |
让客户端显示用量、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/started、turn/completed、
item/started、item/completed、turn/diff/updated、
thread/tokenUsage/updated 等。
v2 视图里最重要的两个容器是 Turn 和 ThreadItem。
TurnStatus
只暴露客户端关心的状态:completed、interrupted、failed、in progress。
ThreadItem
则把用户消息、assistant 消息、plan、reasoning、工具调用等整理成客户端列表项。
真正的翻译发生在两处。第一处是
apply_bespoke_event_handling:
它拿到 core Event { id, msg } 后,按 EventMsg 分支处理。
TurnStarted 会变成 TurnStartedNotification,
TurnComplete 会收尾并发送完成通知;审批请求会变成需要客户端响应的 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 且命令需要审批的一条路径;补充已有 turn 不会再生成一轮新的 started,命令事件也可能与文本 delta 交错:
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
也会为了避免重复渲染而被抑制。也就是说,app-server 不是“有事件就全发”,它会维护客户端视图的一致性。
六、rollout 与恢复:保存能重建的事实
持久化 RolloutItem 包括 session metadata、response items、turn context、压缩记录、token usage,以及选定的运行事件。持久化规则按历史模式分支:legacy 保留对应旧消息事件,paginated 主要保存完成后的 TurnItem;中间 delta、审批请求和 ItemStarted 不会全量落盘。旧 ThreadRolledBack 标记仍用于历史重建,但当前请求枚举已不再提供 thread/rollback。
这也是为什么
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 (simplified persisted records):
EventMsg(TurnStarted)
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、review 都纳入同一输入面。 |
EventMsg |
这条事实由运行时哪一步发出? | 直接等同于 UI 组件。 | 先判定它是生命周期、内容、工具、审批、diff、token 还是恢复相关事件。 |
ServerNotification |
哪个客户端功能需要这条通知? | 以为 core event 会原样发给所有客户端。 | 看 bespoke handler 和 event mapping 是否做了转换、抑制或拆分。 |
RolloutItem |
这条记录能帮助恢复什么? | 当成屏幕快照。 | 把它读成可重放、可归约的历史材料。 |
回到开头的问题:客户端之所以能共享同一套事实,是因为 Codex 把输入、运行、展示和恢复分成了四层。 读工具系统时也沿这条线追问: 模型提出 tool call 之后,Codex 怎样把它路由到具体工具、处理并发、记录结果,并在审批与 sandbox 检查后执行?
参考源码与文档
- openai/codex 固定源码快照
- OpenAI:Unlocking the Codex harness: how we built the App Server
- ClientRequest 宏生成
- turn/start request 定义
- TurnStartParams
- turn_start_inner 构造 TurnInputRequest 并启动或补充 turn
- Submission
- Op
- SessionIo submission/event endpoints
- submit / submit_with_id
- next_event
- Event / EventMsg
- ServerNotification 宏生成
- apply_bespoke_event_handling
- item_event_to_server_notification
- 审批请求映射
- TokenCount 映射
- TurnDiff 映射
- RolloutItem
- build_turns_from_rollout_items
- ThreadHistoryBuilder