Skip to content

内部事件映射

从 ResponseItem 追踪到 TurnItem 与 EventMsg,解释上下文消息过滤、历史投影和事件可见性边界。

基于rust-v0.150.0
CodexRustRuntime

内部事件映射 ​

本文回答一个具体问题:Core 的 EventMsg 如何被 App Server 转换为客户端可消费的 ServerNotification,以及为什么同一事件会分成 ItemStarted、ItemCompleted、delta、TurnCompleted 和 RawResponseItem 等不同 wire surface。当前实现的事实源是 app-server-protocol/src/protocol/event_mapping.rs;Core 的 ResponseItem -> TurnItem 只负责更早一层的 内部 item 投影。

本文不把三个类型当成同一层对象:ResponseItem 是模型/历史输入形状,TurnItem 是 Core 的可见完成单元, EventMsg 是运行时事件信封,ServerNotification 是 App Server 的 wire 投影。stateless item_event_to_server_notification 负责一对一的事件映射,thread listener 仍负责 reducer、订阅连接和 其他副作用。

1. 对象层次 ​

类型所属层谁创建谁消费主要用途
ResponseItemprotocol/modelprovider、用户输入、rolloutparse_turn_item、history、sampling表示模型上下文或响应项
TurnItemCore runtimeparse_turn_item 或工具 handlerturn loop、事件构造、paginated rollout表示对 Core 和客户端可见的用户、agent、reasoning、工具等生命周期项
EventMsgCore event busSession/taskApp Server listener表示运行时发生了什么以及何时发生
ServerNotificationApp Server protocolevent mapperJSON-RPC client面向客户端的稳定 wire 投影

因此,ResponseItem 能否转换为 TurnItem 并不等于它一定会发出独立的 EventMsg;有些 response item 只服务上下文或历史,不属于用户可见的 turn item。

2. parse_turn_item ​

在当前版本,parse_turn_item 仍负责 Core 内部 ResponseItem 到 TurnItem 的可见性判断;跨进程客户端 看到的通知则来自协议层 item_event_to_server_notification。后者只接受已形成的 EventMsg,不再反向 解析模型历史,因此事件到通知的映射不能替代前面的 response-item 过滤。

真实入口位于 codex-rs/core/src/event_mapping.rs :: parse_turn_item。函数接收一个 &ResponseItem,返回 Option<TurnItem>:返回 None 本身就是一个有意义的过滤结果。

源码位置:codex-rs/core/src/event_mapping.rs :: parse_turn_item

rust
pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
    match item {
        ResponseItem::Message {
            role,
            content,
            id,
            phase,
            ..
        } => match role.as_str() {
            "user" => parse_visible_hook_prompt_message(id.as_deref(), content)
                .map(TurnItem::HookPrompt)
                .or_else(|| parse_user_message(content).map(TurnItem::UserMessage)),
            "assistant" => Some(TurnItem::AgentMessage(parse_agent_message(
                id.as_deref(), content, phase.clone(),
            ))),
            "system" => None,
            _ => None,
        },
        ResponseItem::Reasoning { .. } => Some(TurnItem::Reasoning(/* ... */)),
        ResponseItem::WebSearchCall { .. } => Some(TurnItem::WebSearch(/* ... */)),
        ResponseItem::ImageGenerationCall { .. } => Some(TurnItem::ImageGeneration(/* ... */)),
        _ => None,
    }
}

这段分派建立了第一条边界:普通用户消息先尝试识别 hook prompt,再转换为 UserMessage; assistant 消息变成 AgentMessage; system 和没有 Core 可见投影的其他变体返回 None。后续文章不能把“原始 响应存在”直接解释成“用户看到了一个完成项”。

3. 用户消息的映射 ​

parse_user_message 不负责简单地把所有文本拼起来。它先调用 is_contextual_user_message_content,过滤 AGENTS、环境、skill 和其他内部上下文片段;图片和音频的 标签文本也会被删除,但实际媒体仍会转换为 UserInput::Image 或 UserInput::Audio。

源码位置:codex-rs/core/src/event_mapping.rs :: parse_user_message

