Skip to main content

codex_protocol/
items.rs

1use crate::AgentPath;
2use crate::ResponseItemId;
3use crate::ThreadId;
4use crate::dynamic_tools::DynamicToolCallOutputContentItem;
5use crate::mcp::CallToolResult;
6use crate::memory_citation::MemoryCitation;
7use crate::models::ContentItem;
8use crate::models::ImageDetail;
9use crate::models::MessagePhase;
10use crate::models::ResponseItem;
11use crate::models::WebSearchAction;
12use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
13use crate::parse_command::ParsedCommand;
14use crate::protocol::AgentStatus;
15use crate::protocol::CollabAgentRef;
16use crate::protocol::ExecCommandSource;
17use crate::protocol::ExecCommandStatus;
18use crate::protocol::FileChange;
19use crate::protocol::PatchApplyStatus;
20use crate::protocol::ReviewOutputEvent;
21use crate::protocol::ReviewTarget;
22use crate::protocol::SubAgentActivityKind;
23use crate::user_input::ByteRange;
24use crate::user_input::TextElement;
25use crate::user_input::UserInput;
26use codex_extension_items::ExtensionItem;
27use codex_utils_absolute_path::AbsolutePathBuf;
28use codex_utils_path_uri::PathUri;
29use quick_xml::de::from_str as from_xml_str;
30use quick_xml::se::to_string as to_xml_string;
31use schemars::JsonSchema;
32use serde::Deserialize;
33use serde::Serialize;
34use serde_json::Value as JsonValue;
35use std::collections::HashMap;
36use std::path::PathBuf;
37use std::time::Duration;
38use ts_rs::TS;
39
40#[allow(clippy::large_enum_variant)]
41#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
42#[serde(tag = "type")]
43#[ts(tag = "type")]
44pub enum TurnItem {
45    UserMessage(UserMessageItem),
46    HookPrompt(HookPromptItem),
47    AgentMessage(AgentMessageItem),
48    Plan(PlanItem),
49    Reasoning(ReasoningItem),
50    CommandExecution(CommandExecutionItem),
51    DynamicToolCall(DynamicToolCallItem),
52    CollabAgentToolCall(CollabAgentToolCallItem),
53    SubAgentActivity(SubAgentActivityItem),
54    /// Hosted Responses API web-search item handled directly by core.
55    ///
56    /// Standalone web search uses Self::Extension instead because its display
57    /// schema is owned by the web-search extension.
58    WebSearch(WebSearchItem),
59    ImageView(ImageViewItem),
60    /// Item whose schema and lifecycle details are owned by an extension.
61    ///
62    /// Standalone image generation, sleep, and web search use this path.
63    /// App-server wraps the same typed items in their public variants.
64    Extension(ExtensionItem),
65    /// Hosted Responses API image-generation item handled directly by core.
66    ///
67    /// This remains separate from [`Self::Extension`] because core still owns
68    /// hosted image persistence and legacy-event fanout.
69    ImageGeneration(ImageGenerationItem),
70    EnteredReviewMode(EnteredReviewModeItem),
71    ExitedReviewMode(ExitedReviewModeItem),
72    FileChange(FileChangeItem),
73    McpToolCall(McpToolCallItem),
74    ContextCompaction(ContextCompactionItem),
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
78pub struct UserMessageItem {
79    pub id: String,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    #[ts(optional)]
82    pub client_id: Option<String>,
83    pub content: Vec<UserInput>,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
87pub struct HookPromptItem {
88    pub id: String,
89    pub fragments: Vec<HookPromptFragment>,
90}
91
92#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
93#[serde(rename_all = "camelCase")]
94#[ts(rename_all = "camelCase")]
95pub struct HookPromptFragment {
96    pub text: String,
97    pub hook_run_id: String,
98}
99
100#[derive(Debug, Deserialize, Serialize)]
101#[serde(rename = "hook_prompt")]
102struct HookPromptXml {
103    #[serde(rename = "@hook_run_id")]
104    hook_run_id: String,
105    #[serde(rename = "$text")]
106    text: String,
107}
108
109#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
110#[serde(tag = "type")]
111#[ts(tag = "type")]
112pub enum AgentMessageContent {
113    Text { text: String },
114}
115
116#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
117/// Assistant-authored message payload used in turn-item streams.
118///
119/// `phase` is optional because not all providers/models emit it. Consumers
120/// should use it when present, but retain legacy completion semantics when it
121/// is `None`.
122pub struct AgentMessageItem {
123    pub id: String,
124    pub content: Vec<AgentMessageContent>,
125    /// Optional phase metadata carried through from `ResponseItem::Message`.
126    ///
127    /// This is currently used by TUI rendering to distinguish mid-turn
128    /// commentary from a final answer and avoid status-indicator jitter.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    #[ts(optional)]
131    pub phase: Option<MessagePhase>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    #[ts(optional)]
134    pub memory_citation: Option<MemoryCitation>,
135}
136
137#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
138pub struct EnteredReviewModeItem {
139    pub id: String,
140    pub target: ReviewTarget,
141    pub user_facing_hint: String,
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
145pub struct ExitedReviewModeItem {
146    pub id: String,
147    pub review_output: Option<ReviewOutputEvent>,
148}
149
150#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
151pub struct PlanItem {
152    pub id: String,
153    pub text: String,
154}
155
156#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
157pub struct ReasoningItem {
158    pub id: String,
159    pub summary_text: Vec<String>,
160    #[serde(default)]
161    pub raw_content: Vec<String>,
162}
163
164#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
165#[serde(rename_all = "snake_case")]
166pub enum CommandExecutionStatus {
167    InProgress,
168    Completed,
169    Failed,
170    Declined,
171}
172
173impl From<ExecCommandStatus> for CommandExecutionStatus {
174    fn from(value: ExecCommandStatus) -> Self {
175        match value {
176            ExecCommandStatus::Completed => Self::Completed,
177            ExecCommandStatus::Failed => Self::Failed,
178            ExecCommandStatus::Declined => Self::Declined,
179        }
180    }
181}
182
183#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
184pub struct CommandExecutionItem {
185    pub id: String,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    #[ts(optional)]
188    pub process_id: Option<String>,
189    pub command: Vec<String>,
190    pub cwd: PathUri,
191    pub parsed_cmd: Vec<ParsedCommand>,
192    pub source: ExecCommandSource,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    #[ts(optional)]
195    pub interaction_input: Option<String>,
196    pub status: CommandExecutionStatus,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    #[ts(optional)]
199    pub stdout: Option<String>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    #[ts(optional)]
202    pub stderr: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    #[ts(optional)]
205    pub aggregated_output: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    #[ts(optional)]
208    pub exit_code: Option<i32>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    #[ts(type = "string", optional)]
211    pub duration: Option<Duration>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    #[ts(optional)]
214    pub formatted_output: Option<String>,
215}
216
217#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
218#[serde(rename_all = "snake_case")]
219pub enum DynamicToolCallStatus {
220    InProgress,
221    Completed,
222    Failed,
223}
224
225#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
226pub struct DynamicToolCallItem {
227    pub id: String,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    #[ts(optional)]
230    pub namespace: Option<String>,
231    pub tool: String,
232    pub arguments: serde_json::Value,
233    pub status: DynamicToolCallStatus,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    #[ts(optional)]
236    pub content_items: Option<Vec<DynamicToolCallOutputContentItem>>,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    #[ts(optional)]
239    pub success: Option<bool>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    #[ts(optional)]
242    pub error: Option<String>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    #[ts(type = "string", optional)]
245    pub duration: Option<Duration>,
246}
247
248#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
249#[serde(rename_all = "snake_case")]
250pub enum CollabAgentTool {
251    SpawnAgent,
252    SendInput,
253    ResumeAgent,
254    Wait,
255    CloseAgent,
256}
257
258#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
259#[serde(rename_all = "snake_case")]
260pub enum CollabAgentToolCallStatus {
261    InProgress,
262    Completed,
263    Failed,
264}
265
266#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
267pub struct CollabAgentToolCallItem {
268    pub id: String,
269    pub tool: CollabAgentTool,
270    pub status: CollabAgentToolCallStatus,
271    pub sender_thread_id: ThreadId,
272    #[serde(default)]
273    pub receiver_thread_ids: Vec<ThreadId>,
274    #[serde(default)]
275    pub receiver_agents: Vec<CollabAgentRef>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    #[ts(optional)]
278    pub prompt: Option<String>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    #[ts(optional)]
281    pub model: Option<String>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    #[ts(optional)]
284    pub reasoning_effort: Option<ReasoningEffortConfig>,
285    #[serde(default)]
286    pub agents_states: HashMap<ThreadId, AgentStatus>,
287}
288
289#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
290pub struct SubAgentActivityItem {
291    pub id: String,
292    pub kind: SubAgentActivityKind,
293    pub agent_thread_id: ThreadId,
294    pub agent_path: AgentPath,
295}
296
297#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
298pub struct WebSearchItem {
299    pub id: String,
300    pub query: String,
301    pub action: WebSearchAction,
302    /// Structured search results returned out-of-band by standalone web search.
303    ///
304    /// These stay as opaque JSON at the Codex transport boundary so new result
305    /// fields and result types can pass through without changing model-visible
306    /// context or requiring a Codex release.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    #[ts(optional)]
309    pub results: Option<Vec<JsonValue>>,
310}
311
312#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
313pub struct ImageViewItem {
314    pub id: String,
315    /// Path resolved within the selected execution environment.
316    ///
317    /// This core protocol type is not exposed directly in the app-server API.
318    /// App-server converts the path to `LegacyAppPathString` at its boundary.
319    pub path: PathUri,
320}
321
322#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
323pub struct ImageGenerationItem {
324    pub id: String,
325    pub status: String,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    #[ts(optional)]
328    pub revised_prompt: Option<String>,
329    pub result: String,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    #[ts(optional)]
332    pub saved_path: Option<AbsolutePathBuf>,
333}
334
335#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
336pub struct FileChangeItem {
337    pub id: String,
338    pub changes: HashMap<PathBuf, FileChange>,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    #[ts(optional)]
341    pub status: Option<PatchApplyStatus>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    #[ts(optional)]
344    pub auto_approved: Option<bool>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    #[ts(optional)]
347    pub stdout: Option<String>,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    #[ts(optional)]
350    pub stderr: Option<String>,
351}
352
353#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
354#[serde(rename_all = "camelCase")]
355#[ts(rename_all = "camelCase")]
356pub struct McpToolCallItem {
357    pub id: String,
358    pub server: String,
359    pub tool: String,
360    pub arguments: serde_json::Value,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    #[ts(optional)]
363    pub connector_id: Option<String>,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    #[ts(optional)]
366    pub mcp_app_resource_uri: Option<String>,
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    #[ts(optional)]
369    pub link_id: Option<String>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    #[ts(optional)]
372    pub app_name: Option<String>,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    #[ts(optional)]
375    pub action_name: Option<String>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    #[ts(optional)]
378    pub plugin_id: Option<String>,
379    pub status: McpToolCallStatus,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    #[ts(optional)]
382    pub result: Option<CallToolResult>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    #[ts(optional)]
385    pub error: Option<McpToolCallError>,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    #[ts(type = "string", optional)]
388    pub duration: Option<Duration>,
389}
390
391#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
392#[serde(rename_all = "camelCase")]
393#[ts(rename_all = "camelCase")]
394pub enum McpToolCallStatus {
395    InProgress,
396    Completed,
397    Failed,
398}
399
400#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
401#[serde(rename_all = "camelCase")]
402#[ts(rename_all = "camelCase")]
403pub struct McpToolCallError {
404    pub message: String,
405}
406
407#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
408pub struct ContextCompactionItem {
409    pub id: String,
410}
411
412fn new_item_id() -> String {
413    uuid::Uuid::now_v7().to_string()
414}
415
416impl ContextCompactionItem {
417    pub fn new() -> Self {
418        Self { id: new_item_id() }
419    }
420}
421
422impl Default for ContextCompactionItem {
423    fn default() -> Self {
424        Self::new()
425    }
426}
427
428impl UserMessageItem {
429    pub fn new(content: &[UserInput]) -> Self {
430        Self {
431            id: new_item_id(),
432            client_id: None,
433            content: content.to_vec(),
434        }
435    }
436
437    pub fn message(&self) -> String {
438        self.content
439            .iter()
440            .map(|c| match c {
441                UserInput::Text { text, .. } => text.clone(),
442                _ => String::new(),
443            })
444            .collect::<Vec<String>>()
445            .join("")
446    }
447
448    pub fn text_elements(&self) -> Vec<TextElement> {
449        let mut out = Vec::new();
450        let mut offset = 0usize;
451        for input in &self.content {
452            if let UserInput::Text {
453                text,
454                text_elements,
455                ..
456            } = input
457            {
458                // Text element ranges are relative to each text chunk; offset them so they align
459                // with the concatenated message returned by `message()`.
460                for elem in text_elements {
461                    let byte_range = ByteRange {
462                        start: offset + elem.byte_range.start,
463                        end: offset + elem.byte_range.end,
464                    };
465                    out.push(TextElement::new(
466                        byte_range,
467                        elem.placeholder(text).map(str::to_string),
468                    ));
469                }
470                offset += text.len();
471            }
472        }
473        out
474    }
475
476    pub fn image_urls(&self) -> Vec<String> {
477        self.content
478            .iter()
479            .filter_map(|c| match c {
480                UserInput::Image { image_url, .. } => Some(image_url.clone()),
481                _ => None,
482            })
483            .collect()
484    }
485
486    pub fn image_details(&self) -> Vec<Option<ImageDetail>> {
487        trim_trailing_default_image_details(
488            self.content
489                .iter()
490                .filter_map(|c| match c {
491                    UserInput::Image { detail, .. } => Some(*detail),
492                    _ => None,
493                })
494                .collect(),
495        )
496    }
497
498    pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {
499        self.content
500            .iter()
501            .filter_map(|c| match c {
502                UserInput::LocalImage { path, .. } => Some(path.clone()),
503                _ => None,
504            })
505            .collect()
506    }
507
508    pub fn local_image_details(&self) -> Vec<Option<ImageDetail>> {
509        trim_trailing_default_image_details(
510            self.content
511                .iter()
512                .filter_map(|c| match c {
513                    UserInput::LocalImage { detail, .. } => Some(*detail),
514                    _ => None,
515                })
516                .collect(),
517        )
518    }
519
520    pub fn audio_urls(&self) -> Vec<String> {
521        self.content
522            .iter()
523            .filter_map(|c| match c {
524                UserInput::Audio { audio_url } => Some(audio_url.clone()),
525                _ => None,
526            })
527            .collect()
528    }
529
530    pub fn local_audio_paths(&self) -> Vec<std::path::PathBuf> {
531        self.content
532            .iter()
533            .filter_map(|c| match c {
534                UserInput::LocalAudio { path } => Some(path.clone()),
535                _ => None,
536            })
537            .collect()
538    }
539}
540
541fn trim_trailing_default_image_details(
542    mut details: Vec<Option<ImageDetail>>,
543) -> Vec<Option<ImageDetail>> {
544    while matches!(details.last(), Some(None)) {
545        details.pop();
546    }
547    details
548}
549
550impl HookPromptItem {
551    pub fn from_fragments(id: Option<&str>, fragments: Vec<HookPromptFragment>) -> Self {
552        Self {
553            id: id.map(str::to_string).unwrap_or_else(new_item_id),
554            fragments,
555        }
556    }
557}
558
559impl HookPromptFragment {
560    pub fn from_single_hook(text: impl Into<String>, hook_run_id: impl Into<String>) -> Self {
561        Self {
562            text: text.into(),
563            hook_run_id: hook_run_id.into(),
564        }
565    }
566}
567
568pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option<ResponseItem> {
569    let content = fragments
570        .iter()
571        .filter(|fragment| !fragment.hook_run_id.trim().is_empty())
572        .filter_map(|fragment| {
573            serialize_hook_prompt_fragment(&fragment.text, &fragment.hook_run_id)
574                .map(|text| ContentItem::InputText { text })
575        })
576        .collect::<Vec<_>>();
577
578    if content.is_empty() {
579        return None;
580    }
581
582    Some(ResponseItem::Message {
583        id: Some(ResponseItemId::new("msg")),
584        role: "user".to_string(),
585        content,
586        phase: None,
587        internal_chat_message_metadata_passthrough: None,
588    })
589}
590
591pub fn parse_hook_prompt_message(
592    id: Option<&str>,
593    content: &[ContentItem],
594) -> Option<HookPromptItem> {
595    let fragments = content
596        .iter()
597        .map(|content_item| {
598            let ContentItem::InputText { text } = content_item else {
599                return None;
600            };
601            parse_hook_prompt_fragment(text)
602        })
603        .collect::<Option<Vec<_>>>()?;
604
605    if fragments.is_empty() {
606        return None;
607    }
608
609    Some(HookPromptItem::from_fragments(id, fragments))
610}
611
612pub fn parse_hook_prompt_fragment(text: &str) -> Option<HookPromptFragment> {
613    let trimmed = text.trim();
614    let HookPromptXml { text, hook_run_id } = from_xml_str::<HookPromptXml>(trimmed).ok()?;
615    if hook_run_id.trim().is_empty() {
616        return None;
617    }
618
619    Some(HookPromptFragment { text, hook_run_id })
620}
621
622fn serialize_hook_prompt_fragment(text: &str, hook_run_id: &str) -> Option<String> {
623    if hook_run_id.trim().is_empty() {
624        return None;
625    }
626    to_xml_string(&HookPromptXml {
627        text: text.to_string(),
628        hook_run_id: hook_run_id.to_string(),
629    })
630    .ok()
631}
632
633impl TurnItem {
634    pub fn id(&self) -> String {
635        match self {
636            TurnItem::UserMessage(item) => item.id.clone(),
637            TurnItem::HookPrompt(item) => item.id.clone(),
638            TurnItem::AgentMessage(item) => item.id.clone(),
639            TurnItem::Plan(item) => item.id.clone(),
640            TurnItem::Reasoning(item) => item.id.clone(),
641            TurnItem::CommandExecution(item) => item.id.clone(),
642            TurnItem::DynamicToolCall(item) => item.id.clone(),
643            TurnItem::CollabAgentToolCall(item) => item.id.clone(),
644            TurnItem::SubAgentActivity(item) => item.id.clone(),
645            TurnItem::WebSearch(item) => item.id.clone(),
646            TurnItem::ImageView(item) => item.id.clone(),
647            TurnItem::Extension(item) => item.id().to_string(),
648            TurnItem::ImageGeneration(item) => item.id.clone(),
649            TurnItem::EnteredReviewMode(item) => item.id.clone(),
650            TurnItem::ExitedReviewMode(item) => item.id.clone(),
651            TurnItem::FileChange(item) => item.id.clone(),
652            TurnItem::McpToolCall(item) => item.id.clone(),
653            TurnItem::ContextCompaction(item) => item.id.clone(),
654        }
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use codex_extension_items::sleep::SleepItem;
662    use pretty_assertions::assert_eq;
663    use serde_json::json;
664
665    #[test]
666    fn sleep_extension_item_preserves_type_and_kind() {
667        let item = TurnItem::Extension(ExtensionItem::Sleep(SleepItem {
668            id: "sleep-1".to_string(),
669            duration_ms: 1_000,
670        }));
671
672        assert_eq!(
673            serde_json::to_value(item).expect("serialize sleep extension item"),
674            json!({
675                "type": "Extension",
676                "kind": "clock.sleep",
677                "id": "sleep-1",
678                "durationMs": 1_000,
679            })
680        );
681    }
682
683    #[test]
684    fn user_message_item_extracts_audio_attachments() {
685        let item = UserMessageItem::new(&[
686            UserInput::Text {
687                text: "transcribe these".to_string(),
688                text_elements: Vec::new(),
689            },
690            UserInput::Audio {
691                audio_url: "https://example.com/remote.mp3".to_string(),
692            },
693            UserInput::LocalAudio {
694                path: std::path::PathBuf::from("local.wav"),
695            },
696        ]);
697
698        assert_eq!(
699            (item.audio_urls(), item.local_audio_paths()),
700            (
701                vec!["https://example.com/remote.mp3".to_string()],
702                vec![std::path::PathBuf::from("local.wav")],
703            )
704        );
705    }
706
707    #[test]
708    fn hook_prompt_roundtrips_multiple_fragments() {
709        let original = vec![
710            HookPromptFragment::from_single_hook("Retry with care & joy.", "hook-run-1"),
711            HookPromptFragment::from_single_hook("Then summarize cleanly.", "hook-run-2"),
712        ];
713        let message = build_hook_prompt_message(&original).expect("hook prompt");
714
715        let ResponseItem::Message { id, content, .. } = message else {
716            panic!("expected hook prompt message");
717        };
718        assert!(id.is_some_and(|id| id.starts_with("msg_")));
719
720        let parsed = parse_hook_prompt_message(/*id*/ None, &content).expect("parsed hook prompt");
721        assert_eq!(parsed.fragments, original);
722    }
723
724    #[test]
725    fn hook_prompt_parses_legacy_single_hook_run_id() {
726        let parsed = parse_hook_prompt_fragment(
727            r#"<hook_prompt hook_run_id="hook-run-1">Retry with tests.</hook_prompt>"#,
728        )
729        .expect("legacy hook prompt");
730
731        assert_eq!(
732            parsed,
733            HookPromptFragment {
734                text: "Retry with tests.".to_string(),
735                hook_run_id: "hook-run-1".to_string(),
736            }
737        );
738    }
739}