青雲的博客
拆开 Codex 第二部分 一次 turn 如何运转 第 02 章

你按下回车后,Codex 内部发生了什么

从 TUI 的 Enter 分支一路追到 turn/start、turn/steer、core task、模型流与 typed notification。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

在终端里按下 Enter,界面很快出现一条用户消息,然后状态变成 Working。表面解释很自然:TUI 把文本交给模型,等待模型流式返回。

这句话省掉了几乎所有决定行为的分支。Enter 可能正在确认一个 popup,可能只是粘贴过程中的换行,也可能被 Vim 的 pending operator 消费。即使它真的提交了普通文本,当前 thread 有 active turn 时,输入会尝试 steer 现有 turn;只有 idle 时才 turn/start。而一个新 turn 启动后,还可能经历多次模型 sampling 和工具调用。

这一章只追普通文本 Enter 的主路径,但会把那些容易误判的旁路一起标出来。目标不是背函数名,而是知道:什么时候输入被接收,什么时候 turn 真正开始,什么时候它只是被取消,以及哪个事件才代表最终状态。

可复现实验:观察 exec JSON 的边界

使用官方 0.144.6 CLI,可以运行一个要求模型不要调用工具的最小 turn:

npm exec --yes --package=@openai/[email protected] -- \
  codex exec --json --ephemeral \
  --ignore-user-config --ignore-rules \
  --skip-git-repo-check -C /tmp -s read-only \
  'Do not call any tool. Reply with exactly TRACE_OK.'

这里的“不要调用工具”来自最后一行提示语,不是 -s read-only 的效果。-s read-only 只约束进程执行时使用的沙箱权限;Codex 仍会为这次 sampling 构造工具 router,并把模型可见的工具放进请求。这个实验依赖模型遵守提示,因此它适合观察 exec JSON 事件,不适合证明工具面已经被禁用。

这条命令会调用远端模型,需要可用的登录或 API 配置,并可能产生计费。--ephemeral 避免把这次 exec 作为普通持久会话保留,但它不是“离线模式”。响应 id、token 数、chunk 边界和时间都不固定,稳定断言只应放在最终文本 TRACE_OK 与 turn 状态上。

还有一个容易误读的结果:codex exec --json 的 JSON adapter 白名单保留 thread/turn/item 的 start、update、complete 和 error,却不转发 app-server 的 item/agentMessage/delta。普通 assistant 文本通常以最终 item.completed 出现。终端 JSON 里没看到逐 token delta,不能据此否定 core 内部流式处理,也不能外推到所有 app-server 客户端。

Enter 先经过输入状态机

默认 keymap 把普通 Enter 绑定为 composer submit,同时也把 Enter 放进 editor 的换行绑定。但键位绑定只是入口,不是结论。按键先进入 BottomPane:如果有 modal view,它会把 Enter 交给当前 view,并向外返回 InputResult::None。composer 的 popup 也不是一个统一契约:slash command popup 可以补全或派发命令,file/skill/mention popup 主要插入候选;是否在没有选中项时回退到普通提交,要看具体 popup handler。

粘贴还多一层保护。显式大段 paste 会先保存为 placeholder,提交前才展开。终端若把一次粘贴拆成高速字符和 Enter,paste burst 期间的 Enter 会插入换行,而不是立即把半段文本发出去。slash command 上下文是一个有意例外。

Vim 模式也不是“Enter 永远提交”。normal mode 有 pending operator 时,下一键先交给 textarea;例如等待补全的删除操作可以吃掉 Enter。只有提交、排队或命令派发成功,composer 才把 Vim 状态复位到 normal mode。

普通提交最终收敛为 InputResult::Submitted { text, text_elements }ChatWidget 再把附件、图片和 mention binding 合成 UserMessage。空文本且无附件会被多层过滤;只有图片不算空。若 session 尚未配置、plan 正在 TUI 中流式输出,或 autosend 被弹层抑制,消息先进入 UI 队列。

这意味着“我按了 Enter”只能证明键盘事件发生过。要证明请求已经进入 runtime,至少要继续看到 submit_op(AppCommand::UserTurn);要证明 turn 已启动,还要等后面的 TurnStarted

文本如何变成 typed input

