# 一条消息的生命周期 · 从按下回车到看到回复

> 在 TUI 里按下回车，输入先变成一个 AppCommand，再变成发给 app-server 的 turn/start 请求；app-server 把它交给 codex-core 的提交队列，内核起一个 RegularTask 反复调用 run_turn：流式请求模型、边收边派发工具、需要时经 app-server 向界面要审批；内核的事件经每线程一个的监听任务翻译成 v2 通知，回到 TUI 变成流式渲染的历史记录单元。turn/start 立即返回，回复全靠通知。

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

# 一条消息的生命周期 · 从按下回车到看到回复

前面四篇讲了 Codex 是什么、怎么构建、怎么分层、有哪些约定。这一篇把它们串成一条线：你在 TUI 里敲一句话、按下回车，到回复逐字出现在屏幕上，中间每一跳落在哪个文件的哪一行。它是全书的骨架，只走主干，细节一律链接到后续篇目。

先约定两个词（与 [run_turn 主循环](https://daiw.net/manual/codex-source/turn-loop)一致）：**一轮（turn）**是用户一次输入引发的全部工作，对外有一个 turn ID；**一次采样请求**是向模型发一次 Responses 请求。模型每调用一次工具，结果都要送回去再问，所以一轮里常有好几次采样。

## 用户看到的样子

写好一句话按回车：消息立刻出现在对话记录里，状态栏显示 Working，回复一段段流出来；模型要执行需要批准的命令时，底部弹出审批框；回复完毕，状态栏恢复空闲。模型干活时再按回车，新输入会插进当前这一轮，按 `Tab` 则排到下一轮。审批选项见手册[沙箱、审批与安全](https://daiw.net/manual/codex/sandbox-approvals)，程序化接入见 [SDK 与 App Server](https://daiw.net/manual/codex/sdk-app-server)。想亲眼看协议层的往来，可以运行 `codex debug app-server send-message-v2 "hello"`，它起一个 `codex app-server` 子进程，依次打印 `initialize`、`thread/start`、`turn/start` 的响应和随后的通知。

## 开始之前：前端连上了谁

`codex` 不带子命令时，`cli_main` 把控制权交给 `codex_tui::run_main`（`codex-rs/tui/src/lib.rs:1086`）。TUI 从不直接调用内核，先要决定连哪个 app-server，记在 `AppServerTarget`（`codex-rs/tui/src/lib.rs:312`）里。这个版本的功能开关 `daemon_auto_start` 已是稳定功能、默认开启（`codex-rs/features/src/lib.rs:935`），所以默认是拉起或复用本机共享的后台 app-server，经 Unix socket 连上去（`codex-rs/tui/src/startup_orchestration.rs:494`）；带 `--no-daemon`、`--oss`、`--profile`、多数 `-c` 覆盖等参数时（排除条件见 `codex-rs/tui/src/daemon_startup.rs:25`）改在本进程里内嵌一个，`codex exec` 则总是内嵌；`--remote` 连远程端点。三种情况对上层是同一个类型 `AppServerClient`，只有 `InProcess` 与 `Remote` 两个变体（`codex-rs/app-server-client/src/lib.rs:345`），内嵌的那份与独立进程共用同一个 `MessageProcessor`，只是传输换成了内存通道（见[守护进程与传输](https://daiw.net/manual/codex-source/daemon-and-transport)）。

连上之后、你打字之前，新会话的 TUI 已经发过一个 `thread/start`（`codex-rs/tui/src/app_server_session.rs:743`）。app-server 据此调用 `ThreadManager::start_thread`（`codex-rs/core/src/thread_manager.rs:1036`），最终 `Session::spawn` 建好两条通道，容量 512 的提交队列与不限容量的事件队列，并起一个常驻任务 `submission_loop`（`codex-rs/core/src/session/mod.rs:928`）。`codex-protocol` 开头的注释管这叫 SQ（Submission Queue）与 EQ（Event Queue）模式（`codex-rs/protocol/src/protocol.rs:3`）。按下回车时，一条带着提交循环的线程已经在等你了。

## 全程一图

```mermaid
sequenceDiagram
    participant U as 用户
    participant T as TUI<br/>ChatWidget 与 App
    participant S as app-server<br/>MessageProcessor
    participant K as codex-core<br/>CodexThread 与 Session
    participant R as RegularTask<br/>run_turn
    participant M as 模型<br/>Responses API
    participant X as 工具<br/>ToolRouter 与 ToolOrchestrator

    U->>T: 输入并按回车
    T->>S: turn/start，经 app-server-client
    S->>K: start_or_steer_turn，提交 TurnInput
    K->>R: spawn_task 起一个 RegularTask
    K-->>S: Started，turn ID 即提交 ID
    S-->>T: TurnStartResponse，状态 InProgress
    loop 每次采样请求
        R->>M: ModelClientSession.stream
        M-->>R: 流式 ResponseEvent
        R-->>S: EventMsg，经事件队列
        S-->>T: item/agentMessage/delta 等通知
        opt 模型要调用工具
            R->>X: handle_tool_call
            opt 需要审批
                X-->>S: ExecApprovalRequest 事件
                S-->>T: item/commandExecution/requestApproval
                U->>T: 批准或拒绝
                T->>S: 回复这个服务端请求
                S->>K: 提交 ExecApproval
                K-->>X: 唤醒等待中的审批
            end
            X-->>R: 工具输出记入历史，需要再采样
        end
    end
    R-->>S: TurnComplete 事件
    S-->>T: turn/completed
```

下面按图从上往下走。

## 1. 回车变成 turn/start

1. 输入框 `ChatComposer` 默认的提交键是回车，按下后走 `handle_submission`（`codex-rs/tui/src/bottom_pane/chat_composer.rs:3529`），产出 `InputResult::Submitted`，斜杠命令在这一步就被分流走了。
2. `ChatWidget::handle_composer_input_result`（`codex-rs/tui/src/chatwidget/input_flow.rs:21`）决定立即发送还是排队。发送时构造 `AppCommand::user_turn(...)`（`codex-rs/tui/src/chatwidget/input_submission.rs:443`），带上新生成的 `client_user_message_id`、当前目录、审批策略、模型与推理强度；消息先乐观地画进对话记录，再由 `submit_op` 发出 `AppEvent::CodexOp`（`codex-rs/tui/src/chatwidget.rs:1844`）。
3. `App` 的事件循环接住它（`codex-rs/tui/src/app/event_dispatch.rs:983`），来到 `AppCommand::UserTurn` 分支（`codex-rs/tui/src/app/thread_routing.rs:736`）：线程上有正在进行的一轮，就发 `turn/steer` 把输入插进去；否则调用 `AppServerSession::turn_start`（`codex-rs/tui/src/app_server_session.rs:1277`），把 `thread_id`、值为 `"user"` 的 `turn_trigger`、输入项与模型等填进 `TurnStartParams`，发出 `ClientRequest::TurnStart`。

## 2. app-server：分派并交给线程

`AppServerClient::request_typed`（`codex-rs/app-server-client/src/lib.rs:726`）按变体分派。内嵌时请求以类型化的 `ClientRequest` 进入 `MessageProcessor::process_client_request`（`codex-rs/app-server/src/message_processor.rs:688`），省掉 JSON 反序列化；连守护进程或远程时，它被编码成 JSON-RPC，到对端的 `process_request`（`:625`）再解出来。两条路在 `handle_client_request`（`:925`）会合：`dispatch_initialized_client_request`（`:964`）检查握手与实验性接口，对会启动工作的请求做准入检查，再按协议声明的 `serialization: thread_id(params.thread_id)` 放进该线程的串行队列；`handle_initialized_client_request` 的 `ClientRequest::TurnStart` 分支（`:1620`）把它交给 `TurnRequestProcessor::turn_start`（`codex-rs/app-server/src/request_processors/turn_processor.rs:174`）。

`turn_start_inner` 找到线程、校验输入长度、整理好本轮的覆盖设置，然后跨过 app-server 与内核的边界：

```rust
        let submission = thread
            .start_or_steer_turn(
                TurnInputRequest::new(input)
                    .with_thread_settings(thread_settings)
                    // ...
                    .with_trace(self.request_trace_context(&request_id).await),
            )
            .await
            // ...
        let (turn_id, started) = match submission {
            TurnInputSubmission::Started { turn_id } => (turn_id, true),
            TurnInputSubmission::Steered { turn_id } => (turn_id, false),
```

（`codex-rs/app-server/src/request_processors/turn_processor.rs:651`）

拿到 turn ID，处理器就回一个 `status` 为 `InProgress`、`items` 为空的 `Turn`（`:704`）。**`turn/start` 不等模型回复**，一轮刚启动请求就结束了，之后的一切都走通知。app-server 的内部机制见 [app-server](https://daiw.net/manual/codex-source/app-server-architecture)。

## 3. codex-core：提交队列与一轮的诞生

`CodexThread::start_or_steer_turn`（`codex-rs/core/src/codex_thread.rs:352`）把请求包成一个 `Op` 放进提交队列，只等内核给出路由决定：

```rust
    pub(crate) async fn submit_turn_input(
        &self,
        mut request: TurnInputRequest,
        mode: TurnInputMode,
    ) -> CodexResult<TurnInputSubmission> {
        let id = new_submission_id();
        let (reply_tx, reply_rx) = oneshot::channel();
        let trace = request.trace.take();
        self.submit_with_id(Submission {
            id,
            op: Op::TurnInput {
                request: Box::new(request),
                mode,
                reply: reply_tx,
            },
            // ...
        })
        .await?;
        reply_rx.await.unwrap_or(Err(CodexErr::InternalAgentDied))
    }
```

（`codex-rs/core/src/session/mod.rs:993`）

`submission_loop`（`codex-rs/core/src/session/handlers.rs:418`）逐个处理提交，`Op::TurnInput` 分支（`:483`）进入 `start_or_steer`（`codex-rs/core/src/session/turn_input.rs:276`）：先尝试把输入插进正在进行的一轮；没有的话，按本轮设置构造 `TurnContext`，调用 `session.spawn_task(turn_context, task_input, RegularTask::new())`（`:365`）。提交 ID 由 `new_submission_id` 生成，是一个 UUIDv7，它同时就是对外的 turn ID。

`Session::spawn_task`（`codex-rs/core/src/tasks/mod.rs:270`）经 `start_task` 用 `tokio::spawn` 起一个后台任务（`:363`）；`Session` 的文档注释写明，一个会话同一时刻最多只有一个运行中的任务（`codex-rs/core/src/session/session.rs:57`）。`RegularTask::run` 先发出 `TurnStarted` 事件，再循环调用 `run_turn`（`codex-rs/core/src/tasks/regular.rs:105`），直到没有待处理的输入。分层细节见 [Session 与 TurnContext](https://daiw.net/manual/codex-source/session-and-turn-context)。

## 4. run_turn：采样、流式与工具

`run_turn`（`codex-rs/core/src/session/turn.rs:163`）先做开轮准备（必要时压缩上下文、记录环境变化、跑 hook、把用户输入写进历史），然后进入循环。每一圈从历史生成模型可见的输入，经 `run_sampling_request`（`:1581`）与 `build_prompt`（`:1552`，附上工具清单，并把 `Prompt` 的 `parallel_tool_calls` 设为真；请求里最终是否并行还要看模型是否走 Responses Lite，见[工具系统总览](https://daiw.net/manual/codex-source/tool-architecture)），由 `try_run_sampling_request`（`:2467`）调用 `client_session.stream(...)`（`:2504`）。`ModelClientSession::stream`（`codex-rs/core/src/client.rs:2215`）优先走 Responses 的 WebSocket，不可用时回落到 HTTP，见[模型客户端](https://daiw.net/manual/codex-source/model-client)。流回来的 `ResponseEvent` 里，`OutputTextDelta`（`codex-rs/core/src/session/turn.rs:2907`）立刻作为 `EventMsg::AgentMessageContentDelta` 发往事件队列；`Completed`（`:2857`）记账并结束这次采样；`OutputItemDone`（`:2601`）交给 `handle_output_item_done`，其中的工具调用在流还没结束时就派发出去：

```rust
    match ToolRouter::build_tool_call(item.clone()) {
        // The model emitted a tool call; log it, persist the item immediately, and queue the tool execution.
        Ok(Some(call)) => {
            // ...
            record_completed_response_item(ctx.sess.as_ref(), ctx.step_context.as_ref(), &item)
                .await;

            let cancellation_token = ctx.cancellation_token.child_token();
            let tool_future: InFlightFuture<'static> = Box::pin(
                ctx.tool_runtime
                    .clone()
                    .handle_tool_call(call, cancellation_token),
            );

            output.needs_follow_up = true;
            output.tool_future = Some(tool_future);
        }
```

（`codex-rs/core/src/stream_events_utils.rs:323`）

`ToolCallRuntime::handle_tool_call`（`codex-rs/core/src/tools/parallel.rs:77`）当场 spawn 执行：支持并行的工具共享一把读锁，其余的独占写锁（`:203`）。之后经 `ToolRouter::dispatch_tool_call_with_state`（`codex-rs/core/src/tools/router.rs:327`）与 `ToolRegistry::dispatch_any_with_state`（`codex-rs/core/src/tools/registry.rs:526`）找到处理器；执行命令、改文件这类有副作用的工具，再经 `ToolOrchestrator::run`（`codex-rs/core/src/tools/orchestrator.rs:122`）走“审批、选沙箱、执行、被拒时升级重试”的固定流程。工具输出写回历史，`needs_follow_up` 让 `run_turn` 再采样一次；模型只给文字、也没有待插入的输入时，`if !needs_follow_up` 分支（`codex-rs/core/src/session/turn.rs:640`）跑完 Stop hook 后收尾。详见 [run_turn 主循环](https://daiw.net/manual/codex-source/turn-loop)与[工具系统总览](https://daiw.net/manual/codex-source/tool-architecture)。

## 5. 审批：一次往返

需要批准时，`Session::request_command_approval`（`codex-rs/core/src/session/mod.rs:2792`）先把一个 oneshot 发送端登记到本轮状态里，发出 `EventMsg::ExecApprovalRequest`（`:2859`），然后原地等待。app-server 把这个事件变成发给客户端的服务端请求 `item/commandExecution/requestApproval`（`codex-rs/app-server/src/bespoke_event_handling.rs:804`）。你在审批框里的选择从 `codex-rs/tui/src/bottom_pane/approval_overlay.rs:407` 变成 `AppCommand::ExecApproval`，由 `try_resolve_app_server_request`（`codex-rs/tui/src/app/thread_routing.rs:1061`）作为该请求的响应发回；app-server 在 `on_command_execution_request_approval_response`（`codex-rs/app-server/src/bespoke_event_handling.rs:1960`）里把它换算成内核的 `ReviewDecision`，提交 `Op::ExecApproval`（`:2077`）；内核的提交循环（`codex-rs/core/src/session/handlers.rs:569`）用 `notify_approval` 唤醒那个等待中的工具。选“取消”对应 `ReviewDecision::Abort`，会直接中断这一轮。完整流程见[审批流程](https://daiw.net/manual/codex-source/approvals-flow)。

## 6. 事件回到屏幕

内核的每个事件都经 `Session::send_event`（`codex-rs/core/src/session/mod.rs:2323`）写进会话记录并放入事件队列。app-server 为每个线程起一个监听任务 `ensure_listener_task_running`（`codex-rs/app-server/src/request_processors/thread_lifecycle.rs:214`），循环等待 `conversation.next_event()`（`:302`），交给 `apply_bespoke_event_handling`（`codex-rs/app-server/src/bespoke_event_handling.rs:141`）翻译成 v2 通知，文字增量 `AgentMessageContentDelta` 在 `:1000` 映射为 `item/agentMessage/delta`。内嵌时，通知变成内存通道里的 `InProcessServerEvent::ServerNotification`（`codex-rs/app-server/src/in_process.rs:745`）；连守护进程时则是 socket 上的一行 JSON-RPC。回到 TUI：

1. 主循环从 `app_server.next_event()` 取到事件（`codex-rs/tui/src/app/startup.rs:1187`），`App::handle_app_server_event`（`codex-rs/tui/src/app/app_server_events.rs:63`）按线程放进各自的事件通道（`enqueue_thread_notification`，`codex-rs/tui/src/app/thread_routing.rs:1142`）；
2. 当前显示的线程的事件被取出后，`handle_thread_event_now`（`:2013`）把通知交给 `ChatWidget::handle_server_notification`（`codex-rs/tui/src/chatwidget/protocol.rs:4`）；
3. `AgentMessageDelta` 分支（`:104`）调用 `handle_streaming_delta`（`codex-rs/tui/src/chatwidget/streaming.rs:573`）：增量先进 `StreamController`，没写完的尾巴以 `StreamingAgentTailCell` 实时显示，定时的提交节拍把写完的行做成历史记录单元，经 `add_boxed_history`（`codex-rs/tui/src/chatwidget.rs:1207`）发出 `AppEvent::InsertHistoryCell`，由 `App` 插进终端的滚动区（`codex-rs/tui/src/app/event_dispatch.rs:817`）。

模型不再要工具时任务结束，`on_task_finished`（`codex-rs/core/src/tasks/mod.rs:621`）发出 `EventMsg::TurnComplete`（`:834`），它变成 `turn/completed` 通知，状态栏回到空闲。渲染这一端见 [TUI 架构](https://daiw.net/manual/codex-source/tui-architecture)与[历史记录单元](https://daiw.net/manual/codex-source/history-cells)。

## 这条线在后面怎么展开

| 这一段 | 细讲的篇目 |
| --- | --- |
| 输入框、事件路由、流式渲染 | [TUI 架构](https://daiw.net/manual/codex-source/tui-architecture)、[输入框](https://daiw.net/manual/codex-source/chat-composer)、[历史记录单元](https://daiw.net/manual/codex-source/history-cells) |
| app-server、协议与传输 | [app-server](https://daiw.net/manual/codex-source/app-server-architecture)、[v2 协议](https://daiw.net/manual/codex-source/app-server-protocol)、[守护进程与传输](https://daiw.net/manual/codex-source/daemon-and-transport) |
| 线程、会话与持久化 | [ThreadManager 与 CodexThread](https://daiw.net/manual/codex-source/thread-manager)、[Session 与 TurnContext](https://daiw.net/manual/codex-source/session-and-turn-context)、[会话持久化](https://daiw.net/manual/codex-source/rollout-and-storage) |
| 主循环与模型 | [run_turn 主循环](https://daiw.net/manual/codex-source/turn-loop)、[模型客户端](https://daiw.net/manual/codex-source/model-client)、[上下文与历史](https://daiw.net/manual/codex-source/context-history)、[上下文压缩](https://daiw.net/manual/codex-source/compaction) |
| 工具、审批与沙箱 | [工具系统总览](https://daiw.net/manual/codex-source/tool-architecture)、[审批流程](https://daiw.net/manual/codex-source/approvals-flow)、[权限模型](https://daiw.net/manual/codex-source/permissions-model) |

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

[agent 循环](https://daiw.net/manual/llm-to-agent/agent-loop)那篇的最小实现是一个 `while`：问模型，有工具就执行，把结果喂回去，再问。`run_turn` 里的循环就是它，`needs_follow_up` 就是那个“还要不要再问”。差别在外面：Codex 在流还没结束时就开始执行工具（对应[流式工具执行](https://daiw.net/manual/llm-to-agent/streaming-tool-execution)），审批也不是一个阻塞的 `input()`，而是穿过 app-server 的一次请求往返（对应[权限](https://daiw.net/manual/llm-to-agent/permissions)）。再和 [OpenCode](https://daiw.net/manual/opencode/message-lifecycle) 比一比：它的 `session.prompt` 请求要等整轮对话结束才返回，另有 `prompt_async` 立即返回；Codex 的 `turn/start` 只有一种形态，一轮刚启动就返回，进度全靠通知。

---

上一篇：[读源码之前 · 仓库的工程约定](https://daiw.net/manual/codex-source/reading-the-source) · 下一篇：[app-server · 所有前端共用的服务端](https://daiw.net/manual/codex-source/app-server-architecture)
