Codex Handbook
protocol/src/items.rs 656 lines
use crate::mcp::CallToolResult;use crate::memory_citation::MemoryCitation;use crate::models::ContentItem;use crate::models::ImageDetail;use crate::models::MessagePhase;use crate::models::ResponseItem;use crate::models::WebSearchAction;use crate::protocol::AgentMessageEvent;use crate::protocol::AgentReasoningEvent;use crate::protocol::AgentReasoningRawContentEvent;use crate::protocol::ContextCompactedEvent;use crate::protocol::EventMsg;use crate::protocol::FileChange;use crate::protocol::ImageGenerationEndEvent;use crate::protocol::McpInvocation;use crate::protocol::McpToolCallBeginEvent;use crate::protocol::McpToolCallEndEvent;use crate::protocol::PatchApplyBeginEvent;use crate::protocol::PatchApplyEndEvent;use crate::protocol::PatchApplyStatus;use crate::protocol::UserMessageEvent;use crate::protocol::ViewImageToolCallEvent;use crate::protocol::WebSearchEndEvent;use crate::user_input::ByteRange;use crate::user_input::TextElement;use crate::user_input::UserInput;use codex_utils_absolute_path::AbsolutePathBuf;use quick_xml::de::from_str as from_xml_str;use quick_xml::se::to_string as to_xml_string;use schemars::JsonSchema;use serde::Deserialize;use serde::Serialize;use std::collections::HashMap;use std::path::PathBuf;use std::time::Duration;use ts_rs::TS;#[allow(clippy::large_enum_variant)]#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]#[serde(tag = "type")]#[ts(tag = "type")]pub enum TurnItem {    UserMessage(UserMessageItem),    HookPrompt(HookPromptItem),    AgentMessage(AgentMessageItem),    Plan(PlanItem),    Reasoning(ReasoningItem),    WebSearch(WebSearchItem),    ImageView(ImageViewItem),    Sleep(SleepItem),    ImageGeneration(ImageGenerationItem),    FileChange(FileChangeItem),    McpToolCall(McpToolCallItem),    ContextCompaction(ContextCompactionItem),}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]pub struct UserMessageItem {    pub id: String,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub client_id: Option<String>,    pub content: Vec<UserInput>,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]pub struct HookPromptItem {    pub id: String,    pub fragments: Vec<HookPromptFragment>,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]#[serde(rename_all = "camelCase")]#[ts(rename_all = "camelCase")]pub struct HookPromptFragment {    pub text: String,    pub hook_run_id: String,}#[derive(Debug, Deserialize, Serialize)]#[serde(rename = "hook_prompt")]struct HookPromptXml {    #[serde(rename = "@hook_run_id")]    hook_run_id: String,    #[serde(rename = "$text")]    text: String,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]#[serde(tag = "type")]#[ts(tag = "type")]pub enum AgentMessageContent {    Text { text: String },}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]/// Assistant-authored message payload used in turn-item streams.////// `phase` is optional because not all providers/models emit it. Consumers/// should use it when present, but retain legacy completion semantics when it/// is `None`.pub struct AgentMessageItem {    pub id: String,    pub content: Vec<AgentMessageContent>,    /// Optional phase metadata carried through from `ResponseItem::Message`.    ///    /// This is currently used by TUI rendering to distinguish mid-turn    /// commentary from a final answer and avoid status-indicator jitter.    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub phase: Option<MessagePhase>,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub memory_citation: Option<MemoryCitation>,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]pub struct PlanItem {    pub id: String,    pub text: String,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]pub struct ReasoningItem {    pub id: String,    pub summary_text: Vec<String>,    #[serde(default)]    pub raw_content: Vec<String>,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]pub struct WebSearchItem {    pub id: String,    pub query: String,    pub action: WebSearchAction,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]pub struct ImageViewItem {    pub id: String,    pub path: AbsolutePathBuf,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]pub struct SleepItem {    pub id: String,    pub duration_ms: u64,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]pub struct ImageGenerationItem {    pub id: String,    pub status: String,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub revised_prompt: Option<String>,    pub result: String,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub saved_path: Option<AbsolutePathBuf>,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]pub struct FileChangeItem {    pub id: String,    pub changes: HashMap<PathBuf, FileChange>,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub status: Option<PatchApplyStatus>,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub auto_approved: Option<bool>,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub stdout: Option<String>,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub stderr: Option<String>,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]#[serde(rename_all = "camelCase")]#[ts(rename_all = "camelCase")]pub struct McpToolCallItem {    pub id: String,    pub server: String,    pub tool: String,    pub arguments: serde_json::Value,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub mcp_app_resource_uri: Option<String>,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub plugin_id: Option<String>,    pub status: McpToolCallStatus,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub result: Option<CallToolResult>,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(optional)]    pub error: Option<McpToolCallError>,    #[serde(default, skip_serializing_if = "Option::is_none")]    #[ts(type = "string", optional)]    pub duration: Option<Duration>,}#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]#[serde(rename_all = "camelCase")]#[ts(rename_all = "camelCase")]pub enum McpToolCallStatus {    InProgress,    Completed,    Failed,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]#[serde(rename_all = "camelCase")]#[ts(rename_all = "camelCase")]pub struct McpToolCallError {    pub message: String,}#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]pub struct ContextCompactionItem {    pub id: String,}impl ContextCompactionItem {    pub fn new() -> Self {        Self {            id: uuid::Uuid::new_v4().to_string(),        }    }    pub fn as_legacy_event(&self) -> EventMsg {        EventMsg::ContextCompacted(ContextCompactedEvent {})    }}impl Default for ContextCompactionItem {    fn default() -> Self {        Self::new()    }}impl UserMessageItem {    pub fn new(content: &[UserInput]) -> Self {        Self {            id: uuid::Uuid::new_v4().to_string(),            client_id: None,            content: content.to_vec(),        }    }    pub fn as_legacy_event(&self) -> EventMsg {        // Legacy user-message events flatten only text inputs into `message` and        // rebase text element ranges onto that concatenated text.        EventMsg::UserMessage(UserMessageEvent {            client_id: self.client_id.clone(),            message: self.message(),            images: Some(self.image_urls()),            image_details: self.image_details(),            local_images: self.local_image_paths(),            local_image_details: self.local_image_details(),            text_elements: self.text_elements(),        })    }    pub fn message(&self) -> String {        self.content            .iter()            .map(|c| match c {                UserInput::Text { text, .. } => text.clone(),                _ => String::new(),            })            .collect::<Vec<String>>()            .join("")    }    pub fn text_elements(&self) -> Vec<TextElement> {        let mut out = Vec::new();        let mut offset = 0usize;        for input in &self.content {            if let UserInput::Text {                text,                text_elements,                ..            } = input            {                // Text element ranges are relative to each text chunk; offset them so they align                // with the concatenated message returned by `message()`.                for elem in text_elements {                    let byte_range = ByteRange {                        start: offset + elem.byte_range.start,                        end: offset + elem.byte_range.end,                    };                    out.push(TextElement::new(                        byte_range,                        elem.placeholder(text).map(str::to_string),                    ));                }                offset += text.len();            }        }        out    }    pub fn image_urls(&self) -> Vec<String> {        self.content            .iter()            .filter_map(|c| match c {                UserInput::Image { image_url, .. } => Some(image_url.clone()),                _ => None,            })            .collect()    }    pub fn image_details(&self) -> Vec<Option<ImageDetail>> {        trim_trailing_default_image_details(            self.content                .iter()                .filter_map(|c| match c {                    UserInput::Image { detail, .. } => Some(*detail),                    _ => None,                })                .collect(),        )    }    pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {        self.content            .iter()            .filter_map(|c| match c {                UserInput::LocalImage { path, .. } => Some(path.clone()),                _ => None,            })            .collect()    }    pub fn local_image_details(&self) -> Vec<Option<ImageDetail>> {        trim_trailing_default_image_details(            self.content                .iter()                .filter_map(|c| match c {                    UserInput::LocalImage { detail, .. } => Some(*detail),                    _ => None,                })                .collect(),        )    }}fn trim_trailing_default_image_details(    mut details: Vec<Option<ImageDetail>>,) -> Vec<Option<ImageDetail>> {    while matches!(details.last(), Some(None)) {        details.pop();    }    details}impl HookPromptItem {    pub fn from_fragments(id: Option<&String>, fragments: Vec<HookPromptFragment>) -> Self {        Self {            id: id                .cloned()                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),            fragments,        }    }}impl HookPromptFragment {    pub fn from_single_hook(text: impl Into<String>, hook_run_id: impl Into<String>) -> Self {        Self {            text: text.into(),            hook_run_id: hook_run_id.into(),        }    }}pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<ResponseItem> {    let content = fragments        .iter()        .filter(|fragment| !fragment.hook_run_id.trim().is_empty())        .filter_map(|fragment| {            serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id)                .map(|text| ContentItem::InputText { text })        })        .collect::<Vec<_>>();    if content.is_empty() {        return None;    }    Some(ResponseItem::Message {        id: Some(uuid::Uuid::new_v4().to_string()),        role: "user".to_string(),        content,        phase: None,        metadata: None,    })}pub fn parse_hook_prompt_message(    id: Option<&String>,    content: &[ContentItem],) -> Option<HookPromptItem> {    let fragments = content        .iter()        .map(|content_item| {            let ContentItem::InputText { text } = content_item else {                return None;            };            parse_hook_prompt_fragment(text)        })        .collect::<Option<Vec<_>>>()?;    if fragments.is_empty() {        return None;    }    Some(HookPromptItem::from_fragments(id, fragments))}pub fn parse_hook_prompt_fragment(text: &str) -> Option<HookPromptFragment> {    let trimmed = text.trim();    let HookPromptXml { text, hook_run_id } = from_xml_str::<HookPromptXml>(trimmed).ok()?;    if hook_run_id.trim().is_empty() {        return None;    }    Some(HookPromptFragment { text, hook_run_id })}fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option<String> {    if hook_run_id.trim().is_empty() {        return None;    }    to_xml_string(&HookPromptXml {        text: text.to_string(),        hook_run_id: hook_run_id.to_string(),    })    .ok()}impl AgentMessageItem {    pub fn new(content: &[AgentMessageContent]) -> Self {        Self {            id: uuid::Uuid::new_v4().to_string(),            content: content.to_vec(),            phase: None,            memory_citation: None,        }    }    pub fn as_legacy_events(&self) -> Vec<EventMsg> {        self.content            .iter()            .map(|c| match c {                AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent {                    message: text.clone(),                    phase: self.phase.clone(),                    memory_citation: self.memory_citation.clone(),                }),            })            .collect()    }}impl ReasoningItem {    pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {        let mut events = Vec::new();        for summary in &self.summary_text {            events.push(EventMsg::AgentReasoning(AgentReasoningEvent {                text: summary.clone(),            }));        }        if show_raw_agent_reasoning {            for entry in &self.raw_content {                events.push(EventMsg::AgentReasoningRawContent(                    AgentReasoningRawContentEvent {                        text: entry.clone(),                    },                ));            }        }        events    }}impl WebSearchItem {    pub fn as_legacy_event(&self) -> EventMsg {        EventMsg::WebSearchEnd(WebSearchEndEvent {            call_id: self.id.clone(),            query: self.query.clone(),            action: self.action.clone(),        })    }}impl ImageGenerationItem {    pub fn as_legacy_event(&self) -> EventMsg {        EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {            call_id: self.id.clone(),            status: self.status.clone(),            revised_prompt: self.revised_prompt.clone(),            result: self.result.clone(),            saved_path: self.saved_path.clone(),        })    }}impl FileChangeItem {    pub fn as_legacy_begin_event(&self, turn_id: String) -> EventMsg {        EventMsg::PatchApplyBegin(PatchApplyBeginEvent {            call_id: self.id.clone(),            turn_id,            auto_approved: self.auto_approved.unwrap_or(false),            changes: self.changes.clone(),        })    }    pub fn as_legacy_end_event(&self, turn_id: String) -> Option<EventMsg> {        let status = self.status.clone()?;        Some(EventMsg::PatchApplyEnd(PatchApplyEndEvent {            call_id: self.id.clone(),            turn_id,            stdout: self.stdout.clone().unwrap_or_default(),            stderr: self.stderr.clone().unwrap_or_default(),            success: status == PatchApplyStatus::Completed,            changes: self.changes.clone(),            status,        }))    }}impl McpToolCallItem {    pub fn as_legacy_begin_event(&self) -> EventMsg {        EventMsg::McpToolCallBegin(McpToolCallBeginEvent {            call_id: self.id.clone(),            invocation: McpInvocation {                server: self.server.clone(),                tool: self.tool.clone(),                arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),            },            mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),            plugin_id: self.plugin_id.clone(),        })    }    pub fn as_legacy_end_event(&self) -> Option<EventMsg> {        let result = match (&self.result, &self.error) {            (Some(result), _) => Ok(result.clone()),            (None, Some(error)) => Err(error.message.clone()),            (None, None) => return None,        };        Some(EventMsg::McpToolCallEnd(McpToolCallEndEvent {            call_id: self.id.clone(),            invocation: McpInvocation {                server: self.server.clone(),                tool: self.tool.clone(),                arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),            },            mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),            plugin_id: self.plugin_id.clone(),            duration: self.duration?,            result,        }))    }}impl TurnItem {    pub fn id(&self) -> String {        match self {            TurnItem::UserMessage(item) => item.id.clone(),            TurnItem::HookPrompt(item) => item.id.clone(),            TurnItem::AgentMessage(item) => item.id.clone(),            TurnItem::Plan(item) => item.id.clone(),            TurnItem::Reasoning(item) => item.id.clone(),            TurnItem::WebSearch(item) => item.id.clone(),            TurnItem::ImageView(item) => item.id.clone(),            TurnItem::Sleep(item) => item.id.clone(),            TurnItem::ImageGeneration(item) => item.id.clone(),            TurnItem::FileChange(item) => item.id.clone(),            TurnItem::McpToolCall(item) => item.id.clone(),            TurnItem::ContextCompaction(item) => item.id.clone(),        }    }    pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {        match self {            TurnItem::UserMessage(item) => vec![item.as_legacy_event()],            TurnItem::HookPrompt(_) => Vec::new(),            TurnItem::AgentMessage(item) => item.as_legacy_events(),            TurnItem::Plan(_) => Vec::new(),            TurnItem::WebSearch(item) => vec![item.as_legacy_event()],            TurnItem::ImageView(item) => {                vec![EventMsg::ViewImageToolCall(ViewImageToolCallEvent {                    call_id: item.id.clone(),                    path: item.path.clone(),                })]            }            TurnItem::Sleep(_) => Vec::new(),            TurnItem::ImageGeneration(item) => vec![item.as_legacy_event()],            TurnItem::FileChange(item) => item                .as_legacy_end_event(String::new())                .into_iter()                .collect(),            TurnItem::McpToolCall(item) => item.as_legacy_end_event().into_iter().collect(),            TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning),            TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()],        }    }}#[cfg(test)]mod tests {    use super::*;    use pretty_assertions::assert_eq;    #[test]    fn hook_prompt_roundtrips_multiple_fragments() {        let original = vec![            HookPromptFragment::from_single_hook("Retry with care & joy.", "hook-run-1"),            HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"),        ];        let message = build_hook_prompt_message(&original).expect("hook prompt");        let ResponseItem::Message { content, .. } = message else {            panic!("expected hook prompt message");        };        let parsed = parse_hook_prompt_message(/*id*/ None, &content).expect("parsed hook prompt");        assert_eq!(parsed.fragments, original);    }    #[test]    fn hook_prompt_parses_legacy_single_hook_run_id() {        let parsed = parse_hook_prompt_fragment(            r#"<hook_prompt hook_run_id="hook-run-1">Retry with tests.</hook_prompt>"#,        )        .expect("legacy hook prompt");        assert_eq!(            parsed,            HookPromptFragment {                text: "Retry with tests.".to_string(),                hook_run_id: "hook-run-1".to_string(),            }        );    }}