内部事件映射
本文回答一个具体问题: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. 对象层次
| 类型 | 所属层 | 谁创建 | 谁消费 | 主要用途 |
|---|---|---|---|---|
ResponseItem | protocol/model | provider、用户输入、rollout | parse_turn_item、history、sampling | 表示模型上下文或响应项 |
TurnItem | Core runtime | parse_turn_item 或工具 handler | turn loop、事件构造、paginated rollout | 表示对 Core 和客户端可见的用户、agent、reasoning、工具等生命周期项 |
EventMsg | Core event bus | Session/task | App Server listener | 表示运行时发生了什么以及何时发生 |
ServerNotification | App Server protocol | event mapper | JSON-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
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
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 分支
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
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
// 先写入会话历史;响应流的 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_images | user message、文本和两个 image | 输出包含一个 UserInput::Text 和两个 image,顺序不变 | 普通文本/图片转换;不证明上下文过滤 |
skips_local_image_label_text | 图片开闭标签、图片和用户文本 | 标签文本被移除,媒体和用户文本保留 | 图片标签不会污染用户文本;不证明所有自定义标签 |
skips_user_instructions_and_env | AGENTS、environment、skill 和 shell wrapper | parse_turn_item 返回 None | 内部上下文不会成为可见 UserMessage |
parses_assistant_message_input_text_for_backward_compatibility | assistant 的 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. 失败与边界
- 上下文误判:message 被识别为 contextual,返回
None,用户不会看到UserMessage;这依赖is_contextual_user_fragment的标签集合,新增标签必须补测试。 - 不支持的 response variant:
parse_turn_item返回None;原始项仍可通过RawResponseItem保留,但不能推导出ItemCompleted。 - agent 内容类型不匹配:记录 warning,忽略无法转换的 content item;这不是成功保留原始内容的保证。
- 缺少 id:部分投影使用空字符串或生成 UUID;跨层关联必须按具体变体核对 id 策略。
这些路径的用户可见结果不同:过滤是有意隐藏,不支持是没有 Core 投影,warning 是部分归一化。不能把三者 统一描述成“事件丢失”。
8. 事件映射验证
读者可以在与本文 release 相同的源码版本中执行:
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完成本文后,应能回答:
- 为什么 system message 和 contextual user message 都可能返回
None,但原因不同? - 为什么
RawResponseItem存在时仍不能断言产生了TurnItem? - 图片/音频标签为什么要看相邻
ContentItem,而不是简单删除所有标签文本? - 哪些结论需要继续查看 session 或 rollout 测试才能成立?
下一步阅读 Turn主循环与退出条件 观察 TurnItem 如何参与采样后 循环,再阅读 核心数据对象关系 区分 ResponseItem、 TurnItem 和 rollout 投影。
