| use crate::AgentPath; |
| use crate::ResponseItemId; |
| use crate::ThreadId; |
| use crate::dynamic_tools::DynamicToolCallOutputContentItem; |
| use crate::mcp::CallToolResult; |
| use crate::memory_citation::MemoryCitation; |
| use crate::models::ContentItem; |
| use crate::models::FunctionCallOutputBody; |
| use crate::models::ImageDetail; |
| use crate::models::ImageReference; |
| use crate::models::MessagePhase; |
| use crate::models::ResponseItem; |
| use crate::models::WebSearchAction; |
| use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; |
| use crate::parse_command::ParsedCommand; |
| use crate::protocol::AgentStatus; |
| use crate::protocol::CollabAgentRef; |
| use crate::protocol::ExecCommandSource; |
| use crate::protocol::ExecCommandStatus; |
| use crate::protocol::FileChange; |
| use crate::protocol::PatchApplyStatus; |
| use crate::protocol::ReviewOutputEvent; |
| use crate::protocol::ReviewTarget; |
| use crate::protocol::SubAgentActivityKind; |
| use crate::user_input::ByteRange; |
| use crate::user_input::TextElement; |
| use crate::user_input::UserInput; |
| use codex_extension_items::ExtensionItem; |
| use codex_utils_absolute_path::AbsolutePathBuf; |
| use codex_utils_path_uri::PathUri; |
| 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 serde_json::Value as JsonValue; |
| 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), |
| FunctionCallOutput(FunctionCallOutputItem), |
| 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), |
| } |
|
|
| #[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)] |
| pub struct FunctionCallOutputItem { |
| pub id: String, |
| pub name: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub namespace: Option<String>, |
| pub output: FunctionCallOutputBody, |
| } |
|
|
| #[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, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| #[serde(rename_all = "camelCase")] |
| #[ts(rename_all = "camelCase")] |
| #[ts(export_to = "v2/")] |
| pub enum AgentMessageDelivery { |
| Async, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| #[serde(rename_all = "camelCase", deny_unknown_fields)] |
| #[ts(export_to = "v2/")] |
| pub struct AsyncUserInputQuestion { |
| pub title: String, |
| pub options: Option<Vec<String>>, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] |
| |
| |
| |
| |
| |
| pub struct AgentMessageItem { |
| pub id: String, |
| pub content: Vec<AgentMessageContent>, |
| |
| |
| |
| |
| #[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>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub delivery: Option<AgentMessageDelivery>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub questions: Option<Vec<AsyncUserInputQuestion>>, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] |
| pub struct EnteredReviewModeItem { |
| pub id: String, |
| pub target: ReviewTarget, |
| pub user_facing_hint: String, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] |
| pub struct ExitedReviewModeItem { |
| pub id: String, |
| pub review_output: Option<ReviewOutputEvent>, |
| } |
|
|
| #[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, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| #[serde(rename_all = "snake_case")] |
| pub enum CommandExecutionStatus { |
| InProgress, |
| Completed, |
| Failed, |
| Declined, |
| } |
|
|
| impl From<ExecCommandStatus> for CommandExecutionStatus { |
| fn from(value: ExecCommandStatus) -> Self { |
| match value { |
| ExecCommandStatus::Completed => Self::Completed, |
| ExecCommandStatus::Failed => Self::Failed, |
| ExecCommandStatus::Declined => Self::Declined, |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| pub fn is_safe_plugin_relative_path(path: &str) -> bool { |
| !path.is_empty() |
| && !path.starts_with('/') |
| && !path.contains('\\') |
| && path.split('/').all(|component| { |
| !component.is_empty() |
| && !matches!(component, "." | "..") |
| && !matches!( |
| component.as_bytes(), |
| [drive, b':', ..] if drive.is_ascii_alphabetic() |
| ) |
| }) |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Eq)] |
| pub struct ModelInvocationContext { |
| pub model_slug: String, |
| pub reasoning_effort: Option<String>, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] |
| pub struct CommandExecutionItem { |
| #[serde(skip)] |
| #[schemars(skip)] |
| #[ts(skip)] |
| pub model_context: Option<ModelInvocationContext>, |
| pub id: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub plugin_id: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub script_path: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub process_id: Option<String>, |
| pub command: Vec<String>, |
| pub cwd: PathUri, |
| pub parsed_cmd: Vec<ParsedCommand>, |
| pub source: ExecCommandSource, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub interaction_input: Option<String>, |
| pub status: CommandExecutionStatus, |
| #[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>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub aggregated_output: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub exit_code: Option<i32>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(type = "string", optional)] |
| pub duration: Option<Duration>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub formatted_output: Option<String>, |
| } |
|
|
| #[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| #[serde(rename_all = "snake_case")] |
| pub enum DynamicToolCallStatus { |
| InProgress, |
| Completed, |
| Failed, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] |
| pub struct DynamicToolCallItem { |
| pub id: String, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub namespace: Option<String>, |
| pub tool: String, |
| pub arguments: serde_json::Value, |
| pub status: DynamicToolCallStatus, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub content_items: Option<Vec<DynamicToolCallOutputContentItem>>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub success: Option<bool>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub error: Option<String>, |
| #[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 = "snake_case")] |
| pub enum CollabAgentTool { |
| SpawnAgent, |
| SendInput, |
| ResumeAgent, |
| Wait, |
| CloseAgent, |
| SendMessage, |
| FollowupTask, |
| InterruptAgent, |
| ListAgents, |
| } |
|
|
| #[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| #[serde(rename_all = "snake_case")] |
| pub enum CollabAgentToolCallStatus { |
| InProgress, |
| Completed, |
| Failed, |
| Interrupted, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] |
| pub struct CollabAgentToolCallItem { |
| pub id: String, |
| pub tool: CollabAgentTool, |
| pub status: CollabAgentToolCallStatus, |
| pub sender_thread_id: ThreadId, |
| #[serde(default)] |
| pub receiver_thread_ids: Vec<ThreadId>, |
| #[serde(default)] |
| pub receiver_agents: Vec<CollabAgentRef>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub prompt: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub model: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub reasoning_effort: Option<ReasoningEffortConfig>, |
| #[serde(default)] |
| pub agents_states: HashMap<ThreadId, AgentStatus>, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| pub struct SubAgentActivityItem { |
| pub id: String, |
| pub kind: SubAgentActivityKind, |
| pub agent_thread_id: ThreadId, |
| pub agent_path: AgentPath, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] |
| pub struct WebSearchItem { |
| pub id: String, |
| pub query: String, |
| pub action: WebSearchAction, |
| |
| |
| |
| |
| |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub results: Option<Vec<JsonValue>>, |
| } |
|
|
| #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] |
| pub struct ImageViewItem { |
| pub id: String, |
| |
| |
| |
| |
| pub path: PathUri, |
| } |
|
|
| #[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, Eq)] |
| #[serde(rename_all = "camelCase")] |
| #[ts(rename_all = "camelCase")] |
| #[ts(export_to = "v2/")] |
| pub struct McpAppUi { |
| pub resource_uri: String, |
| pub preferred_model_display_mode: McpAppDisplayMode, |
| } |
|
|
| #[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] |
| #[serde(rename_all = "camelCase")] |
| #[ts(rename_all = "camelCase")] |
| #[ts(export_to = "v2/")] |
| pub enum McpAppDisplayMode { |
| Inline, |
| Fullscreen, |
| } |
|
|
| #[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 connector_id: Option<String>, |
| |
| #[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 mcp_app_ui: Option<McpAppUi>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub link_id: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub app_name: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub action_name: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub plugin_id: Option<String>, |
| #[serde(default, skip_serializing_if = "Option::is_none")] |
| #[ts(optional)] |
| pub read_only_hint: Option<bool>, |
| 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, |
| } |
|
|
| fn new_item_id() -> String { |
| uuid::Uuid::now_v7().to_string() |
| } |
|
|
| impl ContextCompactionItem { |
| pub fn new() -> Self { |
| Self { id: new_item_id() } |
| } |
| } |
|
|
| impl Default for ContextCompactionItem { |
| fn default() -> Self { |
| Self::new() |
| } |
| } |
|
|
| impl UserMessageItem { |
| pub fn new(content: &[UserInput]) -> Self { |
| Self { |
| id: new_item_id(), |
| client_id: None, |
| content: content.to_vec(), |
| } |
| } |
|
|
| 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 |
| { |
| |
| |
| 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: ImageReference::Inline { 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 { |
| image: ImageReference::Inline { .. }, |
| 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(), |
| ) |
| } |
|
|
| pub fn audio_urls(&self) -> Vec<String> { |
| self.content |
| .iter() |
| .filter_map(|c| match c { |
| UserInput::Audio { audio_url } => Some(audio_url.clone()), |
| _ => None, |
| }) |
| .collect() |
| } |
|
|
| pub fn local_audio_paths(&self) -> Vec<std::path::PathBuf> { |
| self.content |
| .iter() |
| .filter_map(|c| match c { |
| UserInput::LocalAudio { path } => Some(path.clone()), |
| _ => None, |
| }) |
| .collect() |
| } |
| } |
|
|
| pub(crate) 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<&str>, fragments: Vec<HookPromptFragment>) -> Self { |
| Self { |
| id: id.map(str::to_string).unwrap_or_else(new_item_id), |
| 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(ResponseItemId::new("msg")), |
| role: "user".to_string(), |
| content, |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }) |
| } |
|
|
| pub fn parse_hook_prompt_message( |
| id: Option<&str>, |
| 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 TurnItem { |
| pub fn id(&self) -> String { |
| match self { |
| TurnItem::UserMessage(item) => item.id.clone(), |
| TurnItem::FunctionCallOutput(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::CommandExecution(item) => item.id.clone(), |
| TurnItem::DynamicToolCall(item) => item.id.clone(), |
| TurnItem::CollabAgentToolCall(item) => item.id.clone(), |
| TurnItem::SubAgentActivity(item) => item.id.clone(), |
| TurnItem::WebSearch(item) => item.id.clone(), |
| TurnItem::ImageView(item) => item.id.clone(), |
| TurnItem::Extension(item) => item.id().to_string(), |
| TurnItem::ImageGeneration(item) => item.id.clone(), |
| TurnItem::EnteredReviewMode(item) => item.id.clone(), |
| TurnItem::ExitedReviewMode(item) => item.id.clone(), |
| TurnItem::FileChange(item) => item.id.clone(), |
| TurnItem::McpToolCall(item) => item.id.clone(), |
| TurnItem::ContextCompaction(item) => item.id.clone(), |
| } |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use codex_extension_items::sleep::SleepItem; |
| use pretty_assertions::assert_eq; |
| use serde_json::json; |
|
|
| #[test] |
| fn sleep_extension_item_preserves_type_and_kind() { |
| let item = TurnItem::Extension(ExtensionItem::Sleep(SleepItem { |
| id: "sleep-1".to_string(), |
| duration_ms: 1_000, |
| })); |
|
|
| assert_eq!( |
| serde_json::to_value(item).expect("serialize sleep extension item"), |
| json!({ |
| "type": "Extension", |
| "kind": "clock.sleep", |
| "id": "sleep-1", |
| "durationMs": 1_000, |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn user_message_item_extracts_audio_attachments() { |
| let item = UserMessageItem::new(&[ |
| UserInput::Text { |
| text: "transcribe these".to_string(), |
| text_elements: Vec::new(), |
| }, |
| UserInput::Audio { |
| audio_url: "https://example.com/remote.mp3".to_string(), |
| }, |
| UserInput::LocalAudio { |
| path: std::path::PathBuf::from("local.wav"), |
| }, |
| ]); |
|
|
| assert_eq!( |
| (item.audio_urls(), item.local_audio_paths()), |
| ( |
| vec!["https://example.com/remote.mp3".to_string()], |
| vec![std::path::PathBuf::from("local.wav")], |
| ) |
| ); |
| } |
|
|
| #[test] |
| fn plugin_relative_paths_use_safe_wire_shape() { |
| assert!(is_safe_plugin_relative_path("scripts/run.py")); |
|
|
| for path in [ |
| "", |
| "/home/user/.codex/plugins/cache/sample/scripts/run.py", |
| "C:/Users/user/.codex/plugins/cache/sample/scripts/run.py", |
| "scripts/C:/run.py", |
| r"\\server\share\sample\scripts\run.py", |
| r"scripts\run.py", |
| "scripts//run.py", |
| "scripts/./run.py", |
| "scripts/../run.py", |
| ] { |
| assert!( |
| !is_safe_plugin_relative_path(path), |
| "unsafe plugin-relative path should be rejected: {path:?}" |
| ); |
| } |
| } |
|
|
| #[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 { id, content, .. } = message else { |
| panic!("expected hook prompt message"); |
| }; |
| assert!(id.is_some_and(|id| id.starts_with("msg_"))); |
|
|
| let parsed = parse_hook_prompt_message( 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(), |
| } |
| ); |
| } |
| } |
|
|