ContextItem角色语义
在 Codex 中,“一条消息”至少有三种外形:模型协议里的 ResponseItem、事件与 UI 消费的 TurnItem,以及 rollout 截断算法用来判断新用户回合的边界。它们不是同一个枚举的别名。
本文从本版本源码中的 protocol/src/models.rs、protocol/src/items.rs、core/src/event_mapping.rs 和 core/src/thread_rollout_truncation.rs 出发,回答一个可验证的问题:同一个响应项经过不同消费者时,哪些角色会被保留、转换、过滤或当作回合边界?
默认读者需要了解 Rust 枚举匹配、Option 和 serde 标签。建议先读 模型上下文体系总览 了解 history/prompt 的区别,再读 InternalContext结构 了解隐藏 user fragment 的识别规则。本文不展开 ContextManager 的 append/replace 算法,也不把 TurnItem 当作模型请求协议。
1. 三层对象
| 层 | 类型 | owner | 主要消费者 | 是否决定用户回合 |
|---|---|---|---|---|
| 协议输入/输出 | ResponseItem | protocol 与 Session | history、模型适配、rollout | 间接 |
| 语义投影 | TurnItem | event_mapping::parse_turn_item | TUI、事件流、指标、hook | 对普通 user message 是 |
| 持久化边界 | is_user_turn_boundary | ContextManager / rollout 截断 | rollback、fork、历史裁剪 | 是 |
parse_turn_item 和 is_user_turn_boundary 都读取 ResponseItem,但回答的问题不同:前者问“应该对外呈现什么语义”,后者问“历史从哪里开始一个新的用户回合”。因此不能用 TurnItem 的数量直接推断 history 的 item 数量,也不能用任何 ResponseItem::Message { role: "user" } 直接推断存在一个真实用户回合。
2. 协议角色
2.1 ResponseItem
源码位置:codex-rs/protocol/src/models.rs :: ResponseItem
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem {
AdditionalTools {
id: Option<ResponseItemId>,
role: String,
tools: Vec<serde_json::Value>,
},
Message {
id: Option<ResponseItemId>,
role: String,
content: Vec<ContentItem>,
phase: Option<MessagePhase>,
internal_chat_message_metadata_passthrough:
Option<InternalChatMessageMetadataPassthrough>,
},
AgentMessage {
id: Option<ResponseItemId>,
author: String,
recipient: String,
content: Vec<AgentMessageInputContent>,
internal_chat_message_metadata_passthrough:
Option<InternalChatMessageMetadataPassthrough>,
},
Reasoning {
id: Option<ResponseItemId>,
summary: Vec<ReasoningItemReasoningSummary>,
content: Option<Vec<ReasoningItemContent>>,
encrypted_content: Option<String>,
internal_chat_message_metadata_passthrough:
Option<InternalChatMessageMetadataPassthrough>,
},
LocalShellCall { id: Option<ResponseItemId>, call_id: Option<String>, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
FunctionCall { id: Option<ResponseItemId>, name: String, namespace: Option<String>, arguments: String, encrypted_function_args: Option<Vec<String>>, call_id: String, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
FunctionCallOutput { id: Option<ResponseItemId>, call_id: String, output: FunctionCallOutputPayload, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
CustomToolCall { id: Option<ResponseItemId>, status: Option<String>, call_id: String, name: String, namespace: Option<String>, input: String, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
CustomToolCallOutput { id: Option<ResponseItemId>, call_id: String, name: Option<String>, output: FunctionCallOutputPayload, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
ToolSearchCall { id: Option<ResponseItemId>, call_id: Option<String>, status: Option<String>, execution: String, arguments: serde_json::Value, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
ToolSearchOutput { id: Option<ResponseItemId>, call_id: Option<String>, status: String, execution: String, tools: Vec<serde_json::Value>, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
WebSearchCall { id: Option<ResponseItemId>, status: Option<String>, action: Option<WebSearchAction>, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
ImageGenerationCall { id: Option<ResponseItemId>, status: String, revised_prompt: Option<String>, result: String, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
Compaction { id: Option<ResponseItemId>, encrypted_content: String, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
CompactionTrigger {},
ContextCompaction { id: Option<ResponseItemId>, encrypted_content: Option<String>, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
Other,
}这里的 role 只存在于 Message,不能拿它解释所有响应项。FunctionCall、Reasoning、WebSearchCall 等通过自己的变体表达角色;CompactionTrigger 是请求控制项,Other 是反序列化兜底。它们都可能经过 history 或 rollout 的判断,但不一定有可见 TurnItem。
2.2 ContentItem
源码位置:codex-rs/protocol/src/models.rs :: ContentItem
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentItem {
InputText { text: String },
InputImage { image_url: String, detail: Option<ImageDetail> },
InputAudio { audio_url: String },
OutputText { text: String },
}ContentItem 是 Message 的内容层,不是回合层。图片包裹标签会在 parse_user_message 中被跳过,但图片本身仍转成 UserInput::Image;OutputText 出现在 user message 时只记录 warning,不会变成输入文本。这种“标签过滤、媒体保留”的关系是内容语义,不应写成 ResponseItem 的角色转换。
3. 语义投影
3.1 TurnItem枚举
源码位置:codex-rs/protocol/src/items.rs :: TurnItem
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
#[serde(tag = "type")]
pub enum TurnItem {
UserMessage(UserMessageItem),
HookPrompt(HookPromptItem),
AgentMessage(AgentMessageItem),
Plan(PlanItem),
Reasoning(ReasoningItem),
CommandExecution(CommandExecutionItem),
DynamicToolCall(DynamicToolCallItem),
CollabAgentToolCall(CollabAgentToolCallItem),
SubAgentActivity(SubAgentActivityItem),
WebSearch(WebSearchItem),
ImageView(ImageViewItem),
Extension(ExtensionItem),
ImageGeneration(ImageGenerationItem),
EnteredReviewMode(EnteredReviewModeItem),
ExitedReviewMode(ExitedReviewModeItem),
FileChange(FileChangeItem),
McpToolCall(McpToolCallItem),
ContextCompaction(ContextCompactionItem),
}TurnItem 的变体集合比 parse_turn_item 当前直接处理的 ResponseItem 分支更大。很多变体由工具执行器、扩展或事件完成逻辑直接创建,而 event_mapping.rs 只负责把一部分模型响应转换为基础语义项。看到 TurnItem::CommandExecution 并不意味着 ResponseItem 有同名变体;两者可能由不同生产者产生。
类图中的箭头不是一一对应关系:FunctionCall 没有由 parse_turn_item 直接映射到同名 TurnItem,而 CommandExecution 由其他运行时消费者创建。这正是协议层和语义层必须分开的原因。
3.2 映射入口
源码位置: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 { id, summary, content, .. } => {
let summary_text = summary
.iter()
.map(|entry| match entry {
ReasoningItemReasoningSummary::SummaryText { text } => text.clone(),
})
.collect();
let raw_content = content
.clone()
.unwrap_or_default()
.into_iter()
.map(|entry| match entry {
ReasoningItemContent::ReasoningText { text }
| ReasoningItemContent::Text { text } => text,
})
.collect();
Some(TurnItem::Reasoning(ReasoningItem {
id: id.as_deref().unwrap_or_default().to_string(),
summary_text,
raw_content,
}))
}
ResponseItem::WebSearchCall { id, action, .. } => {
let (action, query) = match action {
Some(action) => (action.clone(), web_search_action_detail(action)),
None => (WebSearchAction::Other, String::new()),
};
Some(TurnItem::WebSearch(WebSearchItem {
id: id.as_deref().unwrap_or_default().to_string(),
query,
action,
results: None,
}))
}
ResponseItem::ImageGenerationCall { id, status, revised_prompt, result, .. } => {
Some(TurnItem::ImageGeneration(ImageGenerationItem {
id: id.as_deref()?.to_string(),
status: status.clone(),
revised_prompt: revised_prompt.clone(),
result: result.clone(),
saved_path: None,
}))
}
_ => None,
}
}这个函数体现了四种输出语义:Some(UserMessage)、Some(HookPrompt)、Some(AgentMessage)、Some 的专用模型项,以及明确过滤的 None。ImageGenerationCall 使用 id.as_deref()?,缺 ID 时整个投影失败;Reasoning 和 WebSearch 则使用空字符串或 Other 保留一个可消费项。这些差异是当前代码的条件边界,不能笼统称为“缺字段自动补齐”。
4. Message分支
4.1 User与Hook
源码位置: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<UserInput> = Vec::new();
for (idx, content_item) in message.iter().enumerate() {
match content_item {
ContentItem::InputText { text } => {
let is_image_label = ((is_local_image_open_tag_text(text)
|| is_image_open_tag_text(text))
&& matches!(message.get(idx + 1), Some(ContentItem::InputImage { .. })))
|| (idx > 0
&& (is_local_image_close_tag_text(text) || is_image_close_tag_text(text))
&& matches!(message.get(idx - 1), Some(ContentItem::InputImage { .. })));
if !is_image_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 { text } => {
warn!("Output text in user message: {}", text);
}
}
}
Some(UserMessageItem::new(&content))
}parse_turn_item 对 user 消息先尝试 parse_visible_hook_prompt_message,再尝试普通 user message。因此 hook prompt 不是普通文本的特殊前缀,而是一个优先级更高的结构化解析结果;内部上下文片段则会让普通 user message 返回 None,除非同一消息中存在可识别的 hook prompt,后者会被保留为 HookPrompt。
4.2 Assistant与phase
源码位置:codex-rs/core/src/event_mapping.rs :: parse_agent_message
fn parse_agent_message(
id: Option<&str>,
message: &[ContentItem],
phase: Option<MessagePhase>,
) -> AgentMessageItem {
let mut content: Vec<AgentMessageContent> = Vec::new();
for content_item in message.iter() {
match content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
content.push(AgentMessageContent::Text { text: text.clone() });
}
_ => {
warn!("Unexpected content item in agent message: {:?}", content_item);
}
}
}
let id = id
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
AgentMessageItem {
id,
content,
phase,
memory_citation: None,
}
}assistant 消费路径允许 InputText 和 OutputText,因为当前仓库还需要兼容旧的 agent message 输入形态;图片和音频不会被转成 AgentMessageContent,而是 warning 后跳过。缺少 ID 时这里生成 UUID,与 ImageGeneration 分支的 ? 行为不同。phase 原样传入,让 TUI 区分 commentary 与 final answer;它不是新的 TurnItem 变体。
5. 专用响应项
5.1 Reasoning
Reasoning 的 summary 和可选 content 分别映射到 summary_text 与 raw_content。当前实现将两种 raw content 变体合并为字符串列表:
5.2 Web与图像工具
WebSearchCall 没有 action 时仍返回 TurnItem::WebSearch,但 action 为 Other、query 为空;这让进行中的不完整响应仍可被下游识别。ImageGenerationCall 则要求 ID 存在,否则返回 None。两者都不是普通 user/assistant message。
5.3 未映射项
FunctionCall、FunctionCallOutput、CustomToolCall、LocalShellCall、CompactionTrigger 和 Other 在 parse_turn_item 的当前函数尾部落入 _ => None。这不表示它们在系统中没有语义:工具执行器和 Session 会直接创建 TurnItem::CommandExecution、McpToolCall 等项,CompactionTrigger 也由上下文流程消费。这里的 None 只表示“不能由这个基础事件映射函数投影成当前 TurnItem”。
6. 回合边界
6.1 上下文决策
源码位置:codex-rs/core/src/context_manager/history.rs :: is_user_turn_boundary
pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
if matches!(item, ResponseItem::AgentMessage { .. }) {
return true;
}
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
(role == "user" && !is_contextual_user_message_content(content))
|| (role == "assistant" && is_inter_agent_instruction_content(content))
}这个判定比 parse_turn_item == Some(UserMessage) 更宽:所有 ResponseItem::AgentMessage 都是边界,assistant 的 inter-agent instruction 也是边界;普通 Message(role=user) 只有在不是上下文片段时才算边界。它服务的是 history 的 turn 计数和 rollback,不是 UI 显示。
6.2 Rollout截断
源码位置:codex-rs/core/src/thread_rollout_truncation.rs :: user_message_positions_in_rollout
pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec<usize> {
let mut user_positions = Vec::new();
for (idx, item) in items.iter().enumerate() {
match item {
RolloutItem::ResponseItem(item @ ResponseItem::Message { .. })
if matches!(
event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_))
) =>
{
user_positions.push(idx);
}
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
let num_turns = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX);
let new_len = user_positions.len().saturating_sub(num_turns);
user_positions.truncate(new_len);
}
_ => {}
}
}
user_positions
}rollout 截断只把真正投影为 TurnItem::UserMessage 的 message 计入位置,并在遇到 rollback event 时回退计数。Hook prompt、internal context、assistant message 和工具响应都不会在这个函数中成为普通 user message 位置。它因此能避免把系统注入误当成可 fork 的用户输入。
7. 消费者差异
| 输入项 | parse_turn_item | is_user_turn_boundary | rollout type |
|---|---|---|---|
普通 Message(user) | UserMessage | true | response.message / user boundary |
contextual Message(user) | None | false | 仍可持久化为 response item |
| hook prompt | HookPrompt | false | 不算普通 user message |
Message(assistant) | AgentMessage | false(除 inter-agent instruction) | response message |
AgentMessage | None | true | response.agent_message |
Reasoning | Reasoning | false | response.reasoning |
WebSearchCall | WebSearch | false | response.web_search_call |
ImageGenerationCall 无 ID | None | false | response item 仍可被记录 |
CompactionTrigger | None | false | 非 durable response item |
persistence_metrics.rs 还会按 TurnItem 变体生成 user_message、reasoning、command_execution 等类型名;这说明语义投影会影响统计,但统计类型不能反向证明原始 ResponseItem 的变体来源。
8. 反向验证
以下测试覆盖 ContextItem 的角色、排序和可见性边界:
just test -p codex-core parses_user_message_with_text_and_two_images
just test -p codex-core parses_hook_prompt_and_hides_other_contextual_fragments
just test -p codex-core internal_model_context_does_not_parse_as_visible_turn_item
just test -p codex-core parses_reasoning_including_raw_content
just test -p codex-core parses_partial_web_search_call_without_action_as_other| 测试 | 输入 | 断言 | 覆盖范围 | 未覆盖 |
|---|---|---|---|---|
parses_user_message_with_text_and_two_images | user text + 两个 image | text/image 顺序和 detail 保留 | ContentItem → UserInput | UI 渲染布局 |
parses_hook_prompt_and_hides_other_contextual_fragments | environment fragment + hook prompt | 返回 HookPrompt,环境片段不进入 fragments | hook 优先级与上下文过滤 | hook runtime 的后续执行 |
internal_model_context_does_not_parse_as_visible_turn_item | internal fragment user message | 返回 None | 内部上下文不可见投影 | history 是否持久化 |
parses_reasoning_including_raw_content | summary + 两种 raw content | 两个列表均保留 | Reasoning 字段映射 | 加密 reasoning 展示 |
parses_partial_web_search_call_without_action_as_other | action=None 的 web search | 返回 WebSearch/Other/空 query | 不完整响应保留策略 | provider 是否最终补发 action |
读者可以用下面的定位命令复查每个分支:
rg -n "pub enum ResponseItem|pub enum ContentItem" codex-rs/protocol/src/models.rs
rg -n "pub enum TurnItem|pub struct UserMessageItem" codex-rs/protocol/src/items.rs
rg -n "pub fn parse_turn_item|fn parse_user_message|fn parse_agent_message" codex-rs/core/src/event_mapping.rs
rg -n "is_user_turn_boundary|user_message_positions_in_rollout" codex-rs/core/src9. 边界练习
- 为什么 contextual user message 可能留在 history,却不能成为
TurnItem::UserMessage或 rollout 的普通用户位置? ImageGenerationCall缺 ID 与Reasoning缺 ID 的投影结果为什么不同?分别指出?和unwrap_or_default()。TurnItem::CommandExecution为什么不能通过搜索ResponseItem::CommandExecution找到对应协议变体?- rollback event 到达
user_message_positions_in_rollout后,为什么是减少已记录位置,而不是直接删除 rollout 数组?
如果能从 ResponseItem 走到 TurnItem,再从同一项走到 boundary 判定,并说清两个消费者为何故意不同,才算真正掌握了 ContextItem 的角色语义。
