# v2 协议 · thread、turn 与 item

> app-server 的线上格式全部定义在 codex-app-server-protocol 一个 crate 里：几个声明宏把 170 个客户端请求、11 个服务端请求与 85 种通知连同参数、响应、实验开关和串行范围一次声明清楚；数据模型是 thread 包含 turn、turn 包含 item。TypeScript 与 JSON Schema 在测试里生成、压缩后嵌进二进制，v1 只剩握手类型和几个弃用方法。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/app-server-protocol
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# v2 协议 · thread、turn 与 item

[上一篇](https://daiw.net/manual/codex-source/app-server-architecture)看的是 app-server 怎么收发消息，这一篇看消息本身。所有线上类型都在 `codex-rs/app-server-protocol`（crate 名 `codex-app-server-protocol`），服务端、TUI、`codex exec` 与各种客户端共用这一份定义。

## 用户看到的样子

协议是双向 JSON-RPC：客户端发请求，服务端回响应、推通知，也会反过来向客户端发请求。握手流程、主要方法与示例见手册[SDK 与 App Server](https://daiw.net/manual/codex/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!`。每个方法一项，写明线上名字、参数类型、响应类型，客户端请求还要声明串行化范围：

```rust
    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` 就是[上一篇](https://daiw.net/manual/codex-source/app-server-architecture)按资源串行的依据，可选 `None`、`global`、`global_shared_read`、`thread_id`、`thread_or_path` 等几种写法，由 `serialization_scope_expr!` 展开。宏把这些声明展开成带 serde 标签的枚举：

```rust
        /// 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 的核心是三层嵌套：

```mermaid
flowchart LR
  TH[Thread<br/>id、status、historyMode、source] -->|turns| TU[Turn<br/>id、status、itemsView、error]
  TU -->|items| IT[ThreadItem<br/>按 type 字段区分]
  IT --> A[对话：userMessage、agentMessage、reasoning、plan]
  IT --> B[动作：commandExecution、fileChange、mcpToolCall、dynamicToolCall]
  IT --> C[其它：collabAgentToolCall、webSearch、imageView、contextCompaction 等]
```

- **Thread**（`codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs:204`）是一段对话。id 由 Codex 生成时是 UUIDv7；还有 `sessionId`、分叉来源 `forkedFromId`、子代理的 `parentThreadId`、`ephemeral`（不落盘）、`historyMode`（`legacy` 或 `paginated`，见[会话持久化](https://daiw.net/manual/codex-source/rollout-and-storage)）、运行状态 `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` 是现成的样板：

```rust
#[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` 末尾：

```rust
#[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` 只是解压嵌在二进制里的那份：

```rust
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`。

<Callout type="warn">

  `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_*` 别名。

</Callout>

## 和《从 LLM 到 Coding Agent》对照

那本书的 [streaming](https://daiw.net/manual/llm-to-agent/streaming) 讲的是模型 API 的流：一次响应拆成一串事件，文本与工具参数一片片到达。v2 协议在它之上又抽了一层：模型输出、命令执行、文件修改、MCP 调用都被统一成 item，按“开始、增量、完成”的节奏推给前端，前端不必知道背后是哪家模型、哪种工具。[Grok Build 的 ACP](https://daiw.net/manual/grok-build/acp) 选的是现成的开放协议 ACP；Codex 自定协议，好处是审批、线程管理、配置读写都能按自己的需要设计，代价是每个编辑器都得专门接入。

---

上一篇：[app-server · 所有前端共用的服务端](https://daiw.net/manual/codex-source/app-server-architecture) · 下一篇：[守护进程与传输 · 一个服务端，多种连法](https://daiw.net/manual/codex-source/daemon-and-transport)