submit_user_message_with_history_and_shell_escape_policy 负责把 UI 对象转换成 app-server protocol 的 UserInput。远程图片、本地图片、文本、skill 和 mention 都是独立的 typed item。普通文本成为:

UserInput::Text {
  text,
  text_elements
}

这里还会处理一个旁路:以 ! 开头的文本可以变成用户 shell command,而不是发给模型。本章讨论的是没有命中这个 shell escape 的普通文本。

随后 TUI 把 cwd、approval policy、permission profile、模型、reasoning effort 和 collaboration mode 等 turn 配置一起装进 AppCommand::UserTurn。这一步还没有调用 turn/start,它只是把一份完整意图交给顶层 App 路由。

当前 agent turn 正在运行时,TUI 不会把这条输入立即渲染成一个新的历史 turn,而是记录为 PendingSteer。这是一个重要的 UI 证据边界:屏幕没有出现新的用户 turn,不表示输入丢了;它可能正在等待 app-server 确认 steer。

active turn 为什么不创建新 turn

顶层 App 收到 AppCommand::UserTurn 后,先查该 thread 缓存的 active turn id。

  • 找到 active turn:调用 turn/steer,并携带 expected_turn_id
  • 没有 active turn:调用 turn/start
  • 缓存说 active、服务端说已经没有:清掉缓存,回落到 turn/start
  • 服务端返回 expected id mismatch:用服务端给出的实际 id 最多重试一次。
  • active turn 是 review 或 compact 等不可 steer 类型:输入不能硬塞进去,TUI 走拒绝/排队语义。

因此,active turn 时再次按 Enter,成功路径是给当前 turn 增加 pending input,不是创建一个相邻的新 turn。只有服务端确认旧 turn 已消失,才会启动新的 turn。这种设计保留了竞态:TUI 的通知缓存可能慢半拍,所以最终状态必须由 app-server 校验,而不能只信本地布尔值。

这里也能看出 turn/steer 和“排队下一轮消息”的区别。steer 的输入会归入当前 active regular turn,影响后续 sampling。review/compact 不接受 steer 时,TUI 可以把消息保留为待处理输入,但它尚未成为 core 当前 turn 的 history。

app-server 在 start 边界做了什么

turn_start_inner 先加载 thread,检查 direct input 是否允许,然后计算所有 text item 的 Unicode 字符数。上限是 1 << 20,等于上限允许,超过才返回结构化输入错误。TUI 自己也会做防御,但 app-server 仍要重新校验,因为协议调用方不只有这个 composer。

接着它把 v2 UserInput 转为 core input,解析 cwd 和环境覆盖,合并 turn settings,构造 Op::UserInput,提交给 core:

TurnStartParams
-> Vec<CoreInputItem>
-> Op::UserInput
-> submit_user_input_with_client_user_message_id
-> submission id
-> Turn { id: submission id, status: InProgress }

这就是 submission id 与 turn id 的准确关系:对一次 turn/start,app-server 直接把这条 Op::UserInput 的 submission id 暴露成 turn id。它没有证明所有 submission 都是 turn;interrupt、审批响应和 shutdown 也走 submission queue,却有自己的生命周期。

还要注意空输入的边界。TUI 的普通文本主路径会过滤空消息,turn/steer 也明确拒绝空 input;但 turn/start 的这段实现没有把空 items 一概判错。不能把“composer 不提交空文本”写成“app-server 所有入口都禁止空 turn”。

从有界队列到 RegularTask

core 的 submission channel 容量是 512,event channel 则是 unbounded。submit_with_id await tx_sub.send 成功,说明 submission 已被队列接收,不表示模型已经开始 sampling。

下面回到 turn/start 进入的 submission 路径。后台 submission_loop 持续 recv;遇到 Op::UserInput 后,user_input_or_turn_inner 处理 start 入队后的竞态,但顺序容易看反:

  1. 它先应用这次 turn settings,并用当前 submission id 创建 current_context。这一步发生在 active task 检查之前。
  2. 随后调用 steer_input(..., expected_turn_id: None)。若此时已有可 steer 的 RegularTask,输入会追加到旧 active turn;steer_input 返回的旧 turn id 在这里被 Ok(_) 丢弃。
  3. 只有收到 NoActiveTurn,它才复用已经创建好的 current_context,组装 task input 并 spawn_task(..., RegularTask::new())
  4. 若 active task 是 review 或 compact,错误事件关联当前 submission id 发出,不会硬塞进不可 steer 的 task。

