Skip to content

ConversationItem类型体系

沿着 ResponseItem 生成、历史记录、ID补全、上下文归一化和 App Server 投影,理解 Codex 的会话 item 生命周期。

基于rust-v0.150.0
CodexRustProtocolContext

ConversationItem类型体系 ​

本文承接UserInput类型体系和Event与EventMsg总表。这里的“ConversationItem”不是某个单独的 Rust enum,而是一条跨层数据流:输入先形成 ResponseInputItem,进入历史后成为更宽的 ResponseItem,Core 再把它记录为 ResponseItemEnvelope、解析为 TurnItem,最后由 App Server 投影成 ThreadItem。

这条链有三个必须分开的问题:item 的业务内容是什么,item 的 Responses ID 如何稳定,以及它是否适合被下一次模型请求、历史 reducer 或 App Server 展示。一个 item 能被序列化,不等于每个消费者都会保留它。

1. 两种Responses项 ​

源码位置:codex-rs/protocol/src/models.rs :: ResponseInputItem、ResponseItem

ResponseInputItem 是向 Responses API 提交的窄集合,ResponseItem 是会话历史使用的宽集合。前者包含 user message 和工具输出;后者还要表示 assistant/agent message、reasoning、工具调用、搜索、图片生成、压缩和未知 wire 变体。

源码位置:codex-rs/protocol/src/models.rs :: ResponseInputItem

rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseInputItem {
    Message {
        role: String,
        content: Vec<ContentItem>,
        phase: Option<MessagePhase>,
    },
    FunctionCallOutput {
        call_id: String,
        output: FunctionCallOutputPayload,
    },
    McpToolCallOutput {
        call_id: String,
        output: CallToolResult,
    },
    CustomToolCallOutput {
        call_id: String,
        name: Option<String>,
        output: FunctionCallOutputPayload,
    },
    ToolSearchOutput {
        call_id: String,
        status: String,
        execution: String,
        tools: Vec<Value>,
    },
}

源码位置: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<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> },
    ToolSearchCall { id: Option<ResponseItemId>, call_id: Option<String>, status: Option<String>, execution: String, arguments: Value, internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough> },
    FunctionCallOutput { id: Option<ResponseItemId>, call_id: Option<String>, name: Option<String>, namespace: Option<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> },
    ToolSearchOutput { id: Option<ResponseItemId>, call_id: Option<String>, status: String, execution: String, tools: Vec<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> },
    #[serde(other)]
    Other,
}

生产代码中的字段带有 serde/TypeScript 注解;上面的展开保留了变体和关键字段,便于对照源码。Other 是兼容未知 wire 类型的兜底,不能据此推断所有下游消费者都能展示未知内容。

2. 输入提升 ​

源码位置:codex-rs/protocol/src/models.rs :: From<ResponseInputItem> for ResponseItem

rust
impl From<ResponseInputItem> for ResponseItem {
    fn from(item: ResponseInputItem) -> Self {
        match item {
            ResponseInputItem::Message { role, content, phase } => Self::Message {
                role,
                content,
                id: None,
                phase,
                internal_chat_message_metadata_passthrough: None,
            },
            ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput {
                id: None,
                call_id: Some(call_id),
                name: None,
                namespace: None,
                output,
                internal_chat_message_metadata_passthrough: None,
            },
            ResponseInputItem::McpToolCallOutput { call_id, output } => {
                let output = output.into_function_call_output_payload();
                Self::FunctionCallOutput {
                    id: None,
                    call_id: Some(call_id),
                    name: None,
                    namespace: None,
                    output,
                    internal_chat_message_metadata_passthrough: None,
                }
            }
            ResponseInputItem::CustomToolCallOutput { call_id, name, output } => {
                Self::CustomToolCallOutput {
                    id: None,
                    call_id,
                    name,
                    output,
                    internal_chat_message_metadata_passthrough: None,
                }
            }
            ResponseInputItem::ToolSearchOutput {
                call_id,
                status,
                execution,
                tools,
            } => Self::ToolSearchOutput {
                call_id: Some(call_id),
                status,
                execution,
                tools,
                id: None,
                internal_chat_message_metadata_passthrough: None,
            },
        }
    }
}

