Skip to content

会话线程与消息历史

源码版本rust-v0.145.0

CodexThread 是 codex 暴露给上层(UI、app-server、extension)的会话句柄。一个 thread 对应一段连续的对话历史、一个 session loop、一个 rollout 文件,以及一组绑定的工作目录和权限配置。它本身不跑模型循环,只是把 Op 投递给底层 Session,再把事件流反吐给调用方。真正存历史、做压缩、跑 agent loop 的是 Session 和它持有的 LiveThread

职责

  1. 接收外部 Op(用户消息、steer、cancel、shutdown),通过 submit / submit_with_trace 投递到 Session 的 mailbox (codex-rs/core/src/codex_thread.rs:205-275)。
  2. 暴露事件流:next_eventEvent,agent_status 把事件折成 AgentStatus 供 UI 显示 idle/running/completed (codex-rs/core/src/codex_thread.rs:414-422)。
  3. 注入模型可见的 item: inject_response_itemsResponseItem 塞进历史但不发起新 turn, inject_if_running 在 turn 进行中插话 (codex-rs/core/src/codex_thread.rs:306-483)。
  4. 管理 thread 配置 snapshot:config_snapshot / preview_thread_settings_overrides 把 sandbox、approval、environment、model 等运行时配置打包给上层 (codex-rs/core/src/codex_thread.rs:355-361)。
  5. 持久化代理:所有 rollout/thread-store 操作经 live_thread 转发,load_history / read_thread / append_rollout_items / update_thread_metadata 都走 LiveThread (codex-rs/core/src/codex_thread.rs:504-558)。

设计动机

早期 codex 把"对话"和"会话循环"耦在一起,后来拆出 CodexThread 当门面,主要原因是 extension 和 app-server 需要一个稳定的句柄去做事——它们不该直接碰 Session 内部的 channel。CodexThreadSessionIo(底层 mpsc)封进自己,只暴露 submitnext_event 两个方向,这就是文档里说的"bidirectional stream of messages that compose a thread"。

历史持久化独立成 thread-store crate,是因为实现可以换:本地 rollout 文件、远程 service、内存 mock。ThreadStore trait 只规定生命周期方法(create_thread / resume_thread / append_items / persist_thread / flush_thread / shutdown_thread / discard_thread),核心代码不关心后端在哪。LiveThread 是 session 持有的句柄,负责把核心的"该写就写"决策翻译成 store 调用。

message-history crate 则是另一个维度:它不是按 thread 存的 rollout,而是 ~/.codex/history.jsonl 这个全局跨 thread 的 JSONL 文件,每条 {session_id, ts, text} 一行。它的用途是给 TUI 的历史命令查找用,而不是恢复对话。所以它独立于 thread-store,自己处理 advisory lock 和 trim。

TryStartTurnIfIdleError 的设计值得一提:extension 想"线程闲了就自动开一轮"时,线程可能正忙、可能在 Plan 模式、或者已有用户触发的 turn 在排队。错误返回里带 reason原始 items 不变,让调用方决定是丢弃、重试还是记录原因——不是直接吃掉输入。

关键文件

codex-rs/core/src/codex_thread.rs:162-203CodexThread 结构体定义和 new 构造器,持 Arc<Session>SessionIoSessionConfiguredEventcodex-rs/core/src/codex_thread.rs:86-124TryStartTurnIfIdleRejectionReasonTryStartTurnIfIdleError,自动 idle turn 的三类拒绝原因。codex-rs/core/src/codex_thread.rs:446-483inject_user_message_without_turninject_response_items,往历史里塞东西但不发起新 turn。codex-rs/core/src/agent/status.rs:6-21agent_status_from_event,把 EventMsg 折成 AgentStatuscodex-rs/thread-store/src/store.rs:36-94ThreadStore trait,所有 thread 持久化后端的契约。codex-rs/thread-store/src/live_thread.rs:35-108LiveThread 结构体和 create,session 持有的 store 句柄。codex-rs/thread-store/src/live_thread.rs:48-90LiveThreadInitGuard,session 初始化失败时安全丢弃 live writer。codex-rs/message-history/src/lib.rs:104-189append_entry,全局 history.jsonl 的原子追加。codex-rs/message-history/src/lib.rs:61-83HistoryEntry / HistoryConfig,全局历史的数据结构。

CodexThread::submit 看起来就是一行 self.io.submit(op).await,但 io: SessionIo 背后是 tokio mpsc + turn id 分配 + trace context 注入。上层只看到"丢了个 Op 进去拿到 sub_id",中间的 mailbox、turn queue、shutdown 信号全封在里面:

rust
// codex_thread.rs:205-207 — Op 的统一入口
pub async fn submit(&self, op: Op) -> CodexResult<String> {
    self.io.submit(op).await
}

agent 状态不是 CodexThread 自己维护的,而是从 EventMsg 派生。TurnStarted -> Running,TurnComplete -> Completed,TurnAborted 按原因分 InterruptedErrored,ShutdownComplete -> Shutdownis_finalPendingInit/Running/Interrupted 都算"还没结束",其他状态都算终态:

rust
// agent/status.rs:6-21 — 状态机完全由事件驱动
pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option<AgentStatus> {
    match msg {
        EventMsg::TurnStarted(_) => Some(AgentStatus::Running),
        EventMsg::TurnComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())),
        EventMsg::TurnAborted(ev) => match ev.reason {
            codex_protocol::protocol::TurnAbortReason::Interrupted
            | codex_protocol::protocol::TurnAbortReason::BudgetLimited => {
                Some(AgentStatus::Interrupted)
            }
            _ => Some(AgentStatus::Errored(format!("{:?}", ev.reason))),
        },
        EventMsg::Error(ev) => Some(AgentStatus::Errored(ev.message.clone())),
        EventMsg::ShutdownComplete => Some(AgentStatus::Shutdown),
        _ => None,
    }
}

全局 history.jsonl 的写入是个有意思的细节:必须在持锁状态下用单次 write_all 把整行 JSON + \n 写下去,POSIX 才保证 PIPE_BUF 字节以内的写入是原子的。多进程并发追加不会交错。代码里特地用 spawn_blocking 把这段同步 IO 移出 async runtime:

rust
// message-history/src/lib.rs:160-172 — 持锁、定位到末尾、一次写完
tokio::task::spawn_blocking(move || -> Result<()> {
    for _ in 0..MAX_RETRIES {
        match history_file.try_lock() {
            Ok(()) => {
                history_file.seek(SeekFrom::End(0))?;
                history_file.write_all(line.as_bytes())?;
                history_file.flush()?;
                enforce_history_limit(&mut history_file, history_max_bytes)?;
                return Ok(());
            }
            Err(std::fs::TryLockError::WouldBlock) => {
                std::thread::sleep(RETRY_SLEEP);
            }
            Err(e) => return Err(e.into()),
        }
    }
    // ...
})

数据流

边界与失败

小结

CodexThread 的核心价值是把"一个会话"抽象成稳定句柄——不管后端是本地 rollout 还是远程 service,不管有没有 sub-agent,上层代码都只看 submit / next_event / config_snapshot。真正的循环逻辑在 Agent 主循环 里,持久化细节在 thread-store trait 各实现里。要看 turn 内部历史怎么被压缩,接 Context 压缩演进