rust
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
    if is_contextual_user_message_content(message) {
        return None;
    }

    let mut content = Vec::new();
    for (idx, content_item) in message.iter().enumerate() {
        match content_item {
            ContentItem::InputText { text } => {
                let is_media_label = /* 相邻 InputImage/InputAudio 的开闭标签 */;
                if !is_media_label {
                    content.push(UserInput::Text {
                        text: text.clone(),
                        text_elements: Vec::new(),
                    });
                }
            }
            ContentItem::InputImage { image_url, detail } => {
                content.push(UserInput::Image { image_url: image_url.clone(), detail: *detail });
            }
            ContentItem::InputAudio { audio_url } => {
                content.push(UserInput::Audio { audio_url: audio_url.clone() });
            }
            ContentItem::OutputText { .. } => { /* warn,不生成用户输入 */ }
        }
    }
    Some(UserMessageItem::new(&content))
}

这里的生效时机是 parse_turn_item 调用时,而不是 UI 渲染时。被判定为 contextual 的 message 不会先 进入 TurnItem 再由消费者猜测是否隐藏。这个设计让 rollout 边界识别、compact 和 hook runtime 共用同一 投影规则,但不会把 TurnItem 写入模型 conversation history。

4. assistant ​

assistant 消息被转换为 AgentMessageItem,保留 id 和 phase;InputText 与 OutputText 都可作为 agent 文本,其他内容会记录 warning 而不伪装成 agent 文本。Reasoning、WebSearch 和 ImageGeneration 使用 各自的 TurnItem 变体,保留后续消费者需要的 id、summary、action 或 status。

源码位置:codex-rs/core/src/event_mapping.rs :: parse_agent_message 与 reasoning 分支

rust
fn parse_agent_message(
    id: Option<&str>,
    message: &[ContentItem],
    phase: Option<MessagePhase>,
) -> AgentMessageItem {
    let content = message.iter().filter_map(|item| match item {
        ContentItem::InputText { text } | ContentItem::OutputText { text } =>
            Some(AgentMessageContent::Text { text: text.clone() }),
        _ => None,
    }).collect();

    AgentMessageItem {
        id: id.map(str::to_string).unwrap_or_else(|| Uuid::new_v4().to_string()),
        content,
        phase,
        memory_citation: None,
    }
}

缺少 id 时生成新的 UUID,说明“协议项 id”和“Core 投影对象 id”并非总是同一个来源。文章或调试工具如果 需要关联 history、事件和 UI,应以具体变体的 id 规则为准,不能假定所有 TurnItem 都来自同一个全局 id。

5. Session项与投影 ​

App Server 协议层的 stateless mapper 还直接处理动态工具、协作 agent、patch、命令开始/输出/结束、终端交互、agent/reasoning/plan delta 等事件。例如 ExecCommandOutputDelta 只把二进制 chunk 转为 lossless UTF-8 文本通知;它不会创建新的 ThreadItem。ExecCommandBegin 与 ExecCommandEnd 则分别构造 ItemStarted/ItemCompleted,使用同一个 call ID 让客户端 reducer 配对。

源码位置:codex-rs/app-server-protocol/src/protocol/event_mapping.rs :: item_event_to_server_notification

rust
EventMsg::ExecCommandBegin(event) => ServerNotification::ItemStarted(
    ItemStartedNotification {
        thread_id,
        turn_id,
        item: build_command_execution_begin_item(&event),
        started_at_ms: event.started_at_ms,
    },
),
EventMsg::ExecCommandOutputDelta(event) => {
    ServerNotification::CommandExecutionOutputDelta(
        CommandExecutionOutputDeltaNotification {
            thread_id,
            turn_id,
            item_id: event.call_id,
            delta: String::from_utf8_lossy(&event.chunk).to_string(),
        },
    )
}
EventMsg::ExecCommandEnd(event) => ServerNotification::ItemCompleted(
    ItemCompletedNotification {
        thread_id,
        turn_id,
        item: build_command_execution_end_item(&event),
        completed_at_ms: event.completed_at_ms,
    },
),