因此,这条竞态路径没有拿 expected id 做匹配,也不是确认 idle 后才捕获 turn context。它真正延后到 idle 分支的只有 RegularTask 创建。

RegularTask 一启动就发 core 的 EventMsg::TurnStarted,其中 turn_idTurnContext.sub_id。随后它先调用一次 run_turn;只有这次 run_turn 返回后,若 active turn 仍有 pending input,才把 next_input 置为空并再次调用 run_turn。模型工具 follow-up 属于单次 run_turn 内部的 sampling loop,不是 RegularTask 在外层提前发起的新调用。

TurnStarted 只说明 task 生命周期已经建立、core 已接管当前 turn。此时模型可能还没有返回第一个 token,更谈不上成功结束。

完整时序:一次 Enter 到多次 sampling

sequenceDiagram
    accTitle: 普通 Enter 从 TUI 到 turn 完成的时序
    accDescr: TUI Composer 提交 typed input;空闲 thread 由 app-server turn/start 送入 core queue 并启动 RegularTask,活跃 thread 由 turn/steer 追加到 Session;模型可多次 sampling,完成事件再经 app-server 通知 TUI。
    actor User as 用户
    participant Composer as TUI Composer
    participant App as TUI App
    participant Server as app-server
    participant Queue as core Submission Queue
    participant Session as core Session
    participant Task as RegularTask
    participant Turn as run_turn
    participant Model as Responses runtime
    participant Tool as Tool runtime

    User->>Composer: Enter
    alt paste burst / popup / Vim pending operator
        Composer-->>User: 插入、选择或消费按键
    else 普通有效输入
        Composer->>App: AppCommand::UserTurn
        alt active regular turn
            App->>Server: turn/steer(expectedTurnId)
            Server->>Session: steer_input
            Session->>Task: 追加 pending input
        else idle
            App->>Server: turn/start
            Server->>Queue: Op::UserInput
            Queue->>Task: spawn RegularTask
            Task-->>Server: TurnStarted event
        end
        loop RegularTask:每轮只调用一次 run_turn
            Task->>Turn: run_turn(next_input)
            loop run_turn 内部 sampling loop
                Turn->>Model: sampling request
                alt FunctionCall
                    Model-->>Turn: tool call item
                    Turn->>Tool: dispatch
                    Tool-->>Turn: ResponseInputItem
                    Turn->>Model: 下一次 sampling
                else assistant final message
                    Model-->>Turn: completed
                end
            end
            Turn-->>Task: run_turn 返回
            alt 返回后仍有 pending input
                Task->>Task: next_input = []
                Note over Task,Turn: 只有下一轮 RegularTask loop 才再次调用 run_turn
            else 返回后没有 pending input
                Task-->>Server: TurnComplete / TurnAborted
            end
        end
        Server-->>App: turn/completed typed notification
        App-->>User: 更新 transcript 与状态
    end

这张图里有两种“继续”,不能混在一起。run_sampling_request 自己可以因可重试的 stream error 重试同一个 sampling;而工具结果、pending input、end_turn=false 或 stop hook continuation 会让外层 turn loop 发起下一次 sampling。前者是传输恢复,后者是 Agent 控制流。

WS 和 SSE 只改变传输,不改变上层事件

ModelClientSession 在同一 turn 内复用,既缓存 WebSocket,也保留 sticky routing state。是否使用 Responses WebSocket,由 provider capability 和 session 是否已经降级共同决定。WebSocket 不可用或收到需要升级等失败时,runtime 可以退回 HTTP Responses API;HTTP 流使用 SSE telemetry。

run_turn 来说,两条 transport 最终都提供 typed ResponseEventCreatedOutputItemAddedOutputItemDone、文本 delta、Completed 等。它不需要在工具循环里分别实现一份 WS parser 和一份 SSE parser。

这也是为什么不能从界面有没有逐字输出,反推底层一定走了哪种 transport。流式语义在 typed event 层已经被统一,客户端是否继续转发每一种 delta,又是更上层的选择。

run_turn 每一步从 session history 重新构造 prompt,调用 run_sampling_request,再把模型或 pending input 是否需要 follow-up 合并判断。只有不需要 follow-up,且 stop hook 没要求继续时,循环才退出。