提升时多数 item 的 id 仍为空;工具输出的 call_id 被保留用于配对。MCP 输出被转换成通用 FunctionCallOutput,因此历史重点是 Responses 可理解的关联,而不是保留原始 Rust 变体名称。

3. ID与调用关联 ​

ResponseItemId 是 Responses item 的稳定标识,call_id 是工具调用与结果之间的配对键。一个 output 可以没有 item ID,但如果它是配对输出,仍需要 call ID;反过来,拥有 item ID 也不代表存在对应工具调用。

源码位置:codex-rs/protocol/src/models.rs :: ResponseItem::id、set_id

rust
pub fn id(&self) -> Option<&ResponseItemId> {
    match self {
        Self::AdditionalTools { id, .. }
        | Self::Message { id, .. }
        | Self::AgentMessage { id, .. }
        | Self::LocalShellCall { id, .. }
        | Self::FunctionCall { id, .. }
        | Self::ToolSearchCall { id, .. }
        | Self::FunctionCallOutput { id, .. }
        | Self::CustomToolCall { id, .. }
        | Self::CustomToolCallOutput { id, .. }
        | Self::ToolSearchOutput { id, .. }
        | Self::WebSearchCall { id, .. }
        | Self::Reasoning { id, .. }
        | Self::ImageGenerationCall { id, .. }
        | Self::Compaction { id, .. }
        | Self::ContextCompaction { id, .. } => id.as_ref(),
        Self::CompactionTrigger { .. } | Self::Other => None,
    }
}

CompactionTrigger 和 Other 没有可读取的 item ID。Core 只能对有 ID 前缀的变体补 ID;控制型或未知变体保持无 ID。

4. 历史准备 ​

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

rust
pub(crate) fn prepare_conversation_items_for_history<'a>(
    &self,
    turn_context: &TurnContext,
    items: &'a [ResponseItem],
) -> (Cow<'a, [ResponseItem]>, Vec<ImagePreparationMetadata>) {
    let mut items = items.to_vec();
    let image_preparation_mode = if unified_image_budget_enabled(
        &turn_context.config.features,
        &turn_context.model_info,
    ) {
        ImagePreparationMode::UnifiedBudget
    } else {
        ImagePreparationMode::DetailBased
    };
    let image_resize_notice_mode = if turn_context
        .config
        .features
        .enabled(Feature::ImageResizeNotice)
    {
        ImageResizeNoticeMode::Enabled
    } else {
        ImageResizeNoticeMode::Disabled
    };
    let image_preparations = prepare_image_response_items(
        &mut items,
        image_preparation_mode,
        image_resize_notice_mode,
    );
    prepare_audio_response_items(&mut items);
    for item in &mut items {
        Self::stamp_response_item_for_history(item, &turn_context.sub_id);
    }
    let items = Cow::Owned(items);
    (Self::assign_missing_response_item_ids(items), image_preparations)
}

历史边界先处理图片/音频,再补 turn ID,最后补 Responses item ID。顺序决定了历史中记录的是准备后的内容,而不是模型请求前的临时对象。

源码位置:codex-rs/core/src/session/mod.rs :: stamp_response_item_for_history、assign_missing_response_item_id

rust
pub(crate) fn stamp_response_item_for_history(item: &mut ResponseItem, turn_id: &str) {
    item.set_turn_id_if_missing(turn_id);
    item.set_create_time_if_missing(Self::response_item_create_time());
}

fn assign_missing_response_item_id(item: &mut ResponseItem) {
    if item.id().is_some_and(|id| !id.is_empty()) {
        return;
    }
    let Some(prefix) = item.id_prefix() else {
        return;
    };
    item.set_id(Some(ResponseItemId::new(prefix)));
}

turn ID 和创建时间属于 harness passthrough metadata;Responses item ID 属于 item 本身。已有非空 ID 不会被覆盖,因此恢复或重试不会随意改变外部关联。

5. 记录与落盘 ​