Session 在处理 response item 时并非只调用 parse_turn_item。真实代码还会发送 EventMsg::RawResponseItem,随后在能够投影时发送 ItemStarted 或 ItemCompleted。

源码位置:codex-rs/core/src/session/mod.rs :: record_response_item_and_emit_turn_item

rust
// 先写入会话历史;响应流的 RawResponseItem 由 send_raw_response_items 负责发送。
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
    .await;

if let Some(item) = parse_turn_item(&response_item) {
    self.emit_turn_item_started(turn_context, &item).await;
    self.emit_turn_item_completed(turn_context, item).await;
}

响应流在另一条 send_raw_response_items 路径中逐项发送 EventMsg::RawResponseItem;上面的函数负责已经 进入历史的单个 response item 的 Core 可见投影。关键结论是:

  • RawResponseItem 用于把协议层原始项广播给原始响应消费者;
  • TurnItem 只在 parse_turn_item 返回 Some 时存在;
  • ItemStarted/ItemCompleted 代表运行时可见的完成单元,不是所有原始 response 的镜像。

6. 过滤与归一化测试 ​

测试输入、断言和覆盖范围必须分开记录:

测试输入关键断言覆盖范围
parses_user_message_with_text_and_two_imagesuser message、文本和两个 image输出包含一个 UserInput::Text 和两个 image,顺序不变普通文本/图片转换;不证明上下文过滤
skips_local_image_label_text图片开闭标签、图片和用户文本标签文本被移除,媒体和用户文本保留图片标签不会污染用户文本;不证明所有自定义标签
skips_user_instructions_and_envAGENTS、environment、skill 和 shell wrapperparse_turn_item 返回 None内部上下文不会成为可见 UserMessage
parses_assistant_message_input_text_for_backward_compatibilityassistant 的 InputText输出为 AgentMessage 且文本保持兼容旧 assistant 内容形状;不证明 UI 展示格式
reasoning/web search tests对应 response item变体、id、summary/action 被正确投影各专用 TurnItem 字段保留;不证明事件订阅顺序

测试文件为 codex-rs/core/src/event_mapping_tests.rs。要声称“事件顺序”或“历史持久化”正确,还必须 结合 session/tests.rs 或 rollout 测试;本文件中的纯映射测试不能独立证明这些更高层行为。

7. 失败与边界 ​

  1. 上下文误判:message 被识别为 contextual,返回 None,用户不会看到 UserMessage;这依赖 is_contextual_user_fragment 的标签集合,新增标签必须补测试。
  2. 不支持的 response variant:parse_turn_item 返回 None;原始项仍可通过 RawResponseItem 保留,但不能推导出 ItemCompleted。
  3. agent 内容类型不匹配:记录 warning,忽略无法转换的 content item;这不是成功保留原始内容的保证。
  4. 缺少 id:部分投影使用空字符串或生成 UUID;跨层关联必须按具体变体核对 id 策略。

这些路径的用户可见结果不同:过滤是有意隐藏,不支持是没有 Core 投影,warning 是部分归一化。不能把三者 统一描述成“事件丢失”。

8. 事件映射验证 ​

读者可以在与本文 release 相同的源码版本中执行:

bash
rg -n "pub fn parse_turn_item|fn parse_user_message|fn parse_agent_message" \
  codex-rs/core/src/event_mapping.rs
rg -n "RawResponseItem|parse_turn_item|ItemStarted|ItemCompleted" \
  codex-rs/core/src/session/mod.rs codex-rs/core/src/session/tests.rs
cargo test -p codex-core event_mapping

完成本文后,应能回答:

  1. 为什么 system message 和 contextual user message 都可能返回 None,但原因不同?
  2. 为什么 RawResponseItem 存在时仍不能断言产生了 TurnItem?
  3. 图片/音频标签为什么要看相邻 ContentItem,而不是简单删除所有标签文本?
  4. 哪些结论需要继续查看 session 或 rollout 测试才能成立?

下一步阅读 Turn主循环与退出条件 观察 TurnItem 如何参与采样后 循环,再阅读 核心数据对象关系 区分 ResponseItem、 TurnItem 和 rollout 投影。