终态从哪里来

core 和 app-server 对终态使用的词不完全一样。

core task 正常收尾时发 TurnComplete;取消路径发 TurnAborted。运行中影响 turn status 的 error 会被 app-server 记录。app-server 收到 TurnComplete 后,有 terminal error 就映射为 Failed,没有则是 Completed;收到 TurnAborted 映射为 Interrupted。最后三种状态都放进同一种 turn/completed typed notification。

所以 CompletedInterruptedFailed 是 app-server TurnStatus 的三个结果,不是 core 发出的三个平行事件。这个区别对日志分析很重要:只搜索字符串 Failed,可能错过真正的 core error;只看到 TurnComplete,也要检查它是否携带此前记录的 terminal error。

取消不是回滚

Op::Interrupt 的协议注释明确限定:中止当前 task,但不终止后台 terminal process。core 的 interrupt 会取消 task token、等待短暂 grace,必要时 abort task,再发 TurnAborted。interrupted marker 不是必然写入:只有 agent_interrupt_message_enabled 开启时,core 才会按 multi-agent 版本生成 marker,写入 history 并在发出 TurnAborted 前尝试 flush;配置关闭时会直接跳过这一步。

它没有把已经写入 history 的 tool call、已经修改的文件、已经启动的外部进程自动恢复到之前状态。thread rollback 是另一条独立 Op。把 Ctrl+C 理解成事务 rollback,会高估 runtime 能提供的保证。

interrupt 处理完以后,submission_loop 返回到 recv,session 仍能接收后续输入。只有 Shutdown 或 channel 关闭才结束这条长期循环。于是“当前 turn 被取消”和“session loop 结束”是两个状态;后者不会销毁 thread 的持久身份。

源码依据

本章把证据分在五个状态边界上:composer 只证明输入形成,TUI routing 决定 steer 或 start;app-server 对 start 返回入队后的 InProgress turn,对 steer 直接追加 active turn input;RegularTask 发出真正的 lifecycle start,app-server typed completion 才给出 Completed、Interrupted 或 Failed。

尚未覆盖的边界包括 realtime conversation、review/compact 的完整 task 实现、resume 后的通知重放,以及模型 provider 的全部重试策略。时序图也没有表示每一种 hook 和 compaction 分支。它只描述普通文本进入 regular turn 的主路径。

动手改一个地方

可以在两个位置加一组不记录正文的 trace:

  1. input_flow.rs 产生 InputResult::Submitted 后,只记 text.chars().count()、附件数量、是否 queued。
  2. turn.rs 处理 OutputTextDelta 时,只累计本次 delta 的 UTF-8 byte 数,不记录 delta 内容。

建议字段:

submission_input_chars
local_image_count
remote_image_count
queued
turn_id
delta_bytes

这样可以回答“输入有多大、何时排队、同一 turn 收到多少流式字节”,又不会把用户正文、图片地址或模型输出复制进 trace。不要使用 response chunk 数作为稳定业务指标:WS/SSE 分片、网络重试和 adapter 过滤都可能改变它。

局部验证可以用一个 mock stream 连续发两个 OutputTextDelta,断言累计字节数,再确认 JSON adapter 仍只输出最终 item。这个测试比在日志里匹配真实回复更稳定。

失败边界

输入被 composer 接收,不代表 app-server 接收;app-server 返回 InProgress,不代表 TurnStarted 已到;TurnStarted 不代表模型已有输出;收到 final assistant item,也要等 tool future、stop hook 和 pending input 收敛;TurnComplete 还要经过 error reduction 才成为外部的 Completed 或 Failed。

取消同样没有跨越这些边界反向清理。已经发生的文件写入、工具副作用和后台进程需要各自的恢复或终止机制。runtime 能保证的是停止当前 task 的推进并给出 Interrupted 语义,不是把机器恢复到按 Enter 之前。

这一章建立了什么

普通 Enter 不等于新 turn。它可能停在 composer,也可能 steer 当前 turn;只有 turn/start 才把 Op::UserInput 送进 submission queue,并在确认 idle 后创建 RegularTask

排障时别只问“Enter 有没有生效”。要看输入停在 composer、RPC、TurnStarted,还是已经到达最终状态;这四个位置证明的不是同一件事。