你按下回车后,Codex 内部发生了什么
从 TUI 的 Enter 分支一路追到 turn/start、turn/steer、core task、模型流与 typed notification。
在终端里按下 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 入队后的竞态,但顺序容易看反:
- 它先应用这次 turn settings,并用当前 submission id 创建
current_context。这一步发生在 active task 检查之前。 - 随后调用
steer_input(..., expected_turn_id: None)。若此时已有可 steer 的RegularTask,输入会追加到旧 active turn;steer_input返回的旧 turn id 在这里被Ok(_)丢弃。 - 只有收到
NoActiveTurn,它才复用已经创建好的current_context,组装 task input 并spawn_task(..., RegularTask::new())。 - 若 active task 是 review 或 compact,错误事件关联当前 submission id 发出,不会硬塞进不可 steer 的 task。
因此,这条竞态路径没有拿 expected id 做匹配,也不是确认 idle 后才捕获 turn context。它真正延后到 idle 分支的只有 RegularTask 创建。
RegularTask 一启动就发 core 的 EventMsg::TurnStarted,其中 turn_id 是 TurnContext.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 ResponseEvent:Created、OutputItemAdded、OutputItemDone、文本 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。
所以 Completed、Interrupted、Failed 是 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:
input_flow.rs产生InputResult::Submitted后,只记text.chars().count()、附件数量、是否 queued。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,还是已经到达最终状态;这四个位置证明的不是同一件事。