源码位置:codex-rs/core/src/session/mod.rs :: record_conversation_items、record_prepared_conversation_items

rust
pub(crate) async fn record_conversation_items(
    &self,
    turn_context: &TurnContext,
    items: &[ResponseItem],
) {
    let (items, image_preparations) =
        self.prepare_conversation_items_for_history(turn_context, items);
    let items = items
        .into_owned()
        .into_iter()
        .map(ResponseItemEnvelope::new)
        .collect();
    self.record_prepared_conversation_items(turn_context, items, image_preparations)
        .await;
}

async fn record_prepared_conversation_items(
    &self,
    turn_context: &TurnContext,
    items: Vec<ResponseItemEnvelope>,
    image_preparations: Vec<ImagePreparationMetadata>,
) {
    let response_items = items
        .iter()
        .map(|envelope| envelope.item.clone())
        .collect::<Vec<_>>();
    {
        let mut state = self.state.lock().await;
        state
            .history
            .record_annotated_items(&items, turn_context.model_info.truncation_policy.into());
    }
    let rollout_items: Vec<RolloutItem> =
        items.into_iter().map(RolloutItem::ResponseItem).collect();
    self.persist_rollout_items(&rollout_items).await;
}

内存 history 保存带 envelope 的 item,rollout 则保存 RolloutItem::ResponseItem。record_conversation_items 返回并不意味着磁盘写入已经完成;恢复问题要继续读 rollout 管线和 thread store。

6. Core投影 ​

源码位置: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(codex_protocol::items::ImageGenerationItem {
                id: id.as_deref()?.to_string(),
                status: status.clone(),
                revised_prompt: revised_prompt.clone(),
                result: result.clone(),
                saved_path: None,
            }))
        }
        _ => None,
    }
}

这不是全量转换器。user message 可能进一步被识别为 hook prompt,assistant message 变成 agent message,reasoning/search/image generation 有专门的 TurnItem;工具调用等其他变体返回 None,由其他路径消费。

7. Prompt归一化 ​

源码位置:codex-rs/core/src/context_manager/history.rs :: for_prompt_annotated、normalize_history

rust
pub(crate) fn for_prompt_annotated(
    mut self,
    input_modalities: &[InputModality],
) -> Vec<ResponseItemEnvelope> {
    self.normalize_history(input_modalities);
    Arc::unwrap_or_clone(self.items)
}

fn normalize_history(&mut self, input_modalities: &[InputModality]) {
    let items = Arc::make_mut(&mut self.items);
    normalize::ensure_call_outputs_present(items);
    normalize::remove_orphan_outputs(items);
    normalize::strip_images_when_unsupported(input_modalities, items);
    normalize::strip_audio_when_unsupported(input_modalities, items);
}

归一化维护四类不变量:function/custom/local-shell/tool-search call 要有对应 output;成对 output 要有 call(服务器工具搜索 output 是例外);模型不支持的图片和音频要被移除。它发生在准备下一次 prompt 时,不会重新执行工具。

源码位置:codex-rs/core/src/context_manager/normalize.rs :: ensure_call_outputs_present、synthetic_output_id

rust
pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItemEnvelope>) {
    let mut function_output_ids = HashSet::new();
    for envelope in items.iter() {
        if let ResponseItem::FunctionCallOutput {
            call_id: Some(call_id), ..
        } = &envelope.item
        {
            function_output_ids.insert(call_id.as_str());
        }
    }
    let mut missing_outputs_to_insert = Vec::new();
    for (idx, envelope) in items.iter().enumerate() {
        if let ResponseItem::FunctionCall { id, call_id, .. } = &envelope.item
            && !function_output_ids.contains(call_id.as_str())
        {
            missing_outputs_to_insert.push((
                idx,
                ResponseItemEnvelope::new(ResponseItem::FunctionCallOutput {
                    id: synthetic_output_id("fco", id.as_deref()),
                    call_id: Some(call_id.clone()),
                    name: None,
                    namespace: None,
                    output: FunctionCallOutputPayload::from_text("aborted".to_string()),
                    internal_chat_message_metadata_passthrough: None,
                }),
            ));
        }
    }
    for (idx, output_item) in missing_outputs_to_insert.into_iter().rev() {
        items.insert(idx + 1, output_item);
    }
}

