雨天小六

读懂 Codex(2.12):TUI 怎样把用户动作翻译为 Runtime 操作

· 更新于 2026-08-02 · 专栏:读懂 Codex

#Codex#Agent Runtime#软件架构#TUI#AppCommand

在当前 Codex 架构里,“TUI 把按键翻译成 Op”只是一个便于记忆的简称,严格说并不准确。TUI 已经是 App Server 的客户端:控件先生成 AppCommand,App 再把它翻译成 typed ClientRequest,App Server 最后才提交 Core Op、调用 steer_input,或完成一个正在等待的 审批回调。

这层间接性不是多余包装。它让同一套 Core 可以服务 TUI、IDE 与其他 JSON-RPC 客户端,也让 TUI 的 草稿恢复、弹窗、乐观渲染和快捷键语义留在界面层,而不会泄漏进 Runtime 协议。

五层翻译,而不是一次 enum 转换

一条用户动作要穿过五个边界:

  1. Interaction/Slash dispatch 判断按键属于编辑、弹窗、命令还是提交;
  2. ChatWidget 把草稿解析成 UserInput,冻结本次 Turn 上下文;
  3. AppCommand 表达与具体控件无关的 TUI 动作;
  4. AppEvent::CodexOp 把动作交给 App 的串行事件循环;
  5. Thread routing 选择 turn/startturn/steerturn/interrupt 等 App Server 方法。
TUI 从用户动作经过 ChatWidget、AppCommand、AppEvent、App Server 最终抵达 Core 的分层翻译链
图 2.12-1:AppCommand 是 TUI 内部动作协议,不是 Core Op 的别名。

AppCommand 覆盖 UserTurn、Interrupt、Review、Compact、UserShell、设置覆盖以及各种审批答案。它的价值 在于切断控件与传输的直接依赖:ChatWidget 不必持有 AppServerSession,App 也不必理解 composer 如何 保存 mention binding。

普通提交先过输入门

submit_user_message_with_history_and_shell_escape_policy 是普通输入的关键收口。它不是“拿到字符串就 发送”,而是按顺序处理:

  • SessionConfigured 尚未到达:把原消息和 history policy 放回队首;
  • 文本和图片都为空:拒绝;
  • 当前模型不支持图片:恢复文本、text elements、local/remote images 和 mention bindings;
  • 文本以 ! 开头且允许 shell escape:转为 RunUserShellCommand
  • 其他输入:组装 Image、LocalImage、Text、Skill 和 Mention 项。

这里恢复“完整草稿”很重要。若只恢复显示文本,用户选中的 skill/plugin/app mention 会退化为普通 $name 字符,重试时语义已经变化。

def accept_composer_message(widget, message):
    if not widget.session_configured:
        widget.queue_front(message)
        return ACCEPTED_BUT_DEFERRED

    if message.has_no_text_or_images():
        return REJECTED

    if message.has_images() and not widget.model_supports_images():
        widget.restore_draft_with_bindings(message)
        return REJECTED

    if message.text.startswith("!") and widget.shell_escape_allowed:
        return emit(AppCommand.run_user_shell(message.text[1:].strip()))

    items = map_images_and_text(message)
    items += resolve_skill_plugin_app_mentions(message)
    widget.inject_ide_context(items)
    return build_user_turn(items, widget.effective_turn_settings())

UserTurn 冻结哪些上下文

AppCommand::UserTurn 除 items 外还携带 cwd、approval policy、active permission profile、model、 reasoning effort、summary、service tier、final output schema、collaboration mode 和 personality。

这些字段选择的是“本次提交看到的有效值”。用户按下 Enter 后即使马上切模型,也不能让已经排队的消息 偷偷改用新模型。App routing 随后把权限 profile 与 runtime workspace roots materialize 成 App Server TurnStartParams

Personality 还有双重门:配置 Feature 必须启用,当前模型也必须声明支持。Collaboration mode 同样只在 相关能力启用时发送。界面里能选择某项,不代表请求可以无条件带上它。

Mention 不是字符串替换

TUI 先从文本中收集 tool mention,再与明确的 mention_bindings 合并:

  • skill://path 解析为 UserInput::Skill {name,path}
  • plugin://config_name 解析为 Mention;
  • app://id 只有 connector 存在且可 mention 时才接受;
  • 绑定与自动扫描都用集合去重,避免一个对象被发送两次。

跨 Session 的消息历史不能只存展开后的对象,因此 TUI 把绑定编码为可恢复的 placeholder;用户从历史 召回文本时,composer 能重建 mention 与路径的对应关系。

为什么先画出用户消息

AppEvent 在同一个循环中串行处理,而远程 turn/start 可能等待连接、环境或网络。为避免用户按下 Enter 后界面像“没反应”,App-event 路径会先把 prompt 乐观写入 transcript,再发请求。

这只是显示承诺,不是 Runtime 确认。TUI 同时保留 safety buffer;如果 turn/start 被服务端拒绝, handle_turn_start_rejection 负责恢复状态并显示错误。直接提交路径不共享这条 App 事件队列,因此保留 原来的失败处理。

async def submit_from_widget(message):
    command = parse_and_freeze(message)
    if command.is_new_user_turn:
        transcript.render_optimistically(message)
        safety_buffer.remember(message)

    try:
        await app.submit_active_thread_command(command)
    except TurnStartRejected as error:
        transcript.reconcile_rejected_prompt(error)

Start 与 Steer 的竞态

同一个用户动作是新 Turn 还是 steering,不由 ChatWidget 最终决定。App 查询每个 Thread 的本地 event store:有 active turn id 时先发 turn/steer(expectedTurnId),没有才发 turn/start

