Skip to main content

codex_protocol/
models.rs

1use std::collections::HashMap;
2use std::io;
3use std::num::NonZeroUsize;
4use std::path::Path;
5
6use codex_utils_image::PromptImageMode;
7use codex_utils_image::data_url_from_bytes;
8use codex_utils_image::load_for_prompt_bytes;
9use serde::Deserialize;
10use serde::Deserializer;
11use serde::Serialize;
12use serde::ser::Serializer;
13use ts_rs::TS;
14
15use crate::permissions::FileSystemAccessMode;
16use crate::permissions::FileSystemPath;
17use crate::permissions::FileSystemSandboxEntry;
18use crate::permissions::FileSystemSandboxKind;
19use crate::permissions::FileSystemSandboxPolicy;
20use crate::permissions::FileSystemSpecialPath;
21use crate::permissions::NetworkSandboxPolicy;
22use crate::protocol::SandboxPolicy;
23use crate::user_input::UserInput;
24use codex_utils_absolute_path::AbsolutePathBuf;
25use codex_utils_image::ImageProcessingError;
26use schemars::JsonSchema;
27
28use crate::ResponseItemId;
29use crate::mcp::CallToolResult;
30
31/// Controls the per-command sandbox override requested by a shell-like tool call.
32#[derive(
33    Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
34)]
35#[serde(rename_all = "snake_case")]
36pub enum SandboxPermissions {
37    /// Run with the turn's configured sandbox policy unchanged.
38    #[default]
39    UseDefault,
40    /// Request to run outside the sandbox.
41    RequireEscalated,
42    /// Request to stay in the sandbox while widening permissions for this
43    /// command only.
44    WithAdditionalPermissions,
45}
46
47impl SandboxPermissions {
48    /// True if SandboxPermissions requires full unsandboxed execution (i.e. RequireEscalated)
49    pub fn requires_escalated_permissions(self) -> bool {
50        matches!(self, SandboxPermissions::RequireEscalated)
51    }
52
53    /// True if SandboxPermissions requests any explicit per-command override
54    /// beyond `UseDefault`.
55    pub fn requests_sandbox_override(self) -> bool {
56        !matches!(self, SandboxPermissions::UseDefault)
57    }
58
59    /// True if SandboxPermissions uses the sandboxed per-command permission
60    /// widening flow.
61    pub fn uses_additional_permissions(self) -> bool {
62        matches!(self, SandboxPermissions::WithAdditionalPermissions)
63    }
64}
65
66#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, JsonSchema, TS)]
67pub struct FileSystemPermissions {
68    pub entries: Vec<FileSystemSandboxEntry>,
69    pub glob_scan_max_depth: Option<NonZeroUsize>,
70}
71
72#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct LegacyReadWriteRoots {
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub read: Option<Vec<AbsolutePathBuf>>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub write: Option<Vec<AbsolutePathBuf>>,
79}
80
81impl FileSystemPermissions {
82    pub fn is_empty(&self) -> bool {
83        self.entries.is_empty()
84    }
85
86    pub fn from_read_write_roots(
87        read: Option<Vec<AbsolutePathBuf>>,
88        write: Option<Vec<AbsolutePathBuf>>,
89    ) -> Self {
90        let mut entries = Vec::new();
91        if let Some(read) = read {
92            entries.extend(read.into_iter().map(|path| {
93                FileSystemSandboxEntry::new(
94                    FileSystemPath::Path { path },
95                    FileSystemAccessMode::Read,
96                )
97            }));
98        }
99        if let Some(write) = write {
100            entries.extend(write.into_iter().map(|path| {
101                FileSystemSandboxEntry::new(
102                    FileSystemPath::Path { path },
103                    FileSystemAccessMode::Write,
104                )
105            }));
106        }
107        Self {
108            entries,
109            glob_scan_max_depth: None,
110        }
111    }
112
113    pub fn legacy_read_write_roots(&self) -> Option<LegacyReadWriteRoots> {
114        self.as_legacy_permissions()
115    }
116
117    fn as_legacy_permissions(&self) -> Option<LegacyReadWriteRoots> {
118        if self.glob_scan_max_depth.is_some() {
119            return None;
120        }
121
122        let mut read = Vec::new();
123        let mut write = Vec::new();
124
125        for entry in &self.entries {
126            let FileSystemPath::Path { path } = &entry.path else {
127                return None;
128            };
129            match entry.access {
130                FileSystemAccessMode::Read => read.push(path.clone()),
131                FileSystemAccessMode::Write => write.push(path.clone()),
132                FileSystemAccessMode::Deny => return None,
133            }
134        }
135
136        Some(LegacyReadWriteRoots {
137            read: (!read.is_empty()).then_some(read),
138            write: (!write.is_empty()).then_some(write),
139        })
140    }
141}
142
143#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145struct CanonicalFileSystemPermissions {
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    entries: Vec<FileSystemSandboxEntry>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    glob_scan_max_depth: Option<NonZeroUsize>,
150}
151
152#[derive(Debug, Clone, Deserialize)]
153#[serde(untagged)]
154enum FileSystemPermissionsDe {
155    Canonical(CanonicalFileSystemPermissions),
156    Legacy(LegacyReadWriteRoots),
157}
158
159impl Serialize for FileSystemPermissions {
160    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
161    where
162        S: Serializer,
163    {
164        if let Some(legacy) = self.as_legacy_permissions() {
165            legacy.serialize(serializer)
166        } else {
167            CanonicalFileSystemPermissions {
168                entries: self.entries.clone(),
169                glob_scan_max_depth: self.glob_scan_max_depth,
170            }
171            .serialize(serializer)
172        }
173    }
174}
175
176impl<'de> Deserialize<'de> for FileSystemPermissions {
177    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
178    where
179        D: Deserializer<'de>,
180    {
181        match FileSystemPermissionsDe::deserialize(deserializer)? {
182            FileSystemPermissionsDe::Canonical(CanonicalFileSystemPermissions {
183                entries,
184                glob_scan_max_depth,
185            }) => Ok(Self {
186                entries,
187                glob_scan_max_depth,
188            }),
189            FileSystemPermissionsDe::Legacy(LegacyReadWriteRoots { read, write }) => {
190                Ok(Self::from_read_write_roots(read, write))
191            }
192        }
193    }
194}
195
196#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
197pub struct NetworkPermissions {
198    pub enabled: Option<bool>,
199}
200
201impl NetworkPermissions {
202    pub fn is_empty(&self) -> bool {
203        self.enabled.is_none()
204    }
205}
206
207/// Partial permission overlay used for per-command requests and approved
208/// session/turn grants.
209#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
210pub struct AdditionalPermissionProfile {
211    pub network: Option<NetworkPermissions>,
212    pub file_system: Option<FileSystemPermissions>,
213}
214
215impl AdditionalPermissionProfile {
216    pub fn is_empty(&self) -> bool {
217        self.network.is_none() && self.file_system.is_none()
218    }
219}
220
221#[derive(
222    Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
223)]
224#[serde(rename_all = "snake_case")]
225pub enum SandboxEnforcement {
226    /// Codex owns sandbox construction for this profile.
227    #[default]
228    Managed,
229    /// No outer filesystem sandbox should be applied.
230    Disabled,
231    /// Filesystem isolation is enforced by an external caller.
232    External,
233}
234
235impl SandboxEnforcement {
236    pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
237        match sandbox_policy {
238            SandboxPolicy::DangerFullAccess => Self::Disabled,
239            SandboxPolicy::ExternalSandbox { .. } => Self::External,
240            SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => Self::Managed,
241        }
242    }
243}
244
245/// Filesystem permissions for profiles where Codex owns sandbox construction.
246#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
247#[serde(tag = "type", rename_all = "snake_case")]
248#[ts(tag = "type")]
249pub enum ManagedFileSystemPermissions {
250    /// Apply a managed filesystem sandbox from the listed entries.
251    #[serde(rename_all = "snake_case")]
252    #[ts(rename_all = "snake_case")]
253    Restricted {
254        entries: Vec<FileSystemSandboxEntry>,
255        #[serde(default, skip_serializing_if = "Option::is_none")]
256        #[ts(optional)]
257        glob_scan_max_depth: Option<NonZeroUsize>,
258    },
259    /// Apply a managed sandbox that allows all filesystem access.
260    Unrestricted,
261}
262
263impl ManagedFileSystemPermissions {
264    fn from_sandbox_policy(file_system_sandbox_policy: &FileSystemSandboxPolicy) -> Self {
265        match file_system_sandbox_policy.kind {
266            FileSystemSandboxKind::Restricted => Self::Restricted {
267                entries: file_system_sandbox_policy.entries.clone(),
268                glob_scan_max_depth: file_system_sandbox_policy
269                    .glob_scan_max_depth
270                    .and_then(NonZeroUsize::new),
271            },
272            FileSystemSandboxKind::Unrestricted => Self::Unrestricted,
273            FileSystemSandboxKind::ExternalSandbox => unreachable!(
274                "external filesystem policies are represented by PermissionProfile::External"
275            ),
276        }
277    }
278
279    pub fn to_sandbox_policy(&self) -> FileSystemSandboxPolicy {
280        match self {
281            Self::Restricted {
282                entries,
283                glob_scan_max_depth,
284            } => FileSystemSandboxPolicy {
285                kind: FileSystemSandboxKind::Restricted,
286                glob_scan_max_depth: glob_scan_max_depth.map(usize::from),
287                entries: entries.clone(),
288            },
289            Self::Unrestricted => FileSystemSandboxPolicy::unrestricted(),
290        }
291    }
292}
293
294/// Reserved identifier for the built-in read-only permission profile.
295pub const BUILT_IN_PERMISSION_PROFILE_READ_ONLY: &str = ":read-only";
296
297/// Reserved identifier for the built-in workspace-write permission profile.
298pub const BUILT_IN_PERMISSION_PROFILE_WORKSPACE: &str = ":workspace";
299
300/// Reserved identifier for the built-in full-access permission profile.
301pub const BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS: &str = ":danger-full-access";
302
303/// Canonical active runtime permissions for a conversation, turn, or command.
304#[derive(Debug, Clone, Eq, PartialEq, Serialize, JsonSchema, TS)]
305#[serde(tag = "type", rename_all = "snake_case")]
306#[ts(tag = "type")]
307pub enum PermissionProfile {
308    /// Codex owns sandbox construction for this profile.
309    #[serde(rename_all = "snake_case")]
310    #[ts(rename_all = "snake_case")]
311    Managed {
312        file_system: ManagedFileSystemPermissions,
313        network: NetworkSandboxPolicy,
314    },
315    /// Do not apply an outer sandbox.
316    Disabled,
317    /// Filesystem isolation is enforced by an external caller.
318    #[serde(rename_all = "snake_case")]
319    #[ts(rename_all = "snake_case")]
320    External { network: NetworkSandboxPolicy },
321}
322
323/// Metadata for the named or implicit built-in permissions profile that
324/// produced the active `PermissionProfile`.
325///
326/// The runtime must honor `PermissionProfile`; this sidecar exists so clients
327/// can display stable profile identity without trying to reverse-engineer a
328/// name from the compiled permissions.
329#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
330pub struct ActivePermissionProfile {
331    /// Profile identifier from `default_permissions` or the implicit built-in
332    /// default, such as `:workspace` or a user-defined `[permissions.<id>]`
333    /// profile.
334    pub id: String,
335
336    /// Optional parent profile identifier from the selected permissions
337    /// profile's `extends` setting.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    #[ts(optional)]
340    pub extends: Option<String>,
341}
342
343impl ActivePermissionProfile {
344    pub fn new(id: impl Into<String>) -> Self {
345        Self {
346            id: id.into(),
347            extends: None,
348        }
349    }
350
351    pub fn read_only() -> Self {
352        Self::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY)
353    }
354}
355
356impl Default for PermissionProfile {
357    fn default() -> Self {
358        Self::Managed {
359            file_system: ManagedFileSystemPermissions::Restricted {
360                entries: Vec::new(),
361                glob_scan_max_depth: None,
362            },
363            network: NetworkSandboxPolicy::Restricted,
364        }
365    }
366}
367
368impl PermissionProfile {
369    /// Managed read-only filesystem access with restricted network access.
370    pub fn read_only() -> Self {
371        let file_system = FileSystemSandboxPolicy::read_only();
372        Self::Managed {
373            file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
374            network: NetworkSandboxPolicy::Restricted,
375        }
376    }
377
378    /// Managed workspace-write filesystem access with restricted network
379    /// access.
380    ///
381    /// The returned profile contains symbolic `:workspace_roots` entries that
382    /// must be resolved against the active permission root before enforcement.
383    pub fn workspace_write() -> Self {
384        Self::workspace_write_with(
385            &[],
386            NetworkSandboxPolicy::Restricted,
387            /*exclude_tmpdir_env_var*/ false,
388            /*exclude_slash_tmp*/ false,
389        )
390    }
391
392    /// Managed workspace-write filesystem access with the legacy
393    /// `sandbox_workspace_write` knobs applied directly to the profile.
394    ///
395    /// The returned profile contains symbolic `:workspace_roots` entries that
396    /// must be resolved against the active permission root before enforcement.
397    pub fn workspace_write_with(
398        writable_roots: &[AbsolutePathBuf],
399        network: NetworkSandboxPolicy,
400        exclude_tmpdir_env_var: bool,
401        exclude_slash_tmp: bool,
402    ) -> Self {
403        let file_system = FileSystemSandboxPolicy::workspace_write(
404            writable_roots,
405            exclude_tmpdir_env_var,
406            exclude_slash_tmp,
407        );
408        Self::Managed {
409            file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
410            network,
411        }
412    }
413
414    pub fn materialize_project_roots_with_workspace_roots(
415        self,
416        workspace_roots: &[AbsolutePathBuf],
417    ) -> Self {
418        match self {
419            Self::Managed {
420                file_system,
421                network,
422            } => {
423                let file_system = file_system
424                    .to_sandbox_policy()
425                    .materialize_project_roots_with_workspace_roots(workspace_roots);
426                Self::Managed {
427                    file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
428                    network,
429                }
430            }
431            Self::Disabled => Self::Disabled,
432            Self::External { network } => Self::External { network },
433        }
434    }
435
436    pub fn from_runtime_permissions(
437        file_system_sandbox_policy: &FileSystemSandboxPolicy,
438        network_sandbox_policy: NetworkSandboxPolicy,
439    ) -> Self {
440        let enforcement = match file_system_sandbox_policy.kind {
441            FileSystemSandboxKind::Restricted | FileSystemSandboxKind::Unrestricted => {
442                SandboxEnforcement::Managed
443            }
444            FileSystemSandboxKind::ExternalSandbox => SandboxEnforcement::External,
445        };
446        Self::from_runtime_permissions_with_enforcement(
447            enforcement,
448            file_system_sandbox_policy,
449            network_sandbox_policy,
450        )
451    }
452
453    pub fn from_runtime_permissions_with_enforcement(
454        enforcement: SandboxEnforcement,
455        file_system_sandbox_policy: &FileSystemSandboxPolicy,
456        network_sandbox_policy: NetworkSandboxPolicy,
457    ) -> Self {
458        match file_system_sandbox_policy.kind {
459            FileSystemSandboxKind::ExternalSandbox => Self::External {
460                network: network_sandbox_policy,
461            },
462            FileSystemSandboxKind::Unrestricted if enforcement == SandboxEnforcement::Disabled => {
463                Self::Disabled
464            }
465            FileSystemSandboxKind::Restricted | FileSystemSandboxKind::Unrestricted => {
466                Self::Managed {
467                    file_system: ManagedFileSystemPermissions::from_sandbox_policy(
468                        file_system_sandbox_policy,
469                    ),
470                    network: network_sandbox_policy,
471                }
472            }
473        }
474    }
475
476    pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
477        Self::from_runtime_permissions_with_enforcement(
478            SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
479            &FileSystemSandboxPolicy::from(sandbox_policy),
480            NetworkSandboxPolicy::from(sandbox_policy),
481        )
482    }
483
484    pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
485        Self::from_runtime_permissions_with_enforcement(
486            SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
487            &FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd),
488            NetworkSandboxPolicy::from(sandbox_policy),
489        )
490    }
491
492    pub fn enforcement(&self) -> SandboxEnforcement {
493        match self {
494            Self::Managed { .. } => SandboxEnforcement::Managed,
495            Self::Disabled => SandboxEnforcement::Disabled,
496            Self::External { .. } => SandboxEnforcement::External,
497        }
498    }
499
500    pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy {
501        match self {
502            Self::Managed { file_system, .. } => file_system.to_sandbox_policy(),
503            Self::Disabled => FileSystemSandboxPolicy::unrestricted(),
504            Self::External { .. } => FileSystemSandboxPolicy::external_sandbox(),
505        }
506    }
507
508    pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy {
509        match self {
510            Self::Managed { network, .. } | Self::External { network } => *network,
511            Self::Disabled => NetworkSandboxPolicy::Enabled,
512        }
513    }
514
515    pub fn to_legacy_sandbox_policy(&self, cwd: &Path) -> io::Result<SandboxPolicy> {
516        match self {
517            Self::Managed {
518                file_system,
519                network,
520            } => file_system
521                .to_sandbox_policy()
522                .to_legacy_sandbox_policy(*network, cwd),
523            Self::Disabled => Ok(SandboxPolicy::DangerFullAccess),
524            Self::External { network } => Ok(SandboxPolicy::ExternalSandbox {
525                network_access: if network.is_enabled() {
526                    crate::protocol::NetworkAccess::Enabled
527                } else {
528                    crate::protocol::NetworkAccess::Restricted
529                },
530            }),
531        }
532    }
533
534    pub fn to_runtime_permissions(&self) -> (FileSystemSandboxPolicy, NetworkSandboxPolicy) {
535        (
536            self.file_system_sandbox_policy(),
537            self.network_sandbox_policy(),
538        )
539    }
540}
541
542#[derive(Debug, Clone, Deserialize)]
543#[serde(tag = "type", rename_all = "snake_case")]
544enum TaggedPermissionProfile {
545    #[serde(rename_all = "snake_case")]
546    Managed {
547        file_system: ManagedFileSystemPermissions,
548        network: NetworkSandboxPolicy,
549    },
550    Disabled,
551    #[serde(rename_all = "snake_case")]
552    External {
553        network: NetworkSandboxPolicy,
554    },
555}
556
557impl From<TaggedPermissionProfile> for PermissionProfile {
558    fn from(value: TaggedPermissionProfile) -> Self {
559        match value {
560            TaggedPermissionProfile::Managed {
561                file_system,
562                network,
563            } => Self::Managed {
564                file_system,
565                network,
566            },
567            TaggedPermissionProfile::Disabled => Self::Disabled,
568            TaggedPermissionProfile::External { network } => Self::External { network },
569        }
570    }
571}
572
573/// Pre-tagged shape written to rollout files before `PermissionProfile`
574/// represented enforcement explicitly.
575#[derive(Debug, Clone, Default, Deserialize)]
576#[serde(deny_unknown_fields)]
577struct LegacyPermissionProfile {
578    network: Option<NetworkPermissions>,
579    file_system: Option<FileSystemPermissions>,
580}
581
582impl From<LegacyPermissionProfile> for PermissionProfile {
583    fn from(value: LegacyPermissionProfile) -> Self {
584        let file_system = value.file_system.map_or_else(
585            || ManagedFileSystemPermissions::Restricted {
586                entries: Vec::new(),
587                glob_scan_max_depth: None,
588            },
589            |permissions| ManagedFileSystemPermissions::Restricted {
590                entries: permissions.entries,
591                glob_scan_max_depth: permissions.glob_scan_max_depth,
592            },
593        );
594        let network_sandbox_policy = if value
595            .network
596            .as_ref()
597            .and_then(|network| network.enabled)
598            .unwrap_or(false)
599        {
600            NetworkSandboxPolicy::Enabled
601        } else {
602            NetworkSandboxPolicy::Restricted
603        };
604        Self::Managed {
605            file_system,
606            network: network_sandbox_policy,
607        }
608    }
609}
610
611#[derive(Debug, Clone, Deserialize)]
612#[serde(untagged)]
613enum PermissionProfileDe {
614    Tagged(TaggedPermissionProfile),
615    Legacy(LegacyPermissionProfile),
616}
617
618impl<'de> Deserialize<'de> for PermissionProfile {
619    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
620    where
621        D: Deserializer<'de>,
622    {
623        Ok(match PermissionProfileDe::deserialize(deserializer)? {
624            PermissionProfileDe::Tagged(tagged) => tagged.into(),
625            PermissionProfileDe::Legacy(legacy) => legacy.into(),
626        })
627    }
628}
629
630impl From<NetworkSandboxPolicy> for NetworkPermissions {
631    fn from(value: NetworkSandboxPolicy) -> Self {
632        Self {
633            enabled: Some(value.is_enabled()),
634        }
635    }
636}
637
638impl From<&FileSystemSandboxPolicy> for FileSystemPermissions {
639    fn from(value: &FileSystemSandboxPolicy) -> Self {
640        let entries = match value.kind {
641            FileSystemSandboxKind::Restricted => value.entries.clone(),
642            FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
643                vec![FileSystemSandboxEntry::new(
644                    FileSystemPath::Special {
645                        value: FileSystemSpecialPath::Root,
646                    },
647                    FileSystemAccessMode::Write,
648                )]
649            }
650        };
651        Self {
652            entries,
653            glob_scan_max_depth: value.glob_scan_max_depth.and_then(NonZeroUsize::new),
654        }
655    }
656}
657
658impl From<&FileSystemPermissions> for FileSystemSandboxPolicy {
659    fn from(value: &FileSystemPermissions) -> Self {
660        let mut policy = FileSystemSandboxPolicy::restricted(value.entries.clone());
661        policy.glob_scan_max_depth = value.glob_scan_max_depth.map(usize::from);
662        policy
663    }
664}
665
666#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
667#[serde(tag = "type", rename_all = "snake_case")]
668pub enum ResponseInputItem {
669    Message {
670        role: String,
671        content: Vec<ContentItem>,
672        #[serde(default, skip_serializing_if = "Option::is_none")]
673        #[ts(optional)]
674        phase: Option<MessagePhase>,
675    },
676    FunctionCallOutput {
677        call_id: String,
678        #[ts(as = "FunctionCallOutputBody")]
679        #[schemars(with = "FunctionCallOutputBody")]
680        output: FunctionCallOutputPayload,
681    },
682    McpToolCallOutput {
683        call_id: String,
684        output: CallToolResult,
685    },
686    CustomToolCallOutput {
687        call_id: String,
688        #[serde(default, skip_serializing_if = "Option::is_none")]
689        #[ts(optional)]
690        name: Option<String>,
691        #[ts(as = "FunctionCallOutputBody")]
692        #[schemars(with = "FunctionCallOutputBody")]
693        output: FunctionCallOutputPayload,
694    },
695    ToolSearchOutput {
696        call_id: String,
697        status: String,
698        execution: String,
699        #[ts(type = "unknown[]")]
700        tools: Vec<serde_json::Value>,
701    },
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
705#[serde(tag = "type", rename_all = "snake_case")]
706pub enum ContentItem {
707    InputText {
708        text: String,
709    },
710    InputImage {
711        image_url: String,
712        #[serde(default, skip_serializing_if = "Option::is_none")]
713        #[ts(optional)]
714        detail: Option<ImageDetail>,
715    },
716    InputAudio {
717        audio_url: String,
718    },
719    OutputText {
720        text: String,
721    },
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
725#[serde(tag = "type", rename_all = "snake_case")]
726pub enum AgentMessageInputContent {
727    InputText { text: String },
728    EncryptedContent { encrypted_content: String },
729}
730
731/// Returns the locally readable text when an agent message is entirely plaintext.
732pub fn plaintext_agent_message_content(content: &[AgentMessageInputContent]) -> Option<String> {
733    let mut text_parts = Vec::with_capacity(content.len());
734    for part in content {
735        match part {
736            AgentMessageInputContent::InputText { text } => text_parts.push(text.as_str()),
737            AgentMessageInputContent::EncryptedContent { .. } => return None,
738        }
739    }
740
741    let text = text_parts.join("\n");
742    (!text.trim().is_empty()).then_some(text)
743}
744
745#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
746#[serde(rename_all = "lowercase")]
747pub enum ImageDetail {
748    Auto,
749    Low,
750    High,
751    Original,
752}
753
754pub const DEFAULT_IMAGE_DETAIL: ImageDetail = ImageDetail::High;
755
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
757#[serde(rename_all = "snake_case")]
758/// Classifies an assistant message as interim commentary or final answer text.
759///
760/// Providers do not emit this consistently, so callers must treat `None` as
761/// "phase unknown" and keep compatibility behavior for legacy models.
762pub enum MessagePhase {
763    /// Mid-turn assistant text (for example preamble/progress narration).
764    ///
765    /// Additional tool calls or assistant output may follow before turn
766    /// completion.
767    Commentary,
768    /// The assistant's terminal answer text for the current turn.
769    FinalAnswer,
770}
771
772/// Internal Responses API passthrough metadata copied into underlying chat messages.
773///
774/// Responses API strongly types this payload. Do not modify it without first getting API
775/// approval and making the corresponding Responses API change.
776#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
777pub struct InternalChatMessageMetadataPassthrough {
778    #[serde(default, skip_serializing_if = "Option::is_none")]
779    #[ts(optional)]
780    pub turn_id: Option<String>,
781}
782
783impl InternalChatMessageMetadataPassthrough {
784    pub(crate) fn set_turn_id_if_missing(metadata: &mut Option<Self>, turn_id: &str) {
785        if turn_id.is_empty()
786            || metadata
787                .as_ref()
788                .and_then(|metadata| metadata.turn_id.as_deref())
789                .is_some_and(|turn_id| !turn_id.is_empty())
790        {
791            return;
792        }
793        metadata.get_or_insert_with(Self::default).turn_id = Some(turn_id.to_string());
794    }
795}
796
797#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
798#[serde(tag = "type", rename_all = "snake_case")]
799pub enum ResponseItem {
800    #[schemars(skip)]
801    #[ts(skip)]
802    AdditionalTools {
803        #[serde(default, skip_serializing_if = "Option::is_none")]
804        id: Option<ResponseItemId>,
805        role: String,
806        tools: Vec<serde_json::Value>,
807    },
808    Message {
809        #[serde(default, skip_serializing_if = "Option::is_none")]
810        #[ts(optional)]
811        id: Option<ResponseItemId>,
812        role: String,
813        content: Vec<ContentItem>,
814        // Optional output-message phase (for example: "commentary", "final_answer").
815        // Availability varies by provider/model, so downstream consumers must
816        // preserve fallback behavior when this is absent.
817        #[serde(default, skip_serializing_if = "Option::is_none")]
818        #[ts(optional)]
819        phase: Option<MessagePhase>,
820        #[serde(default, skip_serializing_if = "Option::is_none")]
821        #[ts(optional)]
822        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
823    },
824    AgentMessage {
825        #[serde(default, skip_serializing_if = "Option::is_none")]
826        #[ts(optional)]
827        id: Option<ResponseItemId>,
828        author: String,
829        recipient: String,
830        content: Vec<AgentMessageInputContent>,
831        #[serde(default, skip_serializing_if = "Option::is_none")]
832        #[ts(optional)]
833        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
834    },
835    Reasoning {
836        #[serde(default, skip_serializing_if = "Option::is_none")]
837        #[ts(optional)]
838        id: Option<ResponseItemId>,
839        summary: Vec<ReasoningItemReasoningSummary>,
840        #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
841        #[ts(optional)]
842        content: Option<Vec<ReasoningItemContent>>,
843        encrypted_content: Option<String>,
844        #[serde(default, skip_serializing_if = "Option::is_none")]
845        #[ts(optional)]
846        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
847    },
848    LocalShellCall {
849        /// Legacy id field retained for compatibility with older payloads.
850        #[serde(default, skip_serializing_if = "Option::is_none")]
851        #[ts(optional)]
852        id: Option<ResponseItemId>,
853        /// Set when using the Responses API.
854        call_id: Option<String>,
855        status: LocalShellStatus,
856        action: LocalShellAction,
857        #[serde(default, skip_serializing_if = "Option::is_none")]
858        #[ts(optional)]
859        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
860    },
861    FunctionCall {
862        #[serde(default, skip_serializing_if = "Option::is_none")]
863        #[ts(optional)]
864        id: Option<ResponseItemId>,
865        name: String,
866        #[serde(default, skip_serializing_if = "Option::is_none")]
867        #[ts(optional)]
868        namespace: Option<String>,
869        // The Responses API returns the function call arguments as a *string* that contains
870        // JSON, not as an already‑parsed object. We keep it as a raw string here and let
871        // Session::handle_function_call parse it into a Value.
872        arguments: String,
873        call_id: String,
874        #[serde(default, skip_serializing_if = "Option::is_none")]
875        #[ts(optional)]
876        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
877    },
878    ToolSearchCall {
879        #[serde(default, skip_serializing_if = "Option::is_none")]
880        #[ts(optional)]
881        id: Option<ResponseItemId>,
882        call_id: Option<String>,
883        #[serde(default, skip_serializing_if = "Option::is_none")]
884        #[ts(optional)]
885        status: Option<String>,
886        execution: String,
887        #[ts(type = "unknown")]
888        arguments: serde_json::Value,
889        #[serde(default, skip_serializing_if = "Option::is_none")]
890        #[ts(optional)]
891        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
892    },
893    // NOTE: The `output` field for `function_call_output` uses a dedicated payload type with
894    // custom serialization. On the wire it is either:
895    //   - a plain string (`content`)
896    //   - an array of structured content items (`content_items`)
897    // We keep this behavior centralized in `FunctionCallOutputPayload`.
898    FunctionCallOutput {
899        #[serde(default, skip_serializing_if = "Option::is_none")]
900        #[ts(optional)]
901        id: Option<ResponseItemId>,
902        call_id: String,
903        #[ts(as = "FunctionCallOutputBody")]
904        #[schemars(with = "FunctionCallOutputBody")]
905        output: FunctionCallOutputPayload,
906        #[serde(default, skip_serializing_if = "Option::is_none")]
907        #[ts(optional)]
908        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
909    },
910    CustomToolCall {
911        #[serde(default, skip_serializing_if = "Option::is_none")]
912        #[ts(optional)]
913        id: Option<ResponseItemId>,
914        #[serde(default, skip_serializing_if = "Option::is_none")]
915        #[ts(optional)]
916        status: Option<String>,
917
918        call_id: String,
919        name: String,
920        #[serde(default, skip_serializing_if = "Option::is_none")]
921        #[ts(optional)]
922        namespace: Option<String>,
923        input: String,
924        #[serde(default, skip_serializing_if = "Option::is_none")]
925        #[ts(optional)]
926        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
927    },
928    // `custom_tool_call_output.output` uses the same wire encoding as
929    // `function_call_output.output` so freeform tools can return either plain
930    // text or structured content items.
931    CustomToolCallOutput {
932        #[serde(default, skip_serializing_if = "Option::is_none")]
933        #[ts(optional)]
934        id: Option<ResponseItemId>,
935        call_id: String,
936        #[serde(default, skip_serializing_if = "Option::is_none")]
937        #[ts(optional)]
938        name: Option<String>,
939        #[ts(as = "FunctionCallOutputBody")]
940        #[schemars(with = "FunctionCallOutputBody")]
941        output: FunctionCallOutputPayload,
942        #[serde(default, skip_serializing_if = "Option::is_none")]
943        #[ts(optional)]
944        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
945    },
946    ToolSearchOutput {
947        #[serde(default, skip_serializing_if = "Option::is_none")]
948        #[ts(optional)]
949        id: Option<ResponseItemId>,
950        call_id: Option<String>,
951        status: String,
952        execution: String,
953        #[ts(type = "unknown[]")]
954        tools: Vec<serde_json::Value>,
955        #[serde(default, skip_serializing_if = "Option::is_none")]
956        #[ts(optional)]
957        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
958    },
959    // Emitted by the Responses API when the agent triggers a web search.
960    // Example payload (from SSE `response.output_item.done`):
961    // {
962    //   "id":"ws_...",
963    //   "type":"web_search_call",
964    //   "status":"completed",
965    //   "action": {"type":"search","query":"weather: San Francisco, CA"}
966    // }
967    WebSearchCall {
968        #[serde(default, skip_serializing_if = "Option::is_none")]
969        #[ts(optional)]
970        id: Option<ResponseItemId>,
971        #[serde(default, skip_serializing_if = "Option::is_none")]
972        #[ts(optional)]
973        status: Option<String>,
974        #[serde(default, skip_serializing_if = "Option::is_none")]
975        #[ts(optional)]
976        action: Option<WebSearchAction>,
977        #[serde(default, skip_serializing_if = "Option::is_none")]
978        #[ts(optional)]
979        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
980    },
981    // Emitted by the Responses API when the agent triggers image generation.
982    // Example payload:
983    // {
984    //   "id":"ig_123",
985    //   "type":"image_generation_call",
986    //   "status":"completed",
987    //   "revised_prompt":"A gray tabby cat hugging an otter...",
988    //   "result":"..."
989    // }
990    ImageGenerationCall {
991        #[serde(default, skip_serializing_if = "Option::is_none")]
992        #[ts(optional)]
993        id: Option<ResponseItemId>,
994        status: String,
995        #[serde(default, skip_serializing_if = "Option::is_none")]
996        #[ts(optional)]
997        revised_prompt: Option<String>,
998        result: String,
999        #[serde(default, skip_serializing_if = "Option::is_none")]
1000        #[ts(optional)]
1001        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
1002    },
1003    #[serde(alias = "compaction_summary")]
1004    Compaction {
1005        #[serde(default, skip_serializing_if = "Option::is_none")]
1006        #[ts(optional)]
1007        id: Option<ResponseItemId>,
1008        encrypted_content: String,
1009        #[serde(default, skip_serializing_if = "Option::is_none")]
1010        #[ts(optional)]
1011        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
1012    },
1013    // Compaction triggers are request controls, not durable response items.
1014    CompactionTrigger {},
1015    ContextCompaction {
1016        #[serde(default, skip_serializing_if = "Option::is_none")]
1017        #[ts(optional)]
1018        id: Option<ResponseItemId>,
1019        #[serde(default, skip_serializing_if = "Option::is_none")]
1020        #[ts(optional)]
1021        encrypted_content: Option<String>,
1022        #[serde(default, skip_serializing_if = "Option::is_none")]
1023        #[ts(optional)]
1024        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
1025    },
1026    #[serde(other)]
1027    Other,
1028}
1029
1030impl ResponseItem {
1031    /// Returns whether this item is an ordinary user-role message.
1032    pub fn is_user_message(&self) -> bool {
1033        matches!(self, Self::Message { role, .. } if role == "user")
1034    }
1035
1036    /// Returns the Responses API item ID, if present.
1037    pub fn id(&self) -> Option<&ResponseItemId> {
1038        match self {
1039            Self::AdditionalTools { id, .. }
1040            | Self::Message { id, .. }
1041            | Self::AgentMessage { id, .. }
1042            | Self::LocalShellCall { id, .. }
1043            | Self::FunctionCall { id, .. }
1044            | Self::ToolSearchCall { id, .. }
1045            | Self::FunctionCallOutput { id, .. }
1046            | Self::CustomToolCall { id, .. }
1047            | Self::CustomToolCallOutput { id, .. }
1048            | Self::ToolSearchOutput { id, .. }
1049            | Self::WebSearchCall { id, .. }
1050            | Self::Reasoning { id, .. }
1051            | Self::ImageGenerationCall { id, .. }
1052            | Self::Compaction { id, .. }
1053            | Self::ContextCompaction { id, .. } => id.as_ref(),
1054            Self::CompactionTrigger { .. } | Self::Other => None,
1055        }
1056    }
1057
1058    /// Sets or clears the Responses API item ID for variants that carry one.
1059    pub fn set_id(&mut self, new_id: Option<ResponseItemId>) {
1060        match self {
1061            Self::AdditionalTools { id, .. }
1062            | Self::Message { id, .. }
1063            | Self::AgentMessage { id, .. }
1064            | Self::LocalShellCall { id, .. }
1065            | Self::FunctionCall { id, .. }
1066            | Self::ToolSearchCall { id, .. }
1067            | Self::FunctionCallOutput { id, .. }
1068            | Self::CustomToolCall { id, .. }
1069            | Self::CustomToolCallOutput { id, .. }
1070            | Self::ToolSearchOutput { id, .. }
1071            | Self::WebSearchCall { id, .. }
1072            | Self::Reasoning { id, .. }
1073            | Self::ImageGenerationCall { id, .. }
1074            | Self::Compaction { id, .. }
1075            | Self::ContextCompaction { id, .. } => *id = new_id,
1076            Self::CompactionTrigger { .. } | Self::Other => {}
1077        }
1078    }
1079
1080    /// Returns the Responses API item ID prefix for variants that carry an ID.
1081    pub fn id_prefix(&self) -> Option<&'static str> {
1082        match self {
1083            Self::AdditionalTools { .. } => Some("at"),
1084            Self::Message { .. } => Some("msg"),
1085            Self::AgentMessage { .. } => Some("amsg"),
1086            Self::Reasoning { .. } => Some("rs"),
1087            Self::LocalShellCall { .. } => Some("lsh"),
1088            Self::FunctionCall { .. } => Some("fc"),
1089            Self::ToolSearchCall { .. } => Some("tsc"),
1090            Self::FunctionCallOutput { .. } => Some("fco"),
1091            Self::CustomToolCall { .. } => Some("ctc"),
1092            Self::CustomToolCallOutput { .. } => Some("ctco"),
1093            Self::ToolSearchOutput { .. } => Some("tso"),
1094            Self::WebSearchCall { .. } => Some("ws"),
1095            Self::ImageGenerationCall { .. } => Some("ig"),
1096            Self::Compaction { .. } | Self::ContextCompaction { .. } => Some("cmp"),
1097            Self::CompactionTrigger { .. } | Self::Other => None,
1098        }
1099    }
1100
1101    /// Returns the non-empty turn ID stamped onto this item, if present.
1102    pub fn turn_id(&self) -> Option<&str> {
1103        self.internal_chat_message_metadata_passthrough()
1104            .and_then(|metadata| metadata.turn_id.as_deref())
1105            .filter(|turn_id| !turn_id.is_empty())
1106    }
1107
1108    /// Stamps the item with `turn_id` unless it already has a non-empty turn ID.
1109    pub fn set_turn_id_if_missing(&mut self, turn_id: &str) {
1110        let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else {
1111            return;
1112        };
1113        InternalChatMessageMetadataPassthrough::set_turn_id_if_missing(metadata, turn_id);
1114    }
1115
1116    /// Removes internal chat message metadata passthrough before sending to a provider that does
1117    /// not accept it.
1118    pub fn clear_internal_chat_message_metadata_passthrough(&mut self) {
1119        if let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() {
1120            *metadata = None;
1121        }
1122    }
1123
1124    fn internal_chat_message_metadata_passthrough(
1125        &self,
1126    ) -> Option<&InternalChatMessageMetadataPassthrough> {
1127        match self {
1128            Self::Message {
1129                internal_chat_message_metadata_passthrough: metadata,
1130                ..
1131            }
1132            | Self::AgentMessage {
1133                internal_chat_message_metadata_passthrough: metadata,
1134                ..
1135            }
1136            | Self::Reasoning {
1137                internal_chat_message_metadata_passthrough: metadata,
1138                ..
1139            }
1140            | Self::LocalShellCall {
1141                internal_chat_message_metadata_passthrough: metadata,
1142                ..
1143            }
1144            | Self::FunctionCall {
1145                internal_chat_message_metadata_passthrough: metadata,
1146                ..
1147            }
1148            | Self::ToolSearchCall {
1149                internal_chat_message_metadata_passthrough: metadata,
1150                ..
1151            }
1152            | Self::FunctionCallOutput {
1153                internal_chat_message_metadata_passthrough: metadata,
1154                ..
1155            }
1156            | Self::CustomToolCall {
1157                internal_chat_message_metadata_passthrough: metadata,
1158                ..
1159            }
1160            | Self::CustomToolCallOutput {
1161                internal_chat_message_metadata_passthrough: metadata,
1162                ..
1163            }
1164            | Self::ToolSearchOutput {
1165                internal_chat_message_metadata_passthrough: metadata,
1166                ..
1167            }
1168            | Self::WebSearchCall {
1169                internal_chat_message_metadata_passthrough: metadata,
1170                ..
1171            }
1172            | Self::ImageGenerationCall {
1173                internal_chat_message_metadata_passthrough: metadata,
1174                ..
1175            }
1176            | Self::Compaction {
1177                internal_chat_message_metadata_passthrough: metadata,
1178                ..
1179            }
1180            | Self::ContextCompaction {
1181                internal_chat_message_metadata_passthrough: metadata,
1182                ..
1183            } => metadata.as_ref(),
1184            Self::CompactionTrigger { .. } | Self::AdditionalTools { .. } | Self::Other => None,
1185        }
1186    }
1187
1188    fn internal_chat_message_metadata_passthrough_mut(
1189        &mut self,
1190    ) -> Option<&mut Option<InternalChatMessageMetadataPassthrough>> {
1191        match self {
1192            Self::Message {
1193                internal_chat_message_metadata_passthrough: metadata,
1194                ..
1195            }
1196            | Self::AgentMessage {
1197                internal_chat_message_metadata_passthrough: metadata,
1198                ..
1199            }
1200            | Self::Reasoning {
1201                internal_chat_message_metadata_passthrough: metadata,
1202                ..
1203            }
1204            | Self::LocalShellCall {
1205                internal_chat_message_metadata_passthrough: metadata,
1206                ..
1207            }
1208            | Self::FunctionCall {
1209                internal_chat_message_metadata_passthrough: metadata,
1210                ..
1211            }
1212            | Self::ToolSearchCall {
1213                internal_chat_message_metadata_passthrough: metadata,
1214                ..
1215            }
1216            | Self::FunctionCallOutput {
1217                internal_chat_message_metadata_passthrough: metadata,
1218                ..
1219            }
1220            | Self::CustomToolCall {
1221                internal_chat_message_metadata_passthrough: metadata,
1222                ..
1223            }
1224            | Self::CustomToolCallOutput {
1225                internal_chat_message_metadata_passthrough: metadata,
1226                ..
1227            }
1228            | Self::ToolSearchOutput {
1229                internal_chat_message_metadata_passthrough: metadata,
1230                ..
1231            }
1232            | Self::WebSearchCall {
1233                internal_chat_message_metadata_passthrough: metadata,
1234                ..
1235            }
1236            | Self::ImageGenerationCall {
1237                internal_chat_message_metadata_passthrough: metadata,
1238                ..
1239            }
1240            | Self::Compaction {
1241                internal_chat_message_metadata_passthrough: metadata,
1242                ..
1243            }
1244            | Self::ContextCompaction {
1245                internal_chat_message_metadata_passthrough: metadata,
1246                ..
1247            } => Some(metadata),
1248            Self::CompactionTrigger { .. } | Self::AdditionalTools { .. } | Self::Other => None,
1249        }
1250    }
1251}
1252
1253pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md");
1254
1255/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI.
1256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1257#[serde(rename = "base_instructions", rename_all = "snake_case")]
1258pub struct BaseInstructions {
1259    pub text: String,
1260}
1261
1262impl Default for BaseInstructions {
1263    fn default() -> Self {
1264        Self {
1265            text: BASE_INSTRUCTIONS_DEFAULT.to_string(),
1266        }
1267    }
1268}
1269
1270const MAX_RENDERED_PREFIXES: usize = 100;
1271const MAX_ALLOW_PREFIX_TEXT_BYTES: usize = 5000;
1272const TRUNCATED_MARKER: &str = "...\n[Some commands were truncated]";
1273
1274pub fn format_allow_prefixes(prefixes: Vec<Vec<String>>) -> Option<String> {
1275    let mut truncated = false;
1276    if prefixes.len() > MAX_RENDERED_PREFIXES {
1277        truncated = true;
1278    }
1279
1280    let mut prefixes = prefixes;
1281    prefixes.sort_by(|a, b| {
1282        a.len()
1283            .cmp(&b.len())
1284            .then_with(|| prefix_combined_str_len(a).cmp(&prefix_combined_str_len(b)))
1285            .then_with(|| a.cmp(b))
1286    });
1287
1288    let full_text = prefixes
1289        .into_iter()
1290        .take(MAX_RENDERED_PREFIXES)
1291        .map(|prefix| format!("- {}", render_command_prefix(&prefix)))
1292        .collect::<Vec<_>>()
1293        .join("\n");
1294
1295    // truncate to last UTF8 char
1296    let mut output = full_text;
1297    let byte_idx = output
1298        .char_indices()
1299        .nth(MAX_ALLOW_PREFIX_TEXT_BYTES)
1300        .map(|(i, _)| i);
1301    if let Some(byte_idx) = byte_idx {
1302        truncated = true;
1303        output = output[..byte_idx].to_string();
1304    }
1305
1306    if truncated {
1307        Some(format!("{output}{TRUNCATED_MARKER}"))
1308    } else {
1309        Some(output)
1310    }
1311}
1312
1313fn prefix_combined_str_len(prefix: &[String]) -> usize {
1314    prefix.iter().map(String::len).sum()
1315}
1316
1317fn render_command_prefix(prefix: &[String]) -> String {
1318    let tokens = prefix
1319        .iter()
1320        .map(|token| serde_json::to_string(token).unwrap_or_else(|_| format!("{token:?}")))
1321        .collect::<Vec<_>>()
1322        .join(", ");
1323    format!("[{tokens}]")
1324}
1325
1326fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>>) -> bool {
1327    match content {
1328        Some(content) => !content
1329            .iter()
1330            .any(|c| matches!(c, ReasoningItemContent::ReasoningText { .. })),
1331        None => false,
1332    }
1333}
1334
1335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1336enum LocalMediaKind {
1337    Audio,
1338    Image,
1339}
1340
1341impl LocalMediaKind {
1342    fn name(self) -> &'static str {
1343        match self {
1344            Self::Audio => "audio",
1345            Self::Image => "image",
1346        }
1347    }
1348}
1349
1350fn local_media_error_placeholder(
1351    path: &std::path::Path,
1352    error: impl std::fmt::Display,
1353    media_kind: LocalMediaKind,
1354) -> ContentItem {
1355    let media_name = media_kind.name();
1356    let path = path.display();
1357    ContentItem::InputText {
1358        text: format!("Codex could not read the local {media_name} at `{path}`: {error}"),
1359    }
1360}
1361
1362pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1363
1364const IMAGE_OPEN_TAG: &str = "<image>";
1365const IMAGE_CLOSE_TAG: &str = "</image>";
1366const LOCAL_IMAGE_OPEN_TAG_PREFIX: &str = "<image name=";
1367const LOCAL_IMAGE_OPEN_TAG_SUFFIX: &str = ">";
1368const LOCAL_IMAGE_CLOSE_TAG: &str = IMAGE_CLOSE_TAG;
1369const AUDIO_OPEN_TAG: &str = "<audio>";
1370const AUDIO_CLOSE_TAG: &str = "</audio>";
1371const LOCAL_AUDIO_OPEN_TAG_PREFIX: &str = "<audio name=";
1372const LOCAL_AUDIO_OPEN_TAG_SUFFIX: &str = ">";
1373const LOCAL_AUDIO_CLOSE_TAG: &str = AUDIO_CLOSE_TAG;
1374
1375pub fn image_open_tag_text() -> String {
1376    IMAGE_OPEN_TAG.to_string()
1377}
1378
1379pub fn image_close_tag_text() -> String {
1380    IMAGE_CLOSE_TAG.to_string()
1381}
1382
1383pub fn local_image_label_text(label_number: usize) -> String {
1384    format!("[Image #{label_number}]")
1385}
1386
1387pub fn local_image_open_tag_text_with_path(label_number: usize, path: &std::path::Path) -> String {
1388    let label = local_image_label_text(label_number);
1389    let path = path.display();
1390    format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label} path=\"{path}\"{LOCAL_IMAGE_OPEN_TAG_SUFFIX}")
1391}
1392
1393pub fn is_local_image_open_tag_text(text: &str) -> bool {
1394    text.strip_prefix(LOCAL_IMAGE_OPEN_TAG_PREFIX)
1395        .is_some_and(|rest| rest.ends_with(LOCAL_IMAGE_OPEN_TAG_SUFFIX))
1396}
1397
1398pub fn is_local_image_close_tag_text(text: &str) -> bool {
1399    is_image_close_tag_text(text)
1400}
1401
1402pub fn is_image_open_tag_text(text: &str) -> bool {
1403    text == IMAGE_OPEN_TAG
1404}
1405
1406pub fn is_image_close_tag_text(text: &str) -> bool {
1407    text == IMAGE_CLOSE_TAG
1408}
1409
1410pub fn audio_open_tag_text() -> String {
1411    AUDIO_OPEN_TAG.to_string()
1412}
1413
1414pub fn audio_close_tag_text() -> String {
1415    AUDIO_CLOSE_TAG.to_string()
1416}
1417
1418pub fn local_audio_label_text(label_number: usize) -> String {
1419    format!("[Audio #{label_number}]")
1420}
1421
1422pub fn local_audio_open_tag_text_with_path(label_number: usize, path: &std::path::Path) -> String {
1423    let label = local_audio_label_text(label_number);
1424    let path = path.display();
1425    format!("{LOCAL_AUDIO_OPEN_TAG_PREFIX}{label} path=\"{path}\"{LOCAL_AUDIO_OPEN_TAG_SUFFIX}")
1426}
1427
1428pub fn is_local_audio_open_tag_text(text: &str) -> bool {
1429    text.strip_prefix(LOCAL_AUDIO_OPEN_TAG_PREFIX)
1430        .is_some_and(|rest| rest.ends_with(LOCAL_AUDIO_OPEN_TAG_SUFFIX))
1431}
1432
1433pub fn is_local_audio_close_tag_text(text: &str) -> bool {
1434    is_audio_close_tag_text(text)
1435}
1436
1437pub fn is_audio_open_tag_text(text: &str) -> bool {
1438    text == AUDIO_OPEN_TAG
1439}
1440
1441pub fn is_audio_close_tag_text(text: &str) -> bool {
1442    text == AUDIO_CLOSE_TAG
1443}
1444
1445fn invalid_image_error_placeholder(
1446    path: &std::path::Path,
1447    error: impl std::fmt::Display,
1448) -> ContentItem {
1449    ContentItem::InputText {
1450        text: format!(
1451            "Image located at `{}` is invalid: {}",
1452            path.display(),
1453            error
1454        ),
1455    }
1456}
1457
1458fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem {
1459    ContentItem::InputText {
1460        text: format!(
1461            "Codex cannot attach image at `{}`: unsupported image `{}`.",
1462            path.display(),
1463            mime
1464        ),
1465    }
1466}
1467
1468pub fn local_image_content_items_with_label_number(
1469    path: &std::path::Path,
1470    file_bytes: Vec<u8>,
1471    label_number: Option<usize>,
1472    detail: ImageDetail,
1473) -> Vec<ContentItem> {
1474    let mode = match detail {
1475        ImageDetail::Original => PromptImageMode::Original,
1476        ImageDetail::Auto | ImageDetail::Low | ImageDetail::High => PromptImageMode::ResizeToFit,
1477    };
1478
1479    match load_for_prompt_bytes(path, file_bytes, mode) {
1480        Ok(image) => local_image_content_items(path, image.into_data_url(), label_number, detail),
1481        Err(err) => match &err {
1482            ImageProcessingError::Read { .. }
1483            | ImageProcessingError::Encode { .. }
1484            | ImageProcessingError::InvalidDataUrl { .. }
1485            | ImageProcessingError::ImageTooLarge { .. } => {
1486                vec![local_media_error_placeholder(
1487                    path,
1488                    &err,
1489                    LocalMediaKind::Image,
1490                )]
1491            }
1492            ImageProcessingError::Decode { .. } if err.is_invalid_image() => {
1493                vec![invalid_image_error_placeholder(path, &err)]
1494            }
1495            ImageProcessingError::Decode { .. } => {
1496                vec![local_media_error_placeholder(
1497                    path,
1498                    &err,
1499                    LocalMediaKind::Image,
1500                )]
1501            }
1502            ImageProcessingError::UnsupportedImageFormat { mime } => {
1503                vec![unsupported_image_error_placeholder(path, mime)]
1504            }
1505        },
1506    }
1507}
1508
1509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1510pub enum LocalImagePreparation {
1511    Process,
1512    Defer,
1513}
1514
1515fn audio_mime_for_path(path: &std::path::Path) -> Option<&'static str> {
1516    let extension = path.extension()?.to_str()?;
1517    if extension.eq_ignore_ascii_case("wav") {
1518        Some("audio/wav")
1519    } else if extension.eq_ignore_ascii_case("mp3") {
1520        Some("audio/mpeg")
1521    } else if extension.eq_ignore_ascii_case("m4a") {
1522        Some("audio/mp4")
1523    } else if extension.eq_ignore_ascii_case("webm") {
1524        Some("audio/webm")
1525    } else if extension.eq_ignore_ascii_case("ogg") {
1526        Some("audio/ogg")
1527    } else {
1528        None
1529    }
1530}
1531
1532fn unsupported_audio_error_placeholder(path: &std::path::Path) -> ContentItem {
1533    ContentItem::InputText {
1534        text: format!(
1535            "Codex cannot attach audio at `{}`: unsupported audio format; use wav, mp3, m4a, webm, or ogg.",
1536            path.display()
1537        ),
1538    }
1539}
1540
1541fn local_audio_content_items(
1542    path: &std::path::Path,
1543    file_bytes: &[u8],
1544    label_number: usize,
1545) -> Vec<ContentItem> {
1546    let Some(mime) = audio_mime_for_path(path) else {
1547        return vec![unsupported_audio_error_placeholder(path)];
1548    };
1549
1550    vec![
1551        ContentItem::InputText {
1552            text: local_audio_open_tag_text_with_path(label_number, path),
1553        },
1554        ContentItem::InputAudio {
1555            audio_url: data_url_from_bytes(mime, file_bytes),
1556        },
1557        ContentItem::InputText {
1558            text: LOCAL_AUDIO_CLOSE_TAG.to_string(),
1559        },
1560    ]
1561}
1562
1563fn local_image_content_items(
1564    path: &std::path::Path,
1565    image_url: String,
1566    label_number: Option<usize>,
1567    detail: ImageDetail,
1568) -> Vec<ContentItem> {
1569    let mut items = Vec::with_capacity(3);
1570    if let Some(label_number) = label_number {
1571        items.push(ContentItem::InputText {
1572            text: local_image_open_tag_text_with_path(label_number, path),
1573        });
1574    }
1575    items.push(ContentItem::InputImage {
1576        image_url,
1577        detail: Some(detail),
1578    });
1579    if label_number.is_some() {
1580        items.push(ContentItem::InputText {
1581            text: LOCAL_IMAGE_CLOSE_TAG.to_string(),
1582        });
1583    }
1584    items
1585}
1586
1587impl From<ResponseInputItem> for ResponseItem {
1588    fn from(item: ResponseInputItem) -> Self {
1589        match item {
1590            ResponseInputItem::Message {
1591                role,
1592                content,
1593                phase,
1594            } => Self::Message {
1595                role,
1596                content,
1597                id: None,
1598                phase,
1599                internal_chat_message_metadata_passthrough: None,
1600            },
1601            ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput {
1602                id: None,
1603                call_id,
1604                output,
1605                internal_chat_message_metadata_passthrough: None,
1606            },
1607            ResponseInputItem::McpToolCallOutput { call_id, output } => {
1608                let output = output.into_function_call_output_payload();
1609                Self::FunctionCallOutput {
1610                    id: None,
1611                    call_id,
1612                    output,
1613                    internal_chat_message_metadata_passthrough: None,
1614                }
1615            }
1616            ResponseInputItem::CustomToolCallOutput {
1617                call_id,
1618                name,
1619                output,
1620            } => Self::CustomToolCallOutput {
1621                id: None,
1622                call_id,
1623                name,
1624                output,
1625                internal_chat_message_metadata_passthrough: None,
1626            },
1627            ResponseInputItem::ToolSearchOutput {
1628                call_id,
1629                status,
1630                execution,
1631                tools,
1632            } => Self::ToolSearchOutput {
1633                call_id: Some(call_id),
1634                status,
1635                execution,
1636                tools,
1637                id: None,
1638                internal_chat_message_metadata_passthrough: None,
1639            },
1640        }
1641    }
1642}
1643
1644#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1645#[serde(rename_all = "snake_case")]
1646pub enum LocalShellStatus {
1647    Completed,
1648    InProgress,
1649    Incomplete,
1650}
1651
1652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1653#[serde(tag = "type", rename_all = "snake_case")]
1654pub enum LocalShellAction {
1655    Exec(LocalShellExecAction),
1656}
1657
1658#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1659pub struct LocalShellExecAction {
1660    pub command: Vec<String>,
1661    pub timeout_ms: Option<u64>,
1662    pub working_directory: Option<String>,
1663    pub env: Option<HashMap<String, String>>,
1664    pub user: Option<String>,
1665}
1666
1667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1668#[serde(tag = "type", rename_all = "snake_case")]
1669#[schemars(rename = "ResponsesApiWebSearchAction")]
1670pub enum WebSearchAction {
1671    Search {
1672        #[serde(default, skip_serializing_if = "Option::is_none")]
1673        #[ts(optional)]
1674        query: Option<String>,
1675        #[serde(default, skip_serializing_if = "Option::is_none")]
1676        #[ts(optional)]
1677        queries: Option<Vec<String>>,
1678    },
1679    OpenPage {
1680        #[serde(default, skip_serializing_if = "Option::is_none")]
1681        #[ts(optional)]
1682        url: Option<String>,
1683    },
1684    FindInPage {
1685        #[serde(default, skip_serializing_if = "Option::is_none")]
1686        #[ts(optional)]
1687        url: Option<String>,
1688        #[serde(default, skip_serializing_if = "Option::is_none")]
1689        #[ts(optional)]
1690        pattern: Option<String>,
1691    },
1692
1693    #[serde(other)]
1694    Other,
1695}
1696
1697#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1698#[serde(tag = "type", rename_all = "snake_case")]
1699pub enum ReasoningItemReasoningSummary {
1700    SummaryText { text: String },
1701}
1702
1703#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1704#[serde(tag = "type", rename_all = "snake_case")]
1705pub enum ReasoningItemContent {
1706    ReasoningText { text: String },
1707    Text { text: String },
1708}
1709
1710impl From<Vec<UserInput>> for ResponseInputItem {
1711    fn from(items: Vec<UserInput>) -> Self {
1712        Self::from_user_input(items, LocalImagePreparation::Process)
1713    }
1714}
1715
1716impl ResponseInputItem {
1717    pub fn from_user_input(
1718        items: Vec<UserInput>,
1719        local_image_preparation: LocalImagePreparation,
1720    ) -> Self {
1721        let mut image_index = 0;
1722        let mut audio_index = 0;
1723        Self::Message {
1724            role: "user".to_string(),
1725            content: items
1726                .into_iter()
1727                .flat_map(|c| match c {
1728                    UserInput::Text { text, .. } => vec![ContentItem::InputText { text }],
1729                    UserInput::Image {
1730                        image_url, detail, ..
1731                    } => {
1732                        image_index += 1;
1733                        let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
1734                        vec![ContentItem::InputImage {
1735                            image_url,
1736                            detail: Some(detail),
1737                        }]
1738                    }
1739                    UserInput::LocalImage { path, detail, .. } => {
1740                        image_index += 1;
1741                        let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
1742                        match std::fs::read(&path) {
1743                            Ok(file_bytes) => match local_image_preparation {
1744                                LocalImagePreparation::Process => {
1745                                    local_image_content_items_with_label_number(
1746                                        &path,
1747                                        file_bytes,
1748                                        Some(image_index),
1749                                        detail,
1750                                    )
1751                                }
1752                                LocalImagePreparation::Defer => local_image_content_items(
1753                                    &path,
1754                                    data_url_from_bytes("application/octet-stream", &file_bytes),
1755                                    Some(image_index),
1756                                    detail,
1757                                ),
1758                            },
1759                            Err(err) => vec![local_media_error_placeholder(
1760                                &path,
1761                                err,
1762                                LocalMediaKind::Image,
1763                            )],
1764                        }
1765                    }
1766                    UserInput::Audio { audio_url } => {
1767                        audio_index += 1;
1768                        vec![ContentItem::InputAudio { audio_url }]
1769                    }
1770                    UserInput::LocalAudio { path } => {
1771                        audio_index += 1;
1772                        match std::fs::read(&path) {
1773                            Ok(file_bytes) => {
1774                                local_audio_content_items(&path, &file_bytes, audio_index)
1775                            }
1776                            Err(err) => vec![local_media_error_placeholder(
1777                                &path,
1778                                err,
1779                                LocalMediaKind::Audio,
1780                            )],
1781                        }
1782                    }
1783                    UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core
1784                })
1785                .collect::<Vec<ContentItem>>(),
1786            phase: None,
1787        }
1788    }
1789}
1790#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
1791pub struct SearchToolCallParams {
1792    pub query: String,
1793    #[serde(default, skip_serializing_if = "Option::is_none")]
1794    #[ts(optional)]
1795    pub limit: Option<usize>,
1796}
1797
1798/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
1799/// `arguments` field should deserialize to this struct.
1800#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
1801pub struct ShellCommandToolCallParams {
1802    pub command: String,
1803    pub workdir: Option<String>,
1804
1805    /// Whether to run the shell with login shell semantics
1806    #[serde(skip_serializing_if = "Option::is_none")]
1807    pub login: Option<bool>,
1808    /// This is the maximum time in milliseconds that the command is allowed to run.
1809    #[serde(alias = "timeout")]
1810    pub timeout_ms: Option<u64>,
1811    #[serde(default, skip_serializing_if = "Option::is_none")]
1812    #[ts(optional)]
1813    pub sandbox_permissions: Option<SandboxPermissions>,
1814    #[serde(default, skip_serializing_if = "Option::is_none")]
1815    #[ts(optional)]
1816    pub prefix_rule: Option<Vec<String>>,
1817    #[serde(default, skip_serializing_if = "Option::is_none")]
1818    #[ts(optional)]
1819    pub additional_permissions: Option<AdditionalPermissionProfile>,
1820    #[serde(skip_serializing_if = "Option::is_none")]
1821    pub justification: Option<String>,
1822}
1823
1824/// Responses API compatible content items that can be returned by a tool call.
1825/// This is a subset of ContentItem with the types we support as function call outputs.
1826#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1827#[serde(tag = "type", rename_all = "snake_case")]
1828pub enum FunctionCallOutputContentItem {
1829    // Do not rename, these are serialized and used directly in the responses API.
1830    InputText {
1831        text: String,
1832    },
1833    // Do not rename, these are serialized and used directly in the responses API.
1834    InputImage {
1835        image_url: String,
1836        #[serde(default, skip_serializing_if = "Option::is_none")]
1837        #[ts(optional)]
1838        detail: Option<ImageDetail>,
1839    },
1840    // Do not rename, these are serialized and used directly in the responses API.
1841    InputAudio {
1842        audio_url: String,
1843    },
1844    EncryptedContent {
1845        encrypted_content: String,
1846    },
1847}
1848
1849/// Converts structured function-call output content into plain text for
1850/// human-readable surfaces.
1851///
1852/// This conversion is intentionally lossy:
1853/// - only `input_text` items are included
1854/// - image and audio items are ignored
1855///
1856/// We use this helper where callers still need a string representation (for
1857/// example telemetry previews or legacy string-only output paths) while keeping
1858/// the original multimodal `content_items` as the authoritative payload sent to
1859/// the model.
1860pub fn function_call_output_content_items_to_text(
1861    content_items: &[FunctionCallOutputContentItem],
1862) -> Option<String> {
1863    let text_segments = content_items
1864        .iter()
1865        .filter_map(|item| match item {
1866            FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => {
1867                Some(text.as_str())
1868            }
1869            FunctionCallOutputContentItem::InputText { .. }
1870            | FunctionCallOutputContentItem::InputImage { .. }
1871            | FunctionCallOutputContentItem::InputAudio { .. }
1872            | FunctionCallOutputContentItem::EncryptedContent { .. } => None,
1873        })
1874        .collect::<Vec<_>>();
1875
1876    if text_segments.is_empty() {
1877        None
1878    } else {
1879        Some(text_segments.join("\n"))
1880    }
1881}
1882
1883impl From<crate::dynamic_tools::DynamicToolCallOutputContentItem>
1884    for FunctionCallOutputContentItem
1885{
1886    fn from(item: crate::dynamic_tools::DynamicToolCallOutputContentItem) -> Self {
1887        match item {
1888            crate::dynamic_tools::DynamicToolCallOutputContentItem::InputText { text } => {
1889                Self::InputText { text }
1890            }
1891            crate::dynamic_tools::DynamicToolCallOutputContentItem::InputImage { image_url } => {
1892                Self::InputImage {
1893                    image_url,
1894                    detail: Some(DEFAULT_IMAGE_DETAIL),
1895                }
1896            }
1897            crate::dynamic_tools::DynamicToolCallOutputContentItem::InputAudio { audio_url } => {
1898                Self::InputAudio { audio_url }
1899            }
1900        }
1901    }
1902}
1903
1904/// The payload we send back to OpenAI when reporting a tool call result.
1905///
1906/// `body` serializes directly as the wire value for `function_call_output.output`.
1907/// `success` remains internal metadata for downstream handling.
1908#[derive(Debug, Default, Clone, PartialEq, JsonSchema, TS)]
1909pub struct FunctionCallOutputPayload {
1910    pub body: FunctionCallOutputBody,
1911    pub success: Option<bool>,
1912}
1913
1914#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1915#[serde(untagged)]
1916pub enum FunctionCallOutputBody {
1917    Text(String),
1918    ContentItems(Vec<FunctionCallOutputContentItem>),
1919}
1920
1921impl FunctionCallOutputBody {
1922    /// Best-effort conversion of a function-call output body to plain text for
1923    /// human-readable surfaces.
1924    ///
1925    /// This conversion is intentionally lossy when the body contains content
1926    /// items: image entries are dropped and text entries are joined with
1927    /// newlines.
1928    pub fn to_text(&self) -> Option<String> {
1929        match self {
1930            Self::Text(content) => Some(content.clone()),
1931            Self::ContentItems(items) => function_call_output_content_items_to_text(items),
1932        }
1933    }
1934}
1935
1936impl Default for FunctionCallOutputBody {
1937    fn default() -> Self {
1938        Self::Text(String::new())
1939    }
1940}
1941
1942impl FunctionCallOutputPayload {
1943    pub fn from_text(content: String) -> Self {
1944        Self {
1945            body: FunctionCallOutputBody::Text(content),
1946            success: None,
1947        }
1948    }
1949
1950    pub fn from_content_items(content_items: Vec<FunctionCallOutputContentItem>) -> Self {
1951        Self {
1952            body: FunctionCallOutputBody::ContentItems(content_items),
1953            success: None,
1954        }
1955    }
1956
1957    pub fn text_content(&self) -> Option<&str> {
1958        match &self.body {
1959            FunctionCallOutputBody::Text(content) => Some(content),
1960            FunctionCallOutputBody::ContentItems(_) => None,
1961        }
1962    }
1963
1964    pub fn content_items(&self) -> Option<&[FunctionCallOutputContentItem]> {
1965        match &self.body {
1966            FunctionCallOutputBody::Text(_) => None,
1967            FunctionCallOutputBody::ContentItems(items) => Some(items),
1968        }
1969    }
1970
1971    pub fn content_items_mut(&mut self) -> Option<&mut Vec<FunctionCallOutputContentItem>> {
1972        match &mut self.body {
1973            FunctionCallOutputBody::Text(_) => None,
1974            FunctionCallOutputBody::ContentItems(items) => Some(items),
1975        }
1976    }
1977}
1978
1979// `function_call_output.output` is encoded as either:
1980//   - an array of structured content items
1981//   - a plain string
1982impl Serialize for FunctionCallOutputPayload {
1983    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1984    where
1985        S: Serializer,
1986    {
1987        match &self.body {
1988            FunctionCallOutputBody::Text(content) => serializer.serialize_str(content),
1989            FunctionCallOutputBody::ContentItems(items) => items.serialize(serializer),
1990        }
1991    }
1992}
1993
1994impl<'de> Deserialize<'de> for FunctionCallOutputPayload {
1995    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1996    where
1997        D: Deserializer<'de>,
1998    {
1999        let body = FunctionCallOutputBody::deserialize(deserializer)?;
2000        Ok(FunctionCallOutputPayload {
2001            body,
2002            success: None,
2003        })
2004    }
2005}
2006
2007impl CallToolResult {
2008    pub fn from_result(result: Result<Self, String>) -> Self {
2009        match result {
2010            Ok(result) => result,
2011            Err(error) => Self::from_error_text(error),
2012        }
2013    }
2014
2015    pub fn from_error_text(text: String) -> Self {
2016        Self {
2017            content: vec![serde_json::json!({
2018                "type": "text",
2019                "text": text,
2020            })],
2021            structured_content: None,
2022            is_error: Some(true),
2023            meta: None,
2024        }
2025    }
2026
2027    pub fn success(&self) -> bool {
2028        self.is_error != Some(true)
2029    }
2030
2031    pub fn as_function_call_output_payload(&self) -> FunctionCallOutputPayload {
2032        let content_items = convert_mcp_content_to_items(&self.content);
2033        if content_items.as_ref().is_some_and(|items| {
2034            items
2035                .iter()
2036                .any(|item| matches!(item, FunctionCallOutputContentItem::EncryptedContent { .. }))
2037        }) {
2038            return FunctionCallOutputPayload {
2039                body: FunctionCallOutputBody::ContentItems(content_items.unwrap_or_default()),
2040                success: Some(self.success()),
2041            };
2042        }
2043
2044        if let Some(structured_content) = &self.structured_content
2045            && !structured_content.is_null()
2046        {
2047            match serde_json::to_string(structured_content) {
2048                Ok(serialized_structured_content) => {
2049                    return FunctionCallOutputPayload {
2050                        body: FunctionCallOutputBody::Text(serialized_structured_content),
2051                        success: Some(self.success()),
2052                    };
2053                }
2054                Err(err) => {
2055                    return FunctionCallOutputPayload {
2056                        body: FunctionCallOutputBody::Text(err.to_string()),
2057                        success: Some(false),
2058                    };
2059                }
2060            }
2061        }
2062
2063        let serialized_content = match serde_json::to_string(&self.content) {
2064            Ok(serialized_content) => serialized_content,
2065            Err(err) => {
2066                return FunctionCallOutputPayload {
2067                    body: FunctionCallOutputBody::Text(err.to_string()),
2068                    success: Some(false),
2069                };
2070            }
2071        };
2072
2073        let body = match content_items {
2074            Some(content_items) => FunctionCallOutputBody::ContentItems(content_items),
2075            None => FunctionCallOutputBody::Text(serialized_content),
2076        };
2077
2078        FunctionCallOutputPayload {
2079            body,
2080            success: Some(self.success()),
2081        }
2082    }
2083
2084    pub fn into_function_call_output_payload(self) -> FunctionCallOutputPayload {
2085        self.as_function_call_output_payload()
2086    }
2087}
2088
2089fn convert_mcp_content_to_items(
2090    contents: &[serde_json::Value],
2091) -> Option<Vec<FunctionCallOutputContentItem>> {
2092    const CODEX_ENCRYPTED_CONTENT_META_KEY: &str = "codex/encryptedContent";
2093    const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
2094
2095    #[derive(serde::Deserialize)]
2096    #[serde(tag = "type")]
2097    enum McpContent {
2098        #[serde(rename = "text")]
2099        Text {
2100            text: String,
2101            #[serde(rename = "_meta", default)]
2102            meta: Option<serde_json::Value>,
2103        },
2104        #[serde(rename = "image")]
2105        Image {
2106            data: String,
2107            #[serde(rename = "mimeType", alias = "mime_type")]
2108            mime_type: Option<String>,
2109            #[serde(rename = "_meta", default)]
2110            meta: Option<serde_json::Value>,
2111        },
2112        #[serde(rename = "audio")]
2113        Audio {
2114            data: String,
2115            #[serde(rename = "mimeType", alias = "mime_type")]
2116            mime_type: Option<String>,
2117            #[serde(rename = "_meta", default)]
2118            _meta: Option<serde_json::Value>,
2119        },
2120        #[serde(other)]
2121        Unknown,
2122    }
2123
2124    let mut saw_content_item = false;
2125    let mut items = Vec::with_capacity(contents.len());
2126
2127    for content in contents {
2128        let item = match serde_json::from_value::<McpContent>(content.clone()) {
2129            Ok(McpContent::Text { text, meta }) => {
2130                if meta
2131                    .as_ref()
2132                    .and_then(|meta| meta.get(CODEX_ENCRYPTED_CONTENT_META_KEY))
2133                    .and_then(serde_json::Value::as_bool)
2134                    == Some(true)
2135                {
2136                    saw_content_item = true;
2137                    FunctionCallOutputContentItem::EncryptedContent {
2138                        encrypted_content: text,
2139                    }
2140                } else {
2141                    FunctionCallOutputContentItem::InputText { text }
2142                }
2143            }
2144            Ok(McpContent::Image {
2145                data,
2146                mime_type,
2147                meta,
2148            }) => {
2149                saw_content_item = true;
2150                let image_url = if data.starts_with("data:") {
2151                    data
2152                } else {
2153                    let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
2154                    format!("data:{mime_type};base64,{data}")
2155                };
2156                FunctionCallOutputContentItem::InputImage {
2157                    image_url,
2158                    detail: meta
2159                        .as_ref()
2160                        .and_then(serde_json::Value::as_object)
2161                        .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY))
2162                        .and_then(serde_json::Value::as_str)
2163                        .and_then(|detail| match detail {
2164                            "auto" => Some(ImageDetail::Auto),
2165                            "low" => Some(ImageDetail::Low),
2166                            "high" => Some(ImageDetail::High),
2167                            "original" => Some(ImageDetail::Original),
2168                            _ => None,
2169                        })
2170                        .or(Some(DEFAULT_IMAGE_DETAIL)),
2171                }
2172            }
2173            Ok(McpContent::Audio {
2174                data, mime_type, ..
2175            }) => {
2176                saw_content_item = true;
2177                let audio_url = if data.starts_with("data:") {
2178                    data
2179                } else {
2180                    let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
2181                    format!("data:{mime_type};base64,{data}")
2182                };
2183                FunctionCallOutputContentItem::InputAudio { audio_url }
2184            }
2185            Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText {
2186                text: serde_json::to_string(content).unwrap_or_else(|_| "<content>".to_string()),
2187            },
2188        };
2189        items.push(item);
2190    }
2191
2192    if saw_content_item { Some(items) } else { None }
2193}
2194
2195// Implement Display so callers can treat the payload like a plain string when logging or doing
2196// trivial substring checks in tests (existing tests call `.contains()` on the output). For
2197// `ContentItems`, Display emits a JSON representation.
2198
2199impl std::fmt::Display for FunctionCallOutputPayload {
2200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2201        match &self.body {
2202            FunctionCallOutputBody::Text(content) => f.write_str(content),
2203            FunctionCallOutputBody::ContentItems(items) => {
2204                let content = serde_json::to_string(items).unwrap_or_default();
2205                f.write_str(content.as_str())
2206            }
2207        }
2208    }
2209}
2210
2211// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.)
2212
2213#[cfg(test)]
2214mod tests {
2215    use super::*;
2216    use anyhow::Result;
2217    use codex_execpolicy::Policy;
2218    use pretty_assertions::assert_eq;
2219    use std::path::PathBuf;
2220    use tempfile::tempdir;
2221
2222    // A tiny valid PNG (1x1) so image conversion tests don't depend on cross-crate
2223    // file paths, which break under Bazel sandboxing.
2224    const TINY_PNG_BYTES: &[u8] = &[
2225        137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
2226        0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
2227        1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
2228    ];
2229
2230    #[test]
2231    fn plaintext_agent_message_content_rejects_mixed_encrypted_content() {
2232        let content = vec![
2233            AgentMessageInputContent::InputText {
2234                text: "Message Type: MESSAGE\nPayload:\n".to_string(),
2235            },
2236            AgentMessageInputContent::EncryptedContent {
2237                encrypted_content: "encrypted-payload".to_string(),
2238            },
2239        ];
2240
2241        assert_eq!(plaintext_agent_message_content(&content), None);
2242    }
2243
2244    #[test]
2245    fn response_input_message_conversion_preserves_phase() {
2246        let item = ResponseItem::from(ResponseInputItem::Message {
2247            role: "assistant".to_string(),
2248            content: vec![ContentItem::OutputText {
2249                text: "still working".to_string(),
2250            }],
2251            phase: Some(MessagePhase::Commentary),
2252        });
2253
2254        assert_eq!(
2255            item,
2256            ResponseItem::Message {
2257                id: None,
2258                role: "assistant".to_string(),
2259                content: vec![ContentItem::OutputText {
2260                    text: "still working".to_string(),
2261                }],
2262                phase: Some(MessagePhase::Commentary),
2263                internal_chat_message_metadata_passthrough: None,
2264            }
2265        );
2266    }
2267
2268    #[test]
2269    fn response_item_passthrough_metadata_round_trips_and_stamps_turn_ids() -> Result<()> {
2270        let mut item =
2271            response_item_with_passthrough_metadata(Some(passthrough_metadata("turn-1")));
2272        let round_trip: ResponseItem = serde_json::from_value(serde_json::to_value(&item)?)?;
2273        assert_eq!(round_trip, item);
2274
2275        let unknown_metadata: ResponseItem = serde_json::from_value(serde_json::json!({
2276            "type": "message",
2277            "role": "user",
2278            "content": [{"type": "input_text", "text": "hello"}],
2279            "internal_chat_message_metadata_passthrough": {
2280                "turn_id": "turn-1",
2281                "other": "ignored",
2282            },
2283        }))?;
2284        assert_eq!(unknown_metadata, item);
2285
2286        item.set_turn_id_if_missing("turn-2");
2287        assert_eq!(item.turn_id(), Some("turn-1"));
2288
2289        let mut empty_turn_id =
2290            response_item_with_passthrough_metadata(Some(passthrough_metadata("")));
2291        empty_turn_id.set_turn_id_if_missing("turn-1");
2292        assert_eq!(empty_turn_id.turn_id(), Some("turn-1"));
2293
2294        let mut missing_turn_id = response_item_with_passthrough_metadata(
2295            /*internal_chat_message_metadata_passthrough*/ None,
2296        );
2297        missing_turn_id.set_turn_id_if_missing("");
2298        missing_turn_id.set_turn_id_if_missing("turn-1");
2299        assert_eq!(missing_turn_id.turn_id(), Some("turn-1"));
2300
2301        let mut other = ResponseItem::Other;
2302        other.set_turn_id_if_missing("turn-1");
2303        assert_eq!(other.turn_id(), None);
2304        Ok(())
2305    }
2306
2307    #[test]
2308    fn response_item_id_getter_and_setter() {
2309        let mut item = response_item_with_passthrough_metadata(
2310            /*internal_chat_message_metadata_passthrough*/ None,
2311        );
2312        assert_eq!(item.id(), None);
2313
2314        item.set_id(Some(ResponseItemId::with_suffix("msg", "test")));
2315
2316        assert_eq!(item.id().map(ResponseItemId::as_str), Some("msg_test"));
2317
2318        item.set_id(/*new_id*/ None);
2319
2320        assert_eq!(item.id(), None);
2321
2322        let mut additional_tools = ResponseItem::AdditionalTools {
2323            id: None,
2324            role: "developer".to_string(),
2325            tools: Vec::new(),
2326        };
2327        additional_tools.set_id(Some(ResponseItemId::with_suffix("at", "test")));
2328        assert_eq!(
2329            additional_tools.id().map(ResponseItemId::as_str),
2330            Some("at_test")
2331        );
2332    }
2333
2334    fn response_item_with_passthrough_metadata(
2335        internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
2336    ) -> ResponseItem {
2337        ResponseItem::Message {
2338            id: None,
2339            role: "user".to_string(),
2340            content: vec![ContentItem::InputText {
2341                text: "hello".to_string(),
2342            }],
2343            phase: None,
2344            internal_chat_message_metadata_passthrough,
2345        }
2346    }
2347
2348    fn passthrough_metadata(turn_id: &str) -> InternalChatMessageMetadataPassthrough {
2349        InternalChatMessageMetadataPassthrough {
2350            turn_id: Some(turn_id.to_string()),
2351        }
2352    }
2353
2354    #[test]
2355    fn image_detail_roundtrips_all_wire_values() -> Result<()> {
2356        assert_eq!(
2357            serde_json::from_str::<ImageDetail>("\"auto\"")?,
2358            ImageDetail::Auto
2359        );
2360        assert_eq!(
2361            serde_json::from_str::<ImageDetail>("\"low\"")?,
2362            ImageDetail::Low
2363        );
2364        assert_eq!(serde_json::to_string(&ImageDetail::Auto)?, "\"auto\"");
2365        assert_eq!(serde_json::to_string(&ImageDetail::Low)?, "\"low\"");
2366
2367        let content_item: ContentItem = serde_json::from_value(serde_json::json!({
2368            "type": "input_image",
2369            "image_url": "data:image/png;base64,abc",
2370            "detail": "auto",
2371        }))?;
2372
2373        assert_eq!(
2374            content_item,
2375            ContentItem::InputImage {
2376                image_url: "data:image/png;base64,abc".to_string(),
2377                detail: Some(ImageDetail::Auto),
2378            }
2379        );
2380
2381        Ok(())
2382    }
2383
2384    #[test]
2385    fn sandbox_permissions_helpers_match_documented_semantics() {
2386        let cases = [
2387            (SandboxPermissions::UseDefault, false, false, false),
2388            (SandboxPermissions::RequireEscalated, true, true, false),
2389            (
2390                SandboxPermissions::WithAdditionalPermissions,
2391                false,
2392                true,
2393                true,
2394            ),
2395        ];
2396
2397        for (
2398            sandbox_permissions,
2399            requires_escalated_permissions,
2400            requests_sandbox_override,
2401            uses_additional_permissions,
2402        ) in cases
2403        {
2404            assert_eq!(
2405                sandbox_permissions.requires_escalated_permissions(),
2406                requires_escalated_permissions
2407            );
2408            assert_eq!(
2409                sandbox_permissions.requests_sandbox_override(),
2410                requests_sandbox_override
2411            );
2412            assert_eq!(
2413                sandbox_permissions.uses_additional_permissions(),
2414                uses_additional_permissions
2415            );
2416        }
2417    }
2418
2419    #[test]
2420    fn convert_mcp_content_to_items_preserves_data_urls() {
2421        let contents = vec![serde_json::json!({
2422            "type": "image",
2423            "data": "data:image/png;base64,Zm9v",
2424            "mimeType": "image/png",
2425        })];
2426
2427        let items = convert_mcp_content_to_items(&contents).expect("expected image items");
2428        assert_eq!(
2429            items,
2430            vec![FunctionCallOutputContentItem::InputImage {
2431                image_url: "data:image/png;base64,Zm9v".to_string(),
2432                detail: Some(DEFAULT_IMAGE_DETAIL),
2433            }]
2434        );
2435    }
2436
2437    #[test]
2438    fn response_item_parses_image_generation_call() {
2439        let item = serde_json::from_value::<ResponseItem>(serde_json::json!({
2440            "id": "ig_123",
2441            "type": "image_generation_call",
2442            "status": "completed",
2443            "revised_prompt": "A small blue square",
2444            "result": "Zm9v",
2445        }))
2446        .expect("image generation item should deserialize");
2447
2448        assert_eq!(
2449            item,
2450            ResponseItem::ImageGenerationCall {
2451                id: Some(ResponseItemId::with_suffix("ig", "123")),
2452                status: "completed".to_string(),
2453                revised_prompt: Some("A small blue square".to_string()),
2454                result: "Zm9v".to_string(),
2455                internal_chat_message_metadata_passthrough: None,
2456            }
2457        );
2458    }
2459
2460    #[test]
2461    fn response_item_parses_image_generation_call_without_revised_prompt() {
2462        let item = serde_json::from_value::<ResponseItem>(serde_json::json!({
2463            "id": "ig_123",
2464            "type": "image_generation_call",
2465            "status": "completed",
2466            "result": "Zm9v",
2467        }))
2468        .expect("image generation item should deserialize");
2469
2470        assert_eq!(
2471            item,
2472            ResponseItem::ImageGenerationCall {
2473                id: Some(ResponseItemId::with_suffix("ig", "123")),
2474                status: "completed".to_string(),
2475                revised_prompt: None,
2476                result: "Zm9v".to_string(),
2477                internal_chat_message_metadata_passthrough: None,
2478            }
2479        );
2480    }
2481
2482    #[test]
2483    fn additional_permission_profile_is_empty_when_all_fields_are_none() {
2484        assert_eq!(AdditionalPermissionProfile::default().is_empty(), true);
2485    }
2486
2487    #[test]
2488    fn additional_permission_profile_is_not_empty_when_field_is_present_but_nested_empty() {
2489        let permission_profile = AdditionalPermissionProfile {
2490            network: Some(NetworkPermissions { enabled: None }),
2491            file_system: None,
2492        };
2493        assert_eq!(permission_profile.is_empty(), false);
2494    }
2495
2496    #[test]
2497    fn permission_profile_round_trip_preserves_glob_scan_max_depth() {
2498        let mut file_system_sandbox_policy =
2499            FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
2500                path: FileSystemPath::GlobPattern {
2501                    pattern: "**/*.env".to_string(),
2502                },
2503                access: FileSystemAccessMode::Deny,
2504                missing_path_behavior: None,
2505            }]);
2506        file_system_sandbox_policy.glob_scan_max_depth = Some(2);
2507
2508        let permission_profile = PermissionProfile::from_runtime_permissions(
2509            &file_system_sandbox_policy,
2510            NetworkSandboxPolicy::Restricted,
2511        );
2512
2513        assert_eq!(
2514            permission_profile.file_system_sandbox_policy(),
2515            file_system_sandbox_policy
2516        );
2517    }
2518
2519    #[test]
2520    fn permission_profile_deserializes_legacy_rollout_shape() -> Result<()> {
2521        let legacy = serde_json::json!({
2522            "network": {
2523                "enabled": true,
2524            },
2525            "file_system": {
2526                "entries": [{
2527                    "path": {
2528                        "type": "special",
2529                        "value": {
2530                            "kind": "root",
2531                        },
2532                    },
2533                    "access": "write",
2534                }],
2535                "glob_scan_max_depth": 2,
2536            },
2537        });
2538
2539        let permission_profile: PermissionProfile = serde_json::from_value(legacy)?;
2540
2541        assert_eq!(
2542            permission_profile,
2543            PermissionProfile::Managed {
2544                file_system: ManagedFileSystemPermissions::Restricted {
2545                    entries: vec![FileSystemSandboxEntry {
2546                        path: FileSystemPath::Special {
2547                            value: FileSystemSpecialPath::Root,
2548                        },
2549                        access: FileSystemAccessMode::Write,
2550                        missing_path_behavior: None,
2551                    }],
2552                    glob_scan_max_depth: NonZeroUsize::new(2),
2553                },
2554                network: NetworkSandboxPolicy::Enabled,
2555            }
2556        );
2557        Ok(())
2558    }
2559
2560    #[test]
2561    fn permission_profile_presets_match_legacy_defaults() {
2562        assert_eq!(
2563            PermissionProfile::read_only(),
2564            PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy())
2565        );
2566        assert_eq!(
2567            PermissionProfile::workspace_write(),
2568            PermissionProfile::from_legacy_sandbox_policy(
2569                &SandboxPolicy::new_workspace_write_policy()
2570            )
2571        );
2572    }
2573
2574    #[test]
2575    fn permission_profile_round_trip_preserves_disabled_sandbox() -> Result<()> {
2576        let cwd = tempdir()?;
2577        let permission_profile =
2578            PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::DangerFullAccess);
2579
2580        assert_eq!(permission_profile, PermissionProfile::Disabled);
2581        assert_eq!(
2582            permission_profile.to_legacy_sandbox_policy(cwd.path())?,
2583            SandboxPolicy::DangerFullAccess
2584        );
2585        assert_eq!(
2586            permission_profile.to_runtime_permissions(),
2587            (
2588                FileSystemSandboxPolicy::unrestricted(),
2589                NetworkSandboxPolicy::Enabled
2590            )
2591        );
2592        Ok(())
2593    }
2594
2595    #[test]
2596    fn disabled_permission_profile_ignores_runtime_network_policy() {
2597        let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
2598            SandboxEnforcement::Disabled,
2599            &FileSystemSandboxPolicy::unrestricted(),
2600            NetworkSandboxPolicy::Restricted,
2601        );
2602
2603        assert_eq!(permission_profile, PermissionProfile::Disabled);
2604    }
2605
2606    #[test]
2607    fn permission_profile_from_runtime_permissions_preserves_external_sandbox() {
2608        let permission_profile = PermissionProfile::from_runtime_permissions(
2609            &FileSystemSandboxPolicy::external_sandbox(),
2610            NetworkSandboxPolicy::Restricted,
2611        );
2612
2613        assert_eq!(
2614            permission_profile,
2615            PermissionProfile::External {
2616                network: NetworkSandboxPolicy::Restricted,
2617            }
2618        );
2619        assert_eq!(
2620            PermissionProfile::from_runtime_permissions_with_enforcement(
2621                SandboxEnforcement::Managed,
2622                &FileSystemSandboxPolicy::external_sandbox(),
2623                NetworkSandboxPolicy::Restricted,
2624            ),
2625            permission_profile,
2626        );
2627    }
2628
2629    #[test]
2630    fn permission_profile_from_runtime_permissions_preserves_unrestricted_managed_network() {
2631        let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
2632            SandboxEnforcement::External,
2633            &FileSystemSandboxPolicy::unrestricted(),
2634            NetworkSandboxPolicy::Restricted,
2635        );
2636
2637        assert_eq!(
2638            permission_profile,
2639            PermissionProfile::Managed {
2640                file_system: ManagedFileSystemPermissions::Unrestricted,
2641                network: NetworkSandboxPolicy::Restricted,
2642            },
2643            "the legacy ExternalSandbox projection must not hide a split unrestricted filesystem policy"
2644        );
2645        assert_eq!(
2646            permission_profile.to_runtime_permissions(),
2647            (
2648                FileSystemSandboxPolicy::unrestricted(),
2649                NetworkSandboxPolicy::Restricted,
2650            )
2651        );
2652    }
2653
2654    #[test]
2655    fn permission_profile_round_trip_preserves_external_sandbox() -> Result<()> {
2656        let cwd = tempdir()?;
2657        let sandbox_policy = SandboxPolicy::ExternalSandbox {
2658            network_access: crate::protocol::NetworkAccess::Restricted,
2659        };
2660        let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy);
2661
2662        assert_eq!(
2663            permission_profile,
2664            PermissionProfile::External {
2665                network: NetworkSandboxPolicy::Restricted,
2666            }
2667        );
2668        assert_eq!(
2669            permission_profile.to_legacy_sandbox_policy(cwd.path())?,
2670            sandbox_policy
2671        );
2672        assert_eq!(
2673            permission_profile.to_runtime_permissions(),
2674            (
2675                FileSystemSandboxPolicy::external_sandbox(),
2676                NetworkSandboxPolicy::Restricted
2677            )
2678        );
2679        Ok(())
2680    }
2681
2682    #[test]
2683    fn file_system_permissions_with_glob_scan_depth_uses_canonical_json() -> Result<()> {
2684        let path = AbsolutePathBuf::try_from(PathBuf::from(if cfg!(windows) {
2685            r"C:\tmp\allowed"
2686        } else {
2687            "/tmp/allowed"
2688        }))
2689        .expect("absolute path");
2690        let file_system_permissions = FileSystemPermissions {
2691            entries: vec![FileSystemSandboxEntry {
2692                path: FileSystemPath::Path { path },
2693                access: FileSystemAccessMode::Read,
2694                missing_path_behavior: None,
2695            }],
2696            glob_scan_max_depth: NonZeroUsize::new(2),
2697        };
2698
2699        let serialized = serde_json::to_value(&file_system_permissions)?;
2700
2701        assert_eq!(serialized.get("read"), None);
2702        assert_eq!(serialized.get("write"), None);
2703        assert_eq!(
2704            serialized.get("glob_scan_max_depth"),
2705            Some(&serde_json::json!(2))
2706        );
2707        assert!(serialized.get("entries").is_some());
2708        assert_eq!(
2709            serde_json::from_value::<FileSystemPermissions>(serialized)?,
2710            file_system_permissions
2711        );
2712        Ok(())
2713    }
2714
2715    #[test]
2716    fn file_system_permissions_rejects_zero_glob_scan_depth() {
2717        serde_json::from_value::<FileSystemPermissions>(serde_json::json!({
2718            "entries": [],
2719            "glob_scan_max_depth": 0,
2720        }))
2721        .expect_err("zero glob scan depth should fail deserialization");
2722    }
2723
2724    #[test]
2725    fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
2726        let contents = vec![serde_json::json!({
2727            "type": "image",
2728            "data": "Zm9v",
2729            "mimeType": "image/png",
2730        })];
2731
2732        let items = convert_mcp_content_to_items(&contents).expect("expected image items");
2733        assert_eq!(
2734            items,
2735            vec![FunctionCallOutputContentItem::InputImage {
2736                image_url: "data:image/png;base64,Zm9v".to_string(),
2737                detail: Some(DEFAULT_IMAGE_DETAIL),
2738            }]
2739        );
2740    }
2741
2742    #[test]
2743    fn convert_mcp_audio_content_builds_data_urls_and_preserves_existing_data_urls() {
2744        let contents = vec![
2745            serde_json::json!({
2746                "type": "audio",
2747                "data": "Zm9v",
2748                "mimeType": "audio/wav",
2749                "_meta": {"source": "microphone"},
2750            }),
2751            serde_json::json!({
2752                "type": "audio",
2753                "data": "data:audio/ogg;base64,YmFy",
2754                "mimeType": "audio/ogg",
2755            }),
2756        ];
2757
2758        assert_eq!(
2759            convert_mcp_content_to_items(&contents),
2760            Some(vec![
2761                FunctionCallOutputContentItem::InputAudio {
2762                    audio_url: "data:audio/wav;base64,Zm9v".to_string(),
2763                },
2764                FunctionCallOutputContentItem::InputAudio {
2765                    audio_url: "data:audio/ogg;base64,YmFy".to_string(),
2766                },
2767            ])
2768        );
2769    }
2770
2771    #[test]
2772    fn convert_mcp_content_to_items_returns_none_without_media() {
2773        let contents = vec![serde_json::json!({
2774            "type": "text",
2775            "text": "hello",
2776        })];
2777
2778        assert_eq!(convert_mcp_content_to_items(&contents), None);
2779    }
2780
2781    #[test]
2782    fn function_call_output_content_items_to_text_joins_text_segments() {
2783        let content_items = vec![
2784            FunctionCallOutputContentItem::InputText {
2785                text: "line 1".to_string(),
2786            },
2787            FunctionCallOutputContentItem::InputImage {
2788                image_url: "data:image/png;base64,AAA".to_string(),
2789                detail: Some(DEFAULT_IMAGE_DETAIL),
2790            },
2791            FunctionCallOutputContentItem::InputText {
2792                text: "line 2".to_string(),
2793            },
2794        ];
2795
2796        let text = function_call_output_content_items_to_text(&content_items);
2797        assert_eq!(text, Some("line 1\nline 2".to_string()));
2798    }
2799
2800    #[test]
2801    fn function_call_output_content_items_to_text_ignores_blank_text_and_media() {
2802        let content_items = vec![
2803            FunctionCallOutputContentItem::InputText {
2804                text: "   ".to_string(),
2805            },
2806            FunctionCallOutputContentItem::InputImage {
2807                image_url: "data:image/png;base64,AAA".to_string(),
2808                detail: Some(DEFAULT_IMAGE_DETAIL),
2809            },
2810            FunctionCallOutputContentItem::InputAudio {
2811                audio_url: "data:audio/wav;base64,AAA".to_string(),
2812            },
2813            FunctionCallOutputContentItem::EncryptedContent {
2814                encrypted_content: "enc_opaque".to_string(),
2815            },
2816        ];
2817
2818        let text = function_call_output_content_items_to_text(&content_items);
2819        assert_eq!(text, None);
2820    }
2821
2822    #[test]
2823    fn function_call_output_body_to_text_returns_plain_text_content() {
2824        let body = FunctionCallOutputBody::Text("ok".to_string());
2825        let text = body.to_text();
2826        assert_eq!(text, Some("ok".to_string()));
2827    }
2828
2829    #[test]
2830    fn function_call_output_body_to_text_uses_content_item_fallback() {
2831        let body = FunctionCallOutputBody::ContentItems(vec![
2832            FunctionCallOutputContentItem::InputText {
2833                text: "line 1".to_string(),
2834            },
2835            FunctionCallOutputContentItem::InputImage {
2836                image_url: "data:image/png;base64,AAA".to_string(),
2837                detail: Some(DEFAULT_IMAGE_DETAIL),
2838            },
2839        ]);
2840
2841        let text = body.to_text();
2842        assert_eq!(text, Some("line 1".to_string()));
2843    }
2844
2845    #[test]
2846    fn function_call_deserializes_optional_namespace() {
2847        let item: ResponseItem = serde_json::from_value(serde_json::json!({
2848            "type": "function_call",
2849            "name": "mcp__codex_apps__gmail_get_recent_emails",
2850            "namespace": "mcp__codex_apps__gmail",
2851            "arguments": "{\"top_k\":5}",
2852            "call_id": "call-1",
2853        }))
2854        .expect("function_call should deserialize");
2855
2856        assert_eq!(
2857            item,
2858            ResponseItem::FunctionCall {
2859                id: None,
2860                name: "mcp__codex_apps__gmail_get_recent_emails".to_string(),
2861                namespace: Some("mcp__codex_apps__gmail".to_string()),
2862                arguments: "{\"top_k\":5}".to_string(),
2863                call_id: "call-1".to_string(),
2864                internal_chat_message_metadata_passthrough: None,
2865            }
2866        );
2867    }
2868
2869    #[test]
2870    fn render_command_prefix_list_sorts_by_len_then_total_len_then_alphabetical() {
2871        let prefixes = vec![
2872            vec!["b".to_string(), "zz".to_string()],
2873            vec!["aa".to_string()],
2874            vec!["b".to_string()],
2875            vec!["a".to_string(), "b".to_string(), "c".to_string()],
2876            vec!["a".to_string()],
2877            vec!["b".to_string(), "a".to_string()],
2878        ];
2879
2880        let output = format_allow_prefixes(prefixes).expect("rendered list");
2881        assert_eq!(
2882            output,
2883            r#"- ["a"]
2884- ["b"]
2885- ["aa"]
2886- ["b", "a"]
2887- ["b", "zz"]
2888- ["a", "b", "c"]"#
2889                .to_string(),
2890        );
2891    }
2892
2893    #[test]
2894    fn render_command_prefix_list_limits_output_to_max_prefixes() {
2895        let prefixes = (0..(MAX_RENDERED_PREFIXES + 5))
2896            .map(|i| vec![format!("{i:03}")])
2897            .collect::<Vec<_>>();
2898
2899        let output = format_allow_prefixes(prefixes).expect("rendered list");
2900        assert_eq!(output.ends_with(TRUNCATED_MARKER), true);
2901        eprintln!("output: {output}");
2902        assert_eq!(output.lines().count(), MAX_RENDERED_PREFIXES + 1);
2903    }
2904
2905    #[test]
2906    fn format_allow_prefixes_limits_output() {
2907        let mut exec_policy = Policy::empty();
2908        for i in 0..200 {
2909            exec_policy
2910                .add_prefix_rule(
2911                    &[format!("tool-{i:03}"), "x".repeat(500)],
2912                    codex_execpolicy::Decision::Allow,
2913                )
2914                .expect("add rule");
2915        }
2916
2917        let output =
2918            format_allow_prefixes(exec_policy.get_allowed_prefixes()).expect("formatted prefixes");
2919        assert!(
2920            output.len() <= MAX_ALLOW_PREFIX_TEXT_BYTES + TRUNCATED_MARKER.len(),
2921            "output length exceeds expected limit: {output}",
2922        );
2923    }
2924
2925    #[test]
2926    fn serializes_success_as_plain_string() -> Result<()> {
2927        let item = ResponseInputItem::FunctionCallOutput {
2928            call_id: "call1".into(),
2929            output: FunctionCallOutputPayload::from_text("ok".into()),
2930        };
2931
2932        let json = serde_json::to_string(&item)?;
2933        let v: serde_json::Value = serde_json::from_str(&json)?;
2934
2935        // Success case -> output should be a plain string
2936        assert_eq!(v.get("output").unwrap().as_str().unwrap(), "ok");
2937        Ok(())
2938    }
2939
2940    #[test]
2941    fn serializes_failure_as_string() -> Result<()> {
2942        let item = ResponseInputItem::FunctionCallOutput {
2943            call_id: "call1".into(),
2944            output: FunctionCallOutputPayload {
2945                body: FunctionCallOutputBody::Text("bad".into()),
2946                success: Some(false),
2947            },
2948        };
2949
2950        let json = serde_json::to_string(&item)?;
2951        let v: serde_json::Value = serde_json::from_str(&json)?;
2952
2953        assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
2954        Ok(())
2955    }
2956
2957    #[test]
2958    fn serializes_image_outputs_as_array() -> Result<()> {
2959        let call_tool_result = CallToolResult {
2960            content: vec![
2961                serde_json::json!({"type":"text","text":"caption"}),
2962                serde_json::json!({"type":"image","data":"BASE64","mimeType":"image/png"}),
2963            ],
2964            structured_content: None,
2965            is_error: Some(false),
2966            meta: None,
2967        };
2968
2969        let payload = call_tool_result.into_function_call_output_payload();
2970        assert_eq!(payload.success, Some(true));
2971        let Some(items) = payload.content_items() else {
2972            panic!("expected content items");
2973        };
2974        let items = items.to_vec();
2975        assert_eq!(
2976            items,
2977            vec![
2978                FunctionCallOutputContentItem::InputText {
2979                    text: "caption".into(),
2980                },
2981                FunctionCallOutputContentItem::InputImage {
2982                    image_url: "data:image/png;base64,BASE64".into(),
2983                    detail: Some(DEFAULT_IMAGE_DETAIL),
2984                },
2985            ]
2986        );
2987
2988        let item = ResponseInputItem::FunctionCallOutput {
2989            call_id: "call1".into(),
2990            output: payload,
2991        };
2992
2993        let json = serde_json::to_string(&item)?;
2994        let v: serde_json::Value = serde_json::from_str(&json)?;
2995
2996        let output = v.get("output").expect("output field");
2997        assert!(output.is_array(), "expected array output");
2998
2999        Ok(())
3000    }
3001
3002    #[test]
3003    fn serializes_audio_outputs_as_array() -> Result<()> {
3004        let call_tool_result = CallToolResult {
3005            content: vec![
3006                serde_json::json!({"type":"text","text":"caption"}),
3007                serde_json::json!({"type":"audio","data":"BASE64","mimeType":"audio/wav"}),
3008            ],
3009            structured_content: None,
3010            is_error: Some(false),
3011            meta: None,
3012        };
3013
3014        let payload = call_tool_result.into_function_call_output_payload();
3015        assert_eq!(
3016            payload,
3017            FunctionCallOutputPayload {
3018                body: FunctionCallOutputBody::ContentItems(vec![
3019                    FunctionCallOutputContentItem::InputText {
3020                        text: "caption".into(),
3021                    },
3022                    FunctionCallOutputContentItem::InputAudio {
3023                        audio_url: "data:audio/wav;base64,BASE64".into(),
3024                    },
3025                ]),
3026                success: Some(true),
3027            }
3028        );
3029
3030        let item = ResponseInputItem::FunctionCallOutput {
3031            call_id: "call1".into(),
3032            output: payload,
3033        };
3034
3035        assert_eq!(
3036            serde_json::to_value(item)?,
3037            serde_json::json!({
3038                "type": "function_call_output",
3039                "call_id": "call1",
3040                "output": [
3041                    {"type": "input_text", "text": "caption"},
3042                    {"type": "input_audio", "audio_url": "data:audio/wav;base64,BASE64"},
3043                ],
3044            })
3045        );
3046
3047        Ok(())
3048    }
3049
3050    #[test]
3051    fn serializes_custom_tool_image_outputs_as_array() -> Result<()> {
3052        let item = ResponseInputItem::CustomToolCallOutput {
3053            call_id: "call1".into(),
3054            name: None,
3055            output: FunctionCallOutputPayload::from_content_items(vec![
3056                FunctionCallOutputContentItem::InputImage {
3057                    image_url: "data:image/png;base64,BASE64".into(),
3058                    detail: Some(DEFAULT_IMAGE_DETAIL),
3059                },
3060            ]),
3061        };
3062
3063        let json = serde_json::to_string(&item)?;
3064        let v: serde_json::Value = serde_json::from_str(&json)?;
3065
3066        let output = v.get("output").expect("output field");
3067        assert!(output.is_array(), "expected array output");
3068
3069        Ok(())
3070    }
3071
3072    #[test]
3073    fn serializes_encrypted_function_output_content_as_array() -> Result<()> {
3074        let item = ResponseInputItem::FunctionCallOutput {
3075            call_id: "call1".into(),
3076            output: FunctionCallOutputPayload::from_content_items(vec![
3077                FunctionCallOutputContentItem::EncryptedContent {
3078                    encrypted_content: "enc_opaque".into(),
3079                },
3080            ]),
3081        };
3082
3083        let json = serde_json::to_value(&item)?;
3084        assert_eq!(
3085            json,
3086            serde_json::json!({
3087                "type": "function_call_output",
3088                "call_id": "call1",
3089                "output": [
3090                    {
3091                        "type": "encrypted_content",
3092                        "encrypted_content": "enc_opaque",
3093                    }
3094                ],
3095            })
3096        );
3097
3098        Ok(())
3099    }
3100
3101    #[test]
3102    fn preserves_existing_image_data_urls() -> Result<()> {
3103        let call_tool_result = CallToolResult {
3104            content: vec![serde_json::json!({
3105                "type": "image",
3106                "data": "data:image/png;base64,BASE64",
3107                "mimeType": "image/png"
3108            })],
3109            structured_content: None,
3110            is_error: Some(false),
3111            meta: None,
3112        };
3113
3114        let payload = call_tool_result.into_function_call_output_payload();
3115        let Some(items) = payload.content_items() else {
3116            panic!("expected content items");
3117        };
3118        let items = items.to_vec();
3119        assert_eq!(
3120            items,
3121            vec![FunctionCallOutputContentItem::InputImage {
3122                image_url: "data:image/png;base64,BASE64".into(),
3123                detail: Some(DEFAULT_IMAGE_DETAIL),
3124            }]
3125        );
3126
3127        Ok(())
3128    }
3129
3130    #[test]
3131    fn preserves_original_detail_metadata_on_mcp_images() -> Result<()> {
3132        let call_tool_result = CallToolResult {
3133            content: vec![serde_json::json!({
3134                "type": "image",
3135                "data": "BASE64",
3136                "mimeType": "image/png",
3137                "_meta": {
3138                    "codex/imageDetail": "original",
3139                },
3140            })],
3141            structured_content: None,
3142            is_error: Some(false),
3143            meta: None,
3144        };
3145
3146        let payload = call_tool_result.into_function_call_output_payload();
3147        let Some(items) = payload.content_items() else {
3148            panic!("expected content items");
3149        };
3150        let items = items.to_vec();
3151        assert_eq!(
3152            items,
3153            vec![FunctionCallOutputContentItem::InputImage {
3154                image_url: "data:image/png;base64,BASE64".into(),
3155                detail: Some(ImageDetail::Original),
3156            }]
3157        );
3158
3159        Ok(())
3160    }
3161
3162    #[test]
3163    fn preserves_standard_detail_metadata_on_mcp_images() -> Result<()> {
3164        let call_tool_result = CallToolResult {
3165            content: vec![serde_json::json!({
3166                "type": "image",
3167                "data": "BASE64",
3168                "mimeType": "image/png",
3169                "_meta": {
3170                    "codex/imageDetail": "high",
3171                },
3172            })],
3173            structured_content: None,
3174            is_error: Some(false),
3175            meta: None,
3176        };
3177
3178        let payload = call_tool_result.into_function_call_output_payload();
3179        let Some(items) = payload.content_items() else {
3180            panic!("expected content items");
3181        };
3182        let items = items.to_vec();
3183        assert_eq!(
3184            items,
3185            vec![FunctionCallOutputContentItem::InputImage {
3186                image_url: "data:image/png;base64,BASE64".into(),
3187                detail: Some(ImageDetail::High),
3188            }]
3189        );
3190
3191        Ok(())
3192    }
3193
3194    #[test]
3195    fn deserializes_array_payload_into_items() -> Result<()> {
3196        let json = r#"[
3197            {"type": "input_text", "text": "note"},
3198            {"type": "input_image", "image_url": "data:image/png;base64,XYZ"}
3199        ]"#;
3200
3201        let payload: FunctionCallOutputPayload = serde_json::from_str(json)?;
3202
3203        assert_eq!(payload.success, None);
3204        let expected_items = vec![
3205            FunctionCallOutputContentItem::InputText {
3206                text: "note".into(),
3207            },
3208            FunctionCallOutputContentItem::InputImage {
3209                image_url: "data:image/png;base64,XYZ".into(),
3210                detail: None,
3211            },
3212        ];
3213        assert_eq!(
3214            payload.body,
3215            FunctionCallOutputBody::ContentItems(expected_items.clone())
3216        );
3217        assert_eq!(
3218            serde_json::to_string(&payload)?,
3219            serde_json::to_string(&expected_items)?
3220        );
3221
3222        Ok(())
3223    }
3224
3225    #[test]
3226    fn deserializes_encrypted_array_payload_into_items() -> Result<()> {
3227        let json = r#"[
3228            {"type": "encrypted_content", "encrypted_content": "enc_opaque"}
3229        ]"#;
3230
3231        let payload: FunctionCallOutputPayload = serde_json::from_str(json)?;
3232        let expected_items = vec![FunctionCallOutputContentItem::EncryptedContent {
3233            encrypted_content: "enc_opaque".into(),
3234        }];
3235
3236        assert_eq!(payload.success, None);
3237        assert_eq!(
3238            payload.body,
3239            FunctionCallOutputBody::ContentItems(expected_items.clone())
3240        );
3241        assert_eq!(
3242            serde_json::to_string(&payload)?,
3243            serde_json::to_string(&expected_items)?
3244        );
3245
3246        Ok(())
3247    }
3248
3249    #[test]
3250    fn deserializes_compaction_alias() -> Result<()> {
3251        let json = r#"{"type":"compaction_summary","encrypted_content":"abc"}"#;
3252
3253        let item: ResponseItem = serde_json::from_str(json)?;
3254
3255        assert_eq!(
3256            item,
3257            ResponseItem::Compaction {
3258                id: None,
3259                encrypted_content: "abc".into(),
3260                internal_chat_message_metadata_passthrough: None,
3261            }
3262        );
3263        Ok(())
3264    }
3265
3266    #[test]
3267    fn deserializes_context_compaction() -> Result<()> {
3268        let json = r#"{"type":"context_compaction","encrypted_content":"abc"}"#;
3269
3270        let item: ResponseItem = serde_json::from_str(json)?;
3271
3272        assert_eq!(
3273            item,
3274            ResponseItem::ContextCompaction {
3275                id: None,
3276                encrypted_content: Some("abc".into()),
3277                internal_chat_message_metadata_passthrough: None,
3278            }
3279        );
3280        Ok(())
3281    }
3282
3283    #[test]
3284    fn serializes_compaction_trigger_without_payload() -> Result<()> {
3285        let item = ResponseItem::CompactionTrigger {};
3286
3287        assert_eq!(
3288            serde_json::to_value(item)?,
3289            serde_json::json!({
3290                "type": "compaction_trigger",
3291            })
3292        );
3293        Ok(())
3294    }
3295
3296    #[test]
3297    fn deserializes_compaction_trigger_without_payload() -> Result<()> {
3298        let json = r#"{"type":"compaction_trigger"}"#;
3299
3300        let item: ResponseItem = serde_json::from_str(json)?;
3301
3302        assert_eq!(item, ResponseItem::CompactionTrigger {});
3303        Ok(())
3304    }
3305
3306    #[test]
3307    fn deserializes_legacy_ghost_snapshot_as_other() -> Result<()> {
3308        let json = r#"{
3309            "type":"ghost_snapshot",
3310            "ghost_commit":{
3311                "id":"ghost-1",
3312                "parent":null,
3313                "preexisting_untracked_files":[],
3314                "preexisting_untracked_dirs":[]
3315            }
3316        }"#;
3317
3318        let item: ResponseItem = serde_json::from_str(json)?;
3319
3320        assert_eq!(item, ResponseItem::Other);
3321        Ok(())
3322    }
3323
3324    #[test]
3325    fn roundtrips_web_search_call_actions() -> Result<()> {
3326        let cases = vec![
3327            (
3328                r#"{
3329                    "type": "web_search_call",
3330                    "status": "completed",
3331                    "action": {
3332                        "type": "search",
3333                        "query": "weather seattle",
3334                        "queries": ["weather seattle", "seattle weather now"]
3335                    }
3336                }"#,
3337                None,
3338                Some(WebSearchAction::Search {
3339                    query: Some("weather seattle".into()),
3340                    queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]),
3341                }),
3342                Some("completed".into()),
3343            ),
3344            (
3345                r#"{
3346                    "type": "web_search_call",
3347                    "status": "open",
3348                    "action": {
3349                        "type": "open_page",
3350                        "url": "https://example.com"
3351                    }
3352                }"#,
3353                None,
3354                Some(WebSearchAction::OpenPage {
3355                    url: Some("https://example.com".into()),
3356                }),
3357                Some("open".into()),
3358            ),
3359            (
3360                r#"{
3361                    "type": "web_search_call",
3362                    "status": "in_progress",
3363                    "action": {
3364                        "type": "find_in_page",
3365                        "url": "https://example.com/docs",
3366                        "pattern": "installation"
3367                    }
3368                }"#,
3369                None,
3370                Some(WebSearchAction::FindInPage {
3371                    url: Some("https://example.com/docs".into()),
3372                    pattern: Some("installation".into()),
3373                }),
3374                Some("in_progress".into()),
3375            ),
3376            (
3377                r#"{
3378                    "type": "web_search_call",
3379                    "status": "in_progress",
3380                    "id": "ws_partial"
3381                }"#,
3382                Some(ResponseItemId::with_suffix("ws", "partial")),
3383                None,
3384                Some("in_progress".into()),
3385            ),
3386        ];
3387
3388        for (json_literal, expected_id, expected_action, expected_status) in cases {
3389            let parsed: ResponseItem = serde_json::from_str(json_literal)?;
3390            let expected = ResponseItem::WebSearchCall {
3391                id: expected_id.clone(),
3392                status: expected_status.clone(),
3393                action: expected_action.clone(),
3394                internal_chat_message_metadata_passthrough: None,
3395            };
3396            assert_eq!(parsed, expected);
3397
3398            let serialized = serde_json::to_value(&parsed)?;
3399            let expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?;
3400            assert_eq!(serialized, expected_serialized);
3401        }
3402
3403        Ok(())
3404    }
3405
3406    #[test]
3407    fn serializes_image_user_input_without_tags() -> Result<()> {
3408        let image_url = "data:image/png;base64,abc".to_string();
3409
3410        let item = ResponseInputItem::from(vec![UserInput::Image {
3411            image_url: image_url.clone(),
3412            detail: None,
3413        }]);
3414
3415        match item {
3416            ResponseInputItem::Message { content, .. } => {
3417                let expected = vec![ContentItem::InputImage {
3418                    image_url,
3419                    detail: Some(DEFAULT_IMAGE_DETAIL),
3420                }];
3421                assert_eq!(content, expected);
3422            }
3423            other => panic!("expected message response but got {other:?}"),
3424        }
3425
3426        Ok(())
3427    }
3428
3429    #[test]
3430    fn serializes_audio_user_input_without_tags() -> Result<()> {
3431        let audio_url = "data:audio/wav;base64,abc".to_string();
3432
3433        let item = ResponseInputItem::from(vec![UserInput::Audio {
3434            audio_url: audio_url.clone(),
3435        }]);
3436
3437        assert_eq!(
3438            item,
3439            ResponseInputItem::Message {
3440                role: "user".to_string(),
3441                content: vec![ContentItem::InputAudio { audio_url }],
3442                phase: None,
3443            }
3444        );
3445        assert_eq!(
3446            serde_json::to_value(item)?,
3447            serde_json::json!({
3448                "type": "message",
3449                "role": "user",
3450                "content": [
3451                    {
3452                        "type": "input_audio",
3453                        "audio_url": "data:audio/wav;base64,abc",
3454                    },
3455                ],
3456            })
3457        );
3458
3459        Ok(())
3460    }
3461
3462    #[test]
3463    fn serializes_local_audio_user_input_with_label_and_data_url() -> Result<()> {
3464        let temp_dir = tempdir()?;
3465        for (extension, mime) in [
3466            ("wav", "audio/wav"),
3467            ("mp3", "audio/mpeg"),
3468            ("m4a", "audio/mp4"),
3469            ("webm", "audio/webm"),
3470            ("ogg", "audio/ogg"),
3471        ] {
3472            let audio_path = temp_dir.path().join(format!("sample.{extension}"));
3473            std::fs::write(&audio_path, b"audio")?;
3474
3475            let item = ResponseInputItem::from(vec![UserInput::LocalAudio {
3476                path: audio_path.clone(),
3477            }]);
3478
3479            assert_eq!(
3480                item,
3481                ResponseInputItem::Message {
3482                    role: "user".to_string(),
3483                    content: vec![
3484                        ContentItem::InputText {
3485                            text: local_audio_open_tag_text_with_path(
3486                                /*label_number*/ 1,
3487                                &audio_path,
3488                            ),
3489                        },
3490                        ContentItem::InputAudio {
3491                            audio_url: format!("data:{mime};base64,YXVkaW8="),
3492                        },
3493                        ContentItem::InputText {
3494                            text: audio_close_tag_text(),
3495                        },
3496                    ],
3497                    phase: None,
3498                }
3499            );
3500        }
3501
3502        Ok(())
3503    }
3504
3505    #[test]
3506    fn replaces_unsupported_local_audio_format_with_placeholder() -> Result<()> {
3507        let temp_dir = tempdir()?;
3508        let audio_path = temp_dir.path().join("sample.flac");
3509        std::fs::write(&audio_path, b"audio")?;
3510
3511        let item = ResponseInputItem::from(vec![UserInput::LocalAudio {
3512            path: audio_path.clone(),
3513        }]);
3514
3515        assert_eq!(
3516            item,
3517            ResponseInputItem::Message {
3518                role: "user".to_string(),
3519                content: vec![ContentItem::InputText {
3520                    text: format!(
3521                        "Codex cannot attach audio at `{}`: unsupported audio format; use wav, mp3, m4a, webm, or ogg.",
3522                        audio_path.display()
3523                    ),
3524                }],
3525                phase: None,
3526            }
3527        );
3528
3529        Ok(())
3530    }
3531
3532    #[test]
3533    fn replaces_unreadable_local_audio_with_placeholder() {
3534        let audio_path = PathBuf::from("missing.wav");
3535
3536        let item = ResponseInputItem::from(vec![UserInput::LocalAudio { path: audio_path }]);
3537
3538        let ResponseInputItem::Message { content, .. } = item else {
3539            panic!("expected message response");
3540        };
3541        let [ContentItem::InputText { text }] = content.as_slice() else {
3542            panic!("expected local audio error placeholder");
3543        };
3544        assert!(
3545            text.starts_with("Codex could not read the local audio at `missing.wav`: "),
3546            "unexpected placeholder: {text}"
3547        );
3548    }
3549
3550    #[test]
3551    fn image_user_input_preserves_requested_detail() -> Result<()> {
3552        let image_url = "data:image/png;base64,abc".to_string();
3553
3554        let item = ResponseInputItem::from(vec![UserInput::Image {
3555            image_url: image_url.clone(),
3556            detail: Some(ImageDetail::Original),
3557        }]);
3558
3559        match item {
3560            ResponseInputItem::Message { content, .. } => {
3561                assert_eq!(
3562                    content.first(),
3563                    Some(&ContentItem::InputImage {
3564                        image_url,
3565                        detail: Some(ImageDetail::Original),
3566                    })
3567                );
3568            }
3569            other => panic!("expected message response but got {other:?}"),
3570        }
3571
3572        Ok(())
3573    }
3574
3575    #[test]
3576    fn tool_search_call_roundtrips() -> Result<()> {
3577        let parsed: ResponseItem = serde_json::from_str(
3578            r#"{
3579                "type": "tool_search_call",
3580                "call_id": "search-1",
3581                "execution": "client",
3582                "arguments": {
3583                    "query": "calendar create",
3584                    "limit": 1
3585                }
3586            }"#,
3587        )?;
3588
3589        assert_eq!(
3590            parsed,
3591            ResponseItem::ToolSearchCall {
3592                id: None,
3593                call_id: Some("search-1".to_string()),
3594                status: None,
3595                execution: "client".to_string(),
3596                arguments: serde_json::json!({
3597                    "query": "calendar create",
3598                    "limit": 1,
3599                }),
3600                internal_chat_message_metadata_passthrough: None,
3601            }
3602        );
3603
3604        assert_eq!(
3605            serde_json::to_value(&parsed)?,
3606            serde_json::json!({
3607                "type": "tool_search_call",
3608                "call_id": "search-1",
3609                "execution": "client",
3610                "arguments": {
3611                    "query": "calendar create",
3612                    "limit": 1,
3613                }
3614            })
3615        );
3616
3617        Ok(())
3618    }
3619
3620    #[test]
3621    fn tool_search_output_roundtrips() -> Result<()> {
3622        let input = ResponseInputItem::ToolSearchOutput {
3623            call_id: "search-1".to_string(),
3624            status: "completed".to_string(),
3625            execution: "client".to_string(),
3626            tools: vec![serde_json::json!({
3627                "type": "function",
3628                "name": "mcp__codex_apps__calendar_create_event",
3629                "description": "Create a calendar event.",
3630                "defer_loading": true,
3631                "parameters": {
3632                    "type": "object",
3633                    "properties": {
3634                        "title": {"type": "string"}
3635                    },
3636                    "required": ["title"],
3637                    "additionalProperties": false,
3638                }
3639            })],
3640        };
3641        assert_eq!(
3642            ResponseItem::from(input.clone()),
3643            ResponseItem::ToolSearchOutput {
3644                id: None,
3645                call_id: Some("search-1".to_string()),
3646                status: "completed".to_string(),
3647                execution: "client".to_string(),
3648                tools: vec![serde_json::json!({
3649                    "type": "function",
3650                    "name": "mcp__codex_apps__calendar_create_event",
3651                    "description": "Create a calendar event.",
3652                    "defer_loading": true,
3653                    "parameters": {
3654                        "type": "object",
3655                        "properties": {
3656                            "title": {"type": "string"}
3657                        },
3658                        "required": ["title"],
3659                        "additionalProperties": false,
3660                    }
3661                })],
3662                internal_chat_message_metadata_passthrough: None,
3663            }
3664        );
3665
3666        assert_eq!(
3667            serde_json::to_value(input)?,
3668            serde_json::json!({
3669                "type": "tool_search_output",
3670                "call_id": "search-1",
3671                "status": "completed",
3672                "execution": "client",
3673                "tools": [{
3674                    "type": "function",
3675                    "name": "mcp__codex_apps__calendar_create_event",
3676                    "description": "Create a calendar event.",
3677                    "defer_loading": true,
3678                    "parameters": {
3679                        "type": "object",
3680                        "properties": {
3681                            "title": {"type": "string"}
3682                        },
3683                        "required": ["title"],
3684                        "additionalProperties": false,
3685                    }
3686                }]
3687            })
3688        );
3689
3690        Ok(())
3691    }
3692
3693    #[test]
3694    fn tool_search_server_items_allow_null_call_id() -> Result<()> {
3695        let parsed_call: ResponseItem = serde_json::from_str(
3696            r#"{
3697                "type": "tool_search_call",
3698                "execution": "server",
3699                "call_id": null,
3700                "status": "completed",
3701                "arguments": {
3702                    "paths": ["crm"]
3703                }
3704            }"#,
3705        )?;
3706        assert_eq!(
3707            parsed_call,
3708            ResponseItem::ToolSearchCall {
3709                id: None,
3710                call_id: None,
3711                status: Some("completed".to_string()),
3712                execution: "server".to_string(),
3713                arguments: serde_json::json!({
3714                    "paths": ["crm"],
3715                }),
3716                internal_chat_message_metadata_passthrough: None,
3717            }
3718        );
3719
3720        let parsed_output: ResponseItem = serde_json::from_str(
3721            r#"{
3722                "type": "tool_search_output",
3723                "execution": "server",
3724                "call_id": null,
3725                "status": "completed",
3726                "tools": []
3727            }"#,
3728        )?;
3729        assert_eq!(
3730            parsed_output,
3731            ResponseItem::ToolSearchOutput {
3732                id: None,
3733                call_id: None,
3734                status: "completed".to_string(),
3735                execution: "server".to_string(),
3736                tools: vec![],
3737                internal_chat_message_metadata_passthrough: None,
3738            }
3739        );
3740
3741        Ok(())
3742    }
3743
3744    #[test]
3745    fn mixed_remote_and_local_images_share_label_sequence() -> Result<()> {
3746        let image_url = "data:image/png;base64,abc".to_string();
3747        let dir = tempdir()?;
3748        let local_path = dir.path().join("local.png");
3749        std::fs::write(&local_path, TINY_PNG_BYTES)?;
3750
3751        let item = ResponseInputItem::from(vec![
3752            UserInput::Image {
3753                image_url: image_url.clone(),
3754                detail: None,
3755            },
3756            UserInput::LocalImage {
3757                path: local_path.clone(),
3758                detail: None,
3759            },
3760        ]);
3761
3762        match item {
3763            ResponseInputItem::Message { content, .. } => {
3764                assert_eq!(
3765                    content.first(),
3766                    Some(&ContentItem::InputImage {
3767                        image_url,
3768                        detail: Some(DEFAULT_IMAGE_DETAIL),
3769                    })
3770                );
3771                assert_eq!(
3772                    content.get(1),
3773                    Some(&ContentItem::InputText {
3774                        text: local_image_open_tag_text_with_path(
3775                            /*label_number*/ 2,
3776                            &local_path
3777                        ),
3778                    })
3779                );
3780                assert!(matches!(
3781                    content.get(2),
3782                    Some(ContentItem::InputImage { .. })
3783                ));
3784                assert_eq!(
3785                    content.get(3),
3786                    Some(&ContentItem::InputText {
3787                        text: image_close_tag_text(),
3788                    })
3789                );
3790            }
3791            other => panic!("expected message response but got {other:?}"),
3792        }
3793
3794        Ok(())
3795    }
3796
3797    #[test]
3798    fn mixed_remote_and_local_audio_share_label_sequence() -> Result<()> {
3799        let audio_url = "data:audio/wav;base64,abc".to_string();
3800        let dir = tempdir()?;
3801        let local_path = dir.path().join("local.mp3");
3802        std::fs::write(&local_path, b"audio")?;
3803
3804        let item = ResponseInputItem::from(vec![
3805            UserInput::Audio {
3806                audio_url: audio_url.clone(),
3807            },
3808            UserInput::LocalAudio {
3809                path: local_path.clone(),
3810            },
3811        ]);
3812
3813        assert_eq!(
3814            item,
3815            ResponseInputItem::Message {
3816                role: "user".to_string(),
3817                content: vec![
3818                    ContentItem::InputAudio { audio_url },
3819                    ContentItem::InputText {
3820                        text: local_audio_open_tag_text_with_path(
3821                            /*label_number*/ 2,
3822                            &local_path,
3823                        ),
3824                    },
3825                    ContentItem::InputAudio {
3826                        audio_url: "data:audio/mpeg;base64,YXVkaW8=".to_string(),
3827                    },
3828                    ContentItem::InputText {
3829                        text: audio_close_tag_text(),
3830                    },
3831                ],
3832                phase: None,
3833            }
3834        );
3835
3836        Ok(())
3837    }
3838
3839    #[test]
3840    fn local_image_open_tag_preserves_path() {
3841        assert_eq!(
3842            local_image_open_tag_text_with_path(
3843                /*label_number*/ 1,
3844                std::path::Path::new(r#"/tmp/a&"<b>.png"#),
3845            ),
3846            r#"<image name=[Image #1] path="/tmp/a&"<b>.png">"#
3847        );
3848    }
3849
3850    #[test]
3851    fn local_image_user_input_preserves_requested_detail() -> Result<()> {
3852        let dir = tempdir()?;
3853        let local_path = dir.path().join("local.png");
3854        std::fs::write(&local_path, TINY_PNG_BYTES)?;
3855
3856        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
3857            path: local_path,
3858            detail: Some(ImageDetail::Original),
3859        }]);
3860
3861        match item {
3862            ResponseInputItem::Message { content, .. } => {
3863                assert!(matches!(
3864                    content.get(1),
3865                    Some(ContentItem::InputImage {
3866                        detail: Some(ImageDetail::Original),
3867                        ..
3868                    })
3869                ));
3870            }
3871            other => panic!("expected message response but got {other:?}"),
3872        }
3873
3874        Ok(())
3875    }
3876
3877    #[test]
3878    fn local_image_read_error_adds_placeholder() -> Result<()> {
3879        let dir = tempdir()?;
3880        let missing_path = dir.path().join("missing-image.png");
3881
3882        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
3883            path: missing_path.clone(),
3884            detail: None,
3885        }]);
3886
3887        match item {
3888            ResponseInputItem::Message { content, .. } => {
3889                assert_eq!(content.len(), 1);
3890                match &content[0] {
3891                    ContentItem::InputText { text } => {
3892                        let display_path = missing_path.display().to_string();
3893                        assert!(
3894                            text.contains(&display_path),
3895                            "placeholder should mention missing path: {text}"
3896                        );
3897                        assert!(
3898                            text.contains("could not read"),
3899                            "placeholder should mention read issue: {text}"
3900                        );
3901                    }
3902                    other => panic!("expected placeholder text but found {other:?}"),
3903                }
3904            }
3905            other => panic!("expected message response but got {other:?}"),
3906        }
3907
3908        Ok(())
3909    }
3910
3911    #[test]
3912    fn local_image_non_image_adds_placeholder() -> Result<()> {
3913        let dir = tempdir()?;
3914        let json_path = dir.path().join("example.json");
3915        std::fs::write(&json_path, br#"{"hello":"world"}"#)?;
3916
3917        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
3918            path: json_path.clone(),
3919            detail: None,
3920        }]);
3921
3922        match item {
3923            ResponseInputItem::Message { content, .. } => {
3924                assert_eq!(content.len(), 1);
3925                match &content[0] {
3926                    ContentItem::InputText { text } => {
3927                        assert!(
3928                            text.contains("unsupported image `application/json`"),
3929                            "placeholder should mention unsupported image MIME: {text}"
3930                        );
3931                        assert!(
3932                            text.contains(&json_path.display().to_string()),
3933                            "placeholder should mention path: {text}"
3934                        );
3935                    }
3936                    other => panic!("expected placeholder text but found {other:?}"),
3937                }
3938            }
3939            other => panic!("expected message response but got {other:?}"),
3940        }
3941
3942        Ok(())
3943    }
3944
3945    #[test]
3946    fn local_image_unsupported_image_format_adds_placeholder() -> Result<()> {
3947        let dir = tempdir()?;
3948        let svg_path = dir.path().join("example.svg");
3949        std::fs::write(
3950            &svg_path,
3951            br#"<?xml version="1.0" encoding="UTF-8"?>
3952<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#,
3953        )?;
3954
3955        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
3956            path: svg_path.clone(),
3957            detail: None,
3958        }]);
3959
3960        match item {
3961            ResponseInputItem::Message { content, .. } => {
3962                assert_eq!(content.len(), 1);
3963                let expected = format!(
3964                    "Codex cannot attach image at `{}`: unsupported image `image/svg+xml`.",
3965                    svg_path.display()
3966                );
3967                match &content[0] {
3968                    ContentItem::InputText { text } => assert_eq!(text, &expected),
3969                    other => panic!("expected placeholder text but found {other:?}"),
3970                }
3971            }
3972            other => panic!("expected message response but got {other:?}"),
3973        }
3974
3975        Ok(())
3976    }
3977}