Skip to content

ContextItem角色语义

以当前协议和事件映射源码为入口,解释 ResponseItem、TurnItem、上下文片段和 rollout 用户边界之间的角色与排序语义。

基于rust-v0.150.0
CodexRustContext

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主要消费者是否决定用户回合
协议输入/输出ResponseItemprotocol 与 Sessionhistory、模型适配、rollout间接
语义投影TurnItemevent_mapping::parse_turn_itemTUI、事件流、指标、hook对普通 user message 是
持久化边界is_user_turn_boundaryContextManager / 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

rust
#[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

rust
#[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

rust
#[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

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 { 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

rust
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

rust
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

rust
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

rust
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_itemis_user_turn_boundaryrollout type
普通 Message(user)UserMessagetrueresponse.message / user boundary
contextual Message(user)Nonefalse仍可持久化为 response item
hook promptHookPromptfalse不算普通 user message
Message(assistant)AgentMessagefalse(除 inter-agent instruction)response message
AgentMessageNonetrueresponse.agent_message
ReasoningReasoningfalseresponse.reasoning
WebSearchCallWebSearchfalseresponse.web_search_call
ImageGenerationCall 无 IDNonefalseresponse item 仍可被记录
CompactionTriggerNonefalse非 durable response item

persistence_metrics.rs 还会按 TurnItem 变体生成 user_message、reasoning、command_execution 等类型名;这说明语义投影会影响统计,但统计类型不能反向证明原始 ResponseItem 的变体来源。

8. 反向验证 ​

以下测试覆盖 ContextItem 的角色、排序和可见性边界:

bash
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_imagesuser text + 两个 imagetext/image 顺序和 detail 保留ContentItem → UserInputUI 渲染布局
parses_hook_prompt_and_hides_other_contextual_fragmentsenvironment fragment + hook prompt返回 HookPrompt,环境片段不进入 fragmentshook 优先级与上下文过滤hook runtime 的后续执行
internal_model_context_does_not_parse_as_visible_turn_iteminternal fragment user message返回 None内部上下文不可见投影history 是否持久化
parses_reasoning_including_raw_contentsummary + 两种 raw content两个列表均保留Reasoning 字段映射加密 reasoning 展示
parses_partial_web_search_call_without_action_as_otheraction=None 的 web search返回 WebSearch/Other/空 query不完整响应保留策略provider 是否最终补发 action

读者可以用下面的定位命令复查每个分支:

bash
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/src

9. 边界练习 ​

  1. 为什么 contextual user message 可能留在 history,却不能成为 TurnItem::UserMessage 或 rollout 的普通用户位置?
  2. ImageGenerationCall 缺 ID 与 Reasoning 缺 ID 的投影结果为什么不同?分别指出 ? 和 unwrap_or_default()。
  3. TurnItem::CommandExecution 为什么不能通过搜索 ResponseItem::CommandExecution 找到对应协议变体?
  4. rollback event 到达 user_message_positions_in_rollout 后,为什么是减少已记录位置,而不是直接删除 rollout 数组?

如果能从 ResponseItem 走到 TurnItem,再从同一项走到 boundary 判定,并说清两个消费者为何故意不同,才算真正掌握了 ContextItem 的角色语义。