本地缓存来自异步 notification,天然可能落后于服务端。因此路由显式处理三个竞态:

  1. 服务端返回 NoActiveTurn:旧 Turn 已结束但通知未到,清掉缓存并 fallback 到 start;
  2. ExpectedTurnMismatch:Review 等流程已经换了 Turn,写入 actual id 并仅重试一次;
  3. ActiveTurnNotSteerable:Review/Compact 正在运行,尝试把消息排队;不能排队则显示结构化错误。
TUI 根据活动 Turn 缓存选择 turn steer 或 turn start,并处理无活动 Turn、ID 不匹配和不可 steering 分支
图 2.12-2:客户端缓存只用于乐观选择,服务端返回值才是活动 Turn 的权威。
async def route_user_turn(thread_id, command):
    cached = store.active_turn_id(thread_id)
    if cached:
        retried = False
        while True:
            try:
                return await app_server.turn_steer(thread_id, cached, command.items)
            except NoActiveTurn:
                store.clear_active_turn_id(thread_id)
                break
            except ExpectedTurnMismatch as error:
                store.set_active_turn_id(thread_id, error.actual)
                if retried:
                    raise
                cached, retried = error.actual, True
            except NonSteerableTurn as error:
                return widget.queue_or_report_rejected_steer(error)

    return await app_server.turn_start(thread_id, command.to_params())

若客户端无限重试 mismatch,它可能永远追逐一个快速切换的活动 Turn;“最多一次”把这类竞态变成有界 失败,而不是 UI 卡死。

Interrupt 为什么也要防竞态

Escape/Ctrl-C 不会无条件发送 Interrupt。Interaction 层先让 overlay、popup 或 composer 消费按键;只有 确有 cancellable work 时才生成 AppCommand::Interrupt

路由读取 active turn id,并在 store 中记录 pending_interrupt_turn_id,同一 Turn 的重复按键不会并发 发多个请求。若 App Server 报告实际活动 Turn 已更换,TUI也只用 actual id 重试一次。

这条请求的等待时间可能比普通设置请求长,因为 App Server 要等 Core 真正产生 TurnAborted 才响应。 所以实现把它放入 Tokio task,避免 App 主事件循环被阻塞。

Slash command 不是一种统一协议

斜杠命令会按职责分流:

  • /compactAppCommand::Compactthread/compact/start
  • /review → Review → review/start
  • /model 等界面命令可能只打开 picker;
  • 设置变化形成 OverrideTurnContext 或专用配置请求;
  • !cmdthread/shellCommand,不调用模型。

因此不能从“命令在输入框里出现”推断它会成为 UserInput,也不能从 AppCommand 名称推断最终一定提交 Core Op。有些动作只改变本地 UI,有些调用 App Server 状态 API,有些才映射为 Op。

审批是从服务端反向发起

命令执行审批、patch 审批、request_user_input 和 MCP elicitation 的方向与普通输入相反:Core 先产生 事件,App Server 将其投影为 JSON-RPC server request,TUI 建立弹窗。

PendingAppServerRequests 保存 request id 与 approval/call/turn 的关联。用户点击 Allow 后形成 ExecApproval 等 AppCommand,但 App 会优先查 pending request,完成原 server request 的 callback; 它不是另发一个无关联 notification。

async def answer_approval(command):
    pending = requests.take_by_approval_id(command.id)
    if pending is None:
        return show_stale_approval_warning()

    response = map_decision(command.decision)
    await pending.jsonrpc_callback.respond(response)

Turn Started/Complete 会取消仍未回答的反向请求。否则旧 Turn 的“允许执行”可能在新 Turn 中唤醒错误的 waiter,这是权限系统最危险的一类时序错误。

Embedded 与 Remote 参数并非完全相同

AppServerSession 支持进程内 Embedded 和 Remote 两种连接。二者使用同一 typed ClientRequest, 但 Thread 参数模式不同:Remote 不应传递只对本机有意义的 model provider config 等嵌入式对象。

“同一协议类型”保证语义一致,“参数裁剪”保证部署边界正确。若把本地配置对象完整序列化到远端,既会 暴露不该跨进程的信息,也可能让远端尝试解释本地路径。

失败与恢复矩阵

位置失败TUI 行为
Session 未配置提交过早原消息放回队首
模型不支持图片能力校验失败恢复完整草稿与绑定
model 为空Thread 尚未同步报错并恢复 composer
turn/start 被拒服务端校验失败reconcile 乐观 prompt
steer 无 active turn通知滞后清缓存,改发 start
steer ID mismatchTurn 被替换actual id 重试一次
Review/Compact steer非可 steering Task排队或显示错误
重复 interrupt同一 Turn 已 pending静默去重
审批请求已取消回答过期不向 Core 发送决定

应锁定的测试

async def test_stale_active_turn_falls_back_to_start(app):
    app.store.active_turn_id = "old"
    app.server.steer_returns(NoActiveTurn())
    await app.submit("next")
    assert app.server.calls == ["turn/steer(old)", "turn/start"]


async def test_unsupported_image_restores_bindings(widget):
    draft = message("$skill", image="a.png", binding="skill://x/SKILL.md")
    assert not widget.submit(draft)
    assert widget.composer.binding("$skill") == "skill://x/SKILL.md"


async def test_duplicate_interrupt_is_suppressed(app):
    app.store.pending_interrupt_turn_id = "t1"
    await app.interrupt("t1")
    assert app.server.interrupt_call_count == 0

TUI 的核心职责不是把所有动作硬塞进一个 Op enum,而是保持 UI 状态、App Server 状态与 Core 状态 最终收敛。分层协议、结构化竞态错误和乐观 UI 的补偿路径,共同实现了这件事。

评论


← 返回文章列表