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

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

作者 David更新于 第 5 篇(共 57 篇)

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

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

先约定两个词(与 run_turn 主循环一致):**一轮(turn)**是用户一次输入引发的全部工作,对外有一个 turn ID;一次采样请求是向模型发一次 Responses 请求。模型每调用一次工具,结果都要送回去再问,所以一轮里常有好几次采样。

用户看到的样子

写好一句话按回车:消息立刻出现在对话记录里,状态栏显示 Working,回复一段段流出来;模型要执行需要批准的命令时,底部弹出审批框;回复完毕,状态栏恢复空闲。模型干活时再按回车,新输入会插进当前这一轮,按 Tab 则排到下一轮。审批选项见手册沙箱、审批与安全,程序化接入见 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,只是传输换成了内存通道(见守护进程与传输)。

连上之后、你打字之前,新会话的 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)。按下回车时,一条带着提交循环的线程已经在等你了。

全程一图

图表加载中…

下面按图从上往下走。

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 与内核的边界:

        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。

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

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

    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。

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,见工具系统总览),由 try_run_sampling_request(:2467)调用 client_session.stream(...)(:2504)。ModelClientSession::stream(codex-rs/core/src/client.rs:2215)优先走 Responses 的 WebSocket,不可用时回落到 HTTP,见模型客户端。流回来的 ResponseEvent 里,OutputTextDelta(codex-rs/core/src/session/turn.rs:2907)立刻作为 EventMsg::AgentMessageContentDelta 发往事件队列;Completed(:2857)记账并结束这次采样;OutputItemDone(:2601)交给 handle_output_item_done,其中的工具调用在流还没结束时就派发出去:

    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 主循环与工具系统总览。

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,会直接中断这一轮。完整流程见审批流程。

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 架构与历史记录单元。

这条线在后面怎么展开

这一段细讲的篇目
输入框、事件路由、流式渲染TUI 架构、输入框、历史记录单元
app-server、协议与传输app-server、v2 协议、守护进程与传输
线程、会话与持久化ThreadManager 与 CodexThread、Session 与 TurnContext、会话持久化
主循环与模型run_turn 主循环、模型客户端、上下文与历史、上下文压缩
工具、审批与沙箱工具系统总览、审批流程、权限模型

和《从 LLM 到 Coding Agent》对照

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


上一篇:读源码之前 · 仓库的工程约定 · 下一篇:app-server · 所有前端共用的服务端

本页目录