fn synthetic_output_id(prefix: &str, item_id: Option<&str>) -> Option<ResponseItemId> {
    let source_id = item_id.filter(|id| !id.is_empty())?;
    let name = format!("{prefix}:{source_id}");
    Some(ResponseItemId::with_suffix(
        prefix,
        Uuid::new_v5(&SYNTHETIC_OUTPUT_ID_NAMESPACE, name.as_bytes()),
    ))
}

合成 output 的 UUIDv5 namespace 和 name 格式必须稳定,因为归一化可能重复运行且合成项不会持久化。稳定 ID 只保证 prompt 结构重复时的可复用性,不表示工具真的返回过成功结果。

8. App Server投影 ​

源码位置:codex-rs/app-server-protocol/src/protocol/v2/item.rs :: From<CoreTurnItem> for ThreadItem

rust
CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage {
    id: user.id,
    client_id: user.client_id,
    content: user.content.into_iter().map(UserInput::from).collect(),
},
CoreTurnItem::AgentMessage(agent) => {
    let text = agent
        .content
        .into_iter()
        .map(|entry| match entry {
            CoreAgentMessageContent::Text { text } => text,
        })
        .collect::<String>();
    ThreadItem::AgentMessage {
        id: agent.id,
        text,
        phase: agent.phase,
        memory_citation: agent.memory_citation.map(Into::into),
        delivery: agent.delivery,
    }
}

App Server 的 ThreadItem 是面向客户端的展示契约,不是 ResponseItem 的 JSON 原样转发。它会把 agent message 内容拼成 text,对命令、MCP、dynamic tool、collaboration 和 extension item 分别投影。某个 item 能进入历史,不代表它一定有一对一的客户端展示项。

9. 测试与边界 ​

测试应分别验证协议 item 的 wire 形状、Core 历史边界的 ID/时间戳处理,以及归一化对不完整工具历史的修复。

源码位置:codex-rs/protocol/src/models.rs :: serializes_image_user_input_without_tags、response_item_id_getter_and_setter

源码位置:codex-rs/core/src/session/tests.rs :: assign_missing_response_item_ids_assigns_agent_message_ids、record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id

源码位置:codex-rs/core/src/context_manager/history_tests.rs :: normalize_adds_missing_output_for_function_call、normalize_removes_orphan_function_call_output

text
cd codex-rs
cargo test -p codex-protocol serializes_image_user_input_without_tags -- --nocapture --test-threads=1
cargo test -p codex-protocol response_item_id_getter_and_setter -- --nocapture --test-threads=1
cargo test -p codex-core assign_missing_response_item_ids_assigns_agent_message_ids -- --nocapture --test-threads=1
cargo test -p codex-core record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id -- --nocapture --test-threads=1
cargo test -p codex-core normalize_adds_missing_output_for_function_call -- --nocapture --test-threads=1
cargo test -p codex-core normalize_removes_orphan_function_call_output -- --nocapture --test-threads=1

这些测试能说明 wire 映射、ID 补全、turn metadata stamping 和 call/output 归一化;不能证明每个未知变体都有客户端投影,也不能证明合成 output 触发了真实工具执行。

10. 源码定位练习 ​

遇到“历史里有 item,但下一次请求没有它”,先看 History::for_prompt_annotated 的归一化和 input modalities;遇到“客户端看不到 item”,再看 parse_turn_item 和 App Server ThreadItem 投影是否支持该变体。

遇到“同一个工具重复请求”,同时记录 ResponseItemId、call_id、call/output 顺序和是否出现 synthetic output。遇到“恢复后 ID 变化”,检查 assign_missing_response_item_id 和持久化前的 item 是否已有非空 ID。

把 ResponseInputItem、ResponseItem、ResponseItemEnvelope、TurnItem 和 ThreadItem 分层阅读,才能准确判断一个会话 item 是被转换、被归一化、被过滤,还是仅仅没有对应的展示适配器。