v2 协议 · thread、turn 与 item
app-server 的线上格式全部定义在 codex-app-server-protocol 一个 crate 里:几个声明宏把 170 个客户端请求、11 个服务端请求与 85 种通知连同参数、响应、实验开关和串行范围一次声明清楚;数据模型是 thread 包含 turn、turn 包含 item。TypeScript 与 JSON Schema 在测试里生成、压缩后嵌进二进制,v1 只剩握手类型和几个弃用方法。
v2 协议 · thread、turn 与 item
上一篇看的是 app-server 怎么收发消息,这一篇看消息本身。所有线上类型都在 codex-rs/app-server-protocol(crate 名 codex-app-server-protocol),服务端、TUI、codex exec 与各种客户端共用这一份定义。
用户看到的样子
协议是双向 JSON-RPC:客户端发请求,服务端回响应、推通知,也会反过来向客户端发请求。握手流程、主要方法与示例见手册SDK 与 App Server。源码里能看到几处手册没细说的线上细节:
rpc.rs开头写明“We do not do true JSON-RPC 2.0”:既不发送也不要求"jsonrpc": "2.0"字段;请求可以额外带一个trace字段,承载 W3C 分布式追踪上下文。- 通知在线上是
ServerNotificationEnvelope:在method、params之外多一个emittedAtMs,记录 app-server 发出它的毫秒时间戳,早于分发到各个连接。 codex app-server generate-ts --out DIR与generate-json-schema --out DIR输出与当前二进制一致的类型定义,加--experimental连实验性部分一起输出。它们是怎么生成的,下文单独讲。
一个方法就是宏里的一项
protocol/common.rs 用四个声明宏定义全部方法:client_request_definitions!、server_request_definitions!、server_notification_definitions!、client_notification_definitions!。每个方法一项,写明线上名字、参数类型、响应类型,客户端请求还要声明串行化范围:
TurnStart => "turn/start" {
params: v2::TurnStartParams,
inspect_params: true,
serialization: thread_id(params.thread_id),
response: v2::TurnStartResponse,
},
#[experimental("turn/settings/update")]
TurnSettingsUpdate => "turn/settings/update" {
params: v2::TurnSettingsUpdateParams,
serialization: thread_id(params.thread_id),
response: v2::TurnSettingsUpdateResponse,
},
TurnSteer => "turn/steer" {
params: v2::TurnSteerParams,
inspect_params: true,
serialization: thread_id(params.thread_id),
response: v2::TurnSteerResponse,
},(codex-rs/app-server-protocol/src/protocol/common.rs:1032)
#[experimental(...)] 把整个方法标成实验性;inspect_params: true 表示方法本身稳定、只有部分字段是实验性的,要看参数才知道;serialization 就是上一篇按资源串行的依据,可选 None、global、global_shared_read、thread_id、thread_or_path 等几种写法,由 serialization_scope_expr! 展开。宏把这些声明展开成带 serde 标签的枚举:
/// Request from the client to the server.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "method", rename_all = "camelCase")]
pub enum ClientRequest {
$(
$(#[doc = $variant_doc])*
#[serde(rename = $wire)]
#[ts(rename = $wire)]
$variant {
#[serde(rename = "id")]
request_id: RequestId,
$(#[$params_meta])*
params: $params,
},
)*
}(codex-rs/app-server-protocol/src/protocol/common.rs:226)
同一次展开还生成 id()、method_name()、serialization_scope()、实验性判断、响应枚举 ClientResponse,以及只在测试里编译的类型导出函数。解码一个通用的 JSONRPCRequest 时,TryFrom 把它重新拼成 id、method、params 三个键的 JSON 对象,再交给这个带标签的枚举反序列化,方法名对不上就是反序列化错误。通知用 tag = "method", content = "params" 的相邻标签,服务端请求在 response_from_result 里按请求类型解码客户端的答复。
在 rust-v0.158.0 数一下这几个宏:客户端请求 170 个,其中 63 个整体标了实验性;服务端请求 11 个,其中 2 个是弃用的 v1 审批请求;服务端通知 85 种,其中 22 种是实验性的;客户端通知只有 initialized 一种。
数据模型:thread、turn、item
v2 的核心是三层嵌套:
- Thread(
codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs:204)是一段对话。id 由 Codex 生成时是 UUIDv7;还有sessionId、分叉来源forkedFromId、子代理的parentThreadId、ephemeral(不落盘)、historyMode(legacy或paginated,见会话持久化)、运行状态status、来源source等。turns字段只在thread/resume、thread/fork与带includeTurns的thread/read响应里填充,其余场合都是空列表。 - Turn 是一轮:用户一次输入到 agent 停下。
status取completed、interrupted、failed、inProgress;itemsView说明items装了多少:notLoaded(有意留空)、summary(只有展示用摘要)或full。thread/turns/list、thread/items/list按 AGENTS.md 规定的游标分页返回历史,注释说它们主要读只追加的 rollout 存储,所以不串行。 - ThreadItem(
codex-rs/app-server-protocol/src/protocol/v2/item.rs:236)是一轮里的一个条目,19 种变体靠type字段区分。core 里对应的是TurnItem,impl From<CoreTurnItem> for ThreadItem负责转换。
线上一轮的节奏是:turn/started,然后每个 item 先 item/started,中间可能有 item/agentMessage/delta、item/commandExecution/outputDelta 之类的增量,最后 item/completed,全部结束再来 turn/completed。完成事件里的 item 才是权威版本,Plan 变体的注释特意提醒:完成的计划 item 不一定等于增量拼接的结果。
thread_history.rs 负责反方向:把磁盘上的 rollout 重放成 turn 与 item。ThreadHistoryBuilder::handle_event 的注释称它是“持久化 rollout 重放”与“运行中线程恢复时追踪当前轮”共用的 reducer,所以它既处理新式的 ItemStarted、ItemCompleted,也处理 ExecCommandBegin、McpToolCallEnd 这类旧事件和早已不能新建的 ThreadRolledBack 标记,老会话才能照常恢复。分页历史走另一条更简单的路:thread_history_projection.rs 里的 project_rollout_line 无状态地投影单行 rollout,文档注明它只适用于以规范化 ItemCompleted 记录条目的新格式,不适用于只有旧事件的 rollout;thread-store 用它把 rollout 增量写进 SQLite 供分页查询。handle_event 的注释还让读者去看 codex-rs/core/rollout/policy.rs 的 should_persist_event_msg,该函数如今在独立的 codex-rs/rollout/src/policy.rs 里。
命名与序列化规范
仓库根 AGENTS.md 为 v2 定了一组规则:新 API 只加在 v2;请求、响应、通知的载荷分别叫 *Params、*Response、*Notification;方法名是 <resource>/<method>、资源名用单数;字段与字符串枚举值一律 camelCase;v2 载荷字段禁止 skip_serializing_if = "Option::is_none";*Params 里每个可选字段标 #[ts(optional = nullable)];ID 在边界上用普通 String;时间戳用 i64 秒、字段名以 _at 结尾;新的列表方法默认游标分页。ThreadStartParams 是现成的样板:
#[derive(
Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi,
)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadStartParams {
#[ts(optional = nullable)]
pub model: Option<String>,
// ...
#[experimental("thread/start.allowProviderModelFallback")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_provider_model_fallback: bool,
// ...
/// Named profile id for this thread. Cannot be combined with `sandbox`.
#[experimental("thread/start.permissions")]
#[ts(optional = nullable)]
pub permissions: Option<String>,(codex-rs/app-server-protocol/src/protocol/v2/thread.rs:57)
布尔字段用 skip_serializing_if = "std::ops::Not::not",省略即 false,这也是 AGENTS.md 明文推荐的写法。规则之外的例外在代码里也看得到:配置类 RPC 为了对齐 config.toml 用 snake_case(AGENTS.md 自己点名的例外);thread/inject_items、thread/increment_elicitation 等方法名带下划线;McpToolCall 的旧字段 mcpAppResourceUri 为兼容保留了 skip_serializing_if。另外,AGENTS.md 提到的 app-server-protocol/src/protocol/v2.rs 在 0.158.0 已经拆成 protocol/v2/ 目录,里面四十多个按领域划分的文件。
实验性 API 怎么闸住
实验开关有两级。方法级是宏里的 #[experimental("method")];字段级靠派生宏 ExperimentalApi:字段标 #[experimental("method.field")],值被“使用”时返回这个理由字符串。什么算使用,规则写在 codex-experimental-api-macros 里:Option 为 Some、Vec 与 map 非空、bool 为真,其余类型一律算。嵌套类型用 #[experimental(nested)] 递归检查。被标记的字段还会经 inventory 登记成 ExperimentalField,导出稳定版 schema 时据此把它们剪掉。
服务端据此把关:连接在 initialize 里没有声明 capabilities.experimentalApi 时,请求用到实验性方法或字段,就收到 <reason> requires experimentalApi capability;出站方向,实验性通知不发给这样的连接,命令审批请求里的实验性字段会被 strip_experimental_fields 抹掉。开关按连接生效,initialize 处理代码里的一条 TODO 承认,同一线程上既有开了、也有没开的客户端时,行为会有点怪。
TypeScript 与 JSON Schema 从哪来
最出人意料的一处在 lib.rs 末尾:
#[cfg(not(test))]
pub(crate) use codex_app_server_protocol_noop_macros::JsonSchema;
#[cfg(not(test))]
pub(crate) use codex_app_server_protocol_noop_macros::TS;
#[cfg(test)]
pub(crate) use schemars::JsonSchema;
#[cfg(test)]
pub(crate) use ts_rs::TS;(codex-rs/app-server-protocol/src/lib.rs:64)
正式构建里,所有类型上的 TS、JsonSchema 派生都是空操作,生成器 export.rs 也只在测试里编译,ts-rs 与 schemars 在这个 crate 里只是开发依赖。空操作宏所在 crate 的文档说得直白:真正的派生只在重新生成导出时才需要,正式构建保留这些标注让定义好读,但不再生成运行时根本用不到的实现。真正的导出发生在 just write-app-server-schema:它运行一个被 #[ignore] 的测试 write_schema_fixtures_from_env,把 ts-rs 与 schemars 的输出写进 schema/typescript(七百多个 .ts 文件)、schema/json(逐类型文件加两个汇总 bundle),并把稳定版、实验版两套导出压成 zstd 放进 schema/precomputed/;随后脚本用 datamodel-codegen 从 v2 bundle 重新生成 Python SDK 的 pydantic 模型。另有测试比对这些提交进仓库的文件与现场生成的结果,防止两边漂移。运行时的 generate-ts 只是解压嵌在二进制里的那份:
const STABLE_EXPORTS: &[u8] =
include_bytes!("../schema/precomputed/app-server-exports-stable.json.zst");
const EXPERIMENTAL_EXPORTS: &[u8] =
include_bytes!("../schema/precomputed/app-server-exports-experimental.json.zst");(codex-rs/app-server-protocol/src/precomputed_exports.rs:15)
用户拿到的类型定义因此总与手上这个版本的二进制对应。稳定版导出会删掉实验性方法与字段,--experimental 选实验版那份。还有一个隐藏的 generate-internal-json-schema,导出的是 rollout 文件每一行的格式 RolloutLine。
v1 还剩什么
AGENTS.md 要求不再给 v1 加任何接口。0.158.0 里 v1 只剩三块:initialize 用的 InitializeParams、InitializeResponse、ClientInfo、InitializeCapabilities;标着 DEPRECATED 的 getConversationSummary、gitDiffToRemote、getAuthStatus 三个方法(后者由 account/read 取代);以及服务端请求 applyPatchApproval、execCommandApproval,注释说它们服务于旧的 SendUserTurn、SendUserMessage 接口,而 app-server 的正式代码已经不再发出它们,TUI 与 codex exec 仍保留处理分支。JSON Schema 导出也只从 v1 目录保留 InitializeParams 与 InitializeResponse。
codex-rs/docs/protocol_v1.md 讲的不是这套 JSON-RPC,而是 core 内部提交队列与事件队列上的 Op、EventMsg。文档开头就声明代码可能与规范不完全一致,0.158.0 里它提到的 Op::ConfigureSession、Op::UserTurn 与 core/src/agent.rs 都已不存在,轮次输入改走 Op::TurnInput。仍然成立的一条是:EventMsg::TurnStarted、TurnComplete 在线上序列化为 task_started、task_complete,同时接受 turn_* 别名。
和《从 LLM 到 Coding Agent》对照
那本书的 streaming 讲的是模型 API 的流:一次响应拆成一串事件,文本与工具参数一片片到达。v2 协议在它之上又抽了一层:模型输出、命令执行、文件修改、MCP 调用都被统一成 item,按“开始、增量、完成”的节奏推给前端,前端不必知道背后是哪家模型、哪种工具。Grok Build 的 ACP 选的是现成的开放协议 ACP;Codex 自定协议,好处是审批、线程管理、配置读写都能按自己的需要设计,代价是每个编辑器都得专门接入。
上一篇:app-server · 所有前端共用的服务端 · 下一篇:守护进程与传输 · 一个服务端,多种连法