Skip to main content

hanzo_protocol/
models.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::path::PathBuf;
4
5use hanzo_utils_image::load_and_resize_to_fit;
6use serde::Deserialize;
7use serde::Deserializer;
8use serde::Serialize;
9use serde::ser::Serializer;
10use ts_rs::TS;
11
12use crate::config_types::CollaborationMode;
13use crate::config_types::SandboxMode;
14use crate::protocol::AskForApproval;
15use crate::protocol::COLLABORATION_MODE_CLOSE_TAG;
16use crate::protocol::COLLABORATION_MODE_OPEN_TAG;
17use crate::protocol::NetworkAccess;
18use crate::protocol::SandboxPolicy;
19use crate::protocol::WritableRoot;
20use crate::user_input::UserInput;
21use hanzo_execpolicy::Policy;
22use hanzo_git_tooling::GhostCommit;
23use hanzo_utils_image::error::ImageProcessingError;
24use schemars::JsonSchema;
25
26use crate::mcp::CallToolResult;
27
28/// Controls whether a command should use the session sandbox or bypass it.
29#[derive(
30    Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
31)]
32#[serde(rename_all = "snake_case")]
33pub enum SandboxPermissions {
34    /// Run with the configured sandbox
35    #[default]
36    UseDefault,
37    /// Request to run outside the sandbox
38    RequireEscalated,
39    /// Request additional sandbox permissions for this command.
40    WithAdditionalPermissions,
41}
42
43impl SandboxPermissions {
44    pub fn requires_escalated_permissions(self) -> bool {
45        matches!(self, SandboxPermissions::RequireEscalated)
46    }
47
48    pub fn requests_sandbox_override(self) -> bool {
49        !matches!(self, SandboxPermissions::UseDefault)
50    }
51
52    pub fn uses_additional_permissions(self) -> bool {
53        matches!(self, SandboxPermissions::WithAdditionalPermissions)
54    }
55
56    pub fn requires_additional_permissions(self) -> bool {
57        self.requests_sandbox_override()
58    }
59}
60
61#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
62pub struct FileSystemPermissions {
63    pub read: Option<Vec<PathBuf>>,
64    pub write: Option<Vec<PathBuf>>,
65}
66
67impl FileSystemPermissions {
68    pub fn is_empty(&self) -> bool {
69        self.read.is_none() && self.write.is_none()
70    }
71}
72
73#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
74pub struct MacOsPermissions {
75    pub preferences: Option<MacOsPreferencesValue>,
76    pub automations: Option<MacOsAutomationValue>,
77    pub accessibility: Option<bool>,
78    pub calendar: Option<bool>,
79}
80
81impl MacOsPermissions {
82    pub fn is_empty(&self) -> bool {
83        self.preferences.is_none()
84            && self.automations.is_none()
85            && self.accessibility.is_none()
86            && self.calendar.is_none()
87    }
88}
89
90#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
91#[serde(untagged)]
92pub enum MacOsPreferencesValue {
93    Bool(bool),
94    Mode(String),
95}
96
97#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, JsonSchema, TS)]
98#[serde(untagged)]
99pub enum MacOsAutomationValue {
100    Bool(bool),
101    BundleIds(Vec<String>),
102}
103
104#[derive(Debug, Deserialize)]
105#[serde(untagged)]
106enum MacOsAutomationValueDe {
107    Bool(bool),
108    BundleIds(Vec<String>),
109    BundleIdsObject { bundle_ids: Vec<String> },
110}
111
112impl<'de> Deserialize<'de> for MacOsAutomationValue {
113    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114    where
115        D: Deserializer<'de>,
116    {
117        let value = MacOsAutomationValueDe::deserialize(deserializer)?;
118        Ok(match value {
119            MacOsAutomationValueDe::Bool(value) => Self::Bool(value),
120            MacOsAutomationValueDe::BundleIds(bundle_ids) => Self::BundleIds(bundle_ids),
121            MacOsAutomationValueDe::BundleIdsObject { bundle_ids } => Self::BundleIds(bundle_ids),
122        })
123    }
124}
125
126#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
127pub struct NetworkPermissions {
128    pub enabled: Option<bool>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(untagged)]
133enum NetworkPermissionValue {
134    Bool(bool),
135    Object(NetworkPermissions),
136}
137
138#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
139pub struct PermissionProfile {
140    #[serde(
141        default,
142        skip_serializing_if = "Option::is_none",
143        serialize_with = "serialize_network_permission",
144        deserialize_with = "deserialize_network_permission"
145    )]
146    #[schemars(with = "Option<NetworkPermissions>")]
147    pub network: Option<bool>,
148    pub file_system: Option<FileSystemPermissions>,
149    pub macos: Option<MacOsPermissions>,
150}
151
152impl PermissionProfile {
153    pub fn is_empty(&self) -> bool {
154        self.network.is_none()
155            && self
156                .file_system
157                .as_ref()
158                .map(FileSystemPermissions::is_empty)
159                .unwrap_or(true)
160            && self
161                .macos
162                .as_ref()
163                .map(MacOsPermissions::is_empty)
164                .unwrap_or(true)
165    }
166}
167
168fn serialize_network_permission<S>(
169    network: &Option<bool>,
170    serializer: S,
171) -> Result<S::Ok, S::Error>
172where
173    S: Serializer,
174{
175    match network {
176        Some(enabled) => NetworkPermissions {
177            enabled: Some(*enabled),
178        }
179        .serialize(serializer),
180        None => serializer.serialize_none(),
181    }
182}
183
184fn deserialize_network_permission<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
185where
186    D: Deserializer<'de>,
187{
188    let value = Option::<NetworkPermissionValue>::deserialize(deserializer)?;
189    Ok(value.map(|value| match value {
190        NetworkPermissionValue::Bool(enabled) => enabled,
191        NetworkPermissionValue::Object(NetworkPermissions { enabled }) => enabled.unwrap_or(false),
192    }))
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
196#[serde(tag = "type", rename_all = "snake_case")]
197pub enum ResponseInputItem {
198    Message {
199        role: String,
200        content: Vec<ContentItem>,
201    },
202    FunctionCallOutput {
203        call_id: String,
204        output: FunctionCallOutputPayload,
205    },
206    McpToolCallOutput {
207        call_id: String,
208        result: Result<CallToolResult, String>,
209    },
210    CustomToolCallOutput {
211        call_id: String,
212        output: FunctionCallOutputPayload,
213    },
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
217#[serde(tag = "type", rename_all = "snake_case")]
218pub enum ContentItem {
219    InputText { text: String },
220    InputImage { image_url: String },
221    OutputText { text: String },
222}
223
224#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
225#[serde(rename_all = "lowercase")]
226pub enum ImageDetail {
227    Auto,
228    Low,
229    High,
230    Original,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
234#[serde(rename_all = "snake_case")]
235/// Classifies an assistant message as interim commentary or final answer text.
236///
237/// Providers do not emit this consistently, so callers must treat `None` as
238/// "phase unknown" and keep compatibility behavior for legacy models.
239pub enum MessagePhase {
240    /// Mid-turn assistant text (for example preamble/progress narration).
241    ///
242    /// Additional tool calls or assistant output may follow before turn
243    /// completion.
244    Commentary,
245    /// The assistant's terminal answer text for the current turn.
246    FinalAnswer,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
250#[serde(tag = "type", rename_all = "snake_case")]
251pub enum ResponseItem {
252    Message {
253        #[serde(default, skip_serializing)]
254        #[ts(skip)]
255        id: Option<String>,
256        role: String,
257        content: Vec<ContentItem>,
258        // Do not use directly, no available consistently across all providers.
259        #[serde(default, skip_serializing_if = "Option::is_none")]
260        #[ts(optional)]
261        end_turn: Option<bool>,
262        // Optional output-message phase (for example: "commentary", "final_answer").
263        // Availability varies by provider/model, so downstream consumers must
264        // preserve fallback behavior when this is absent.
265        #[serde(default, skip_serializing_if = "Option::is_none")]
266        #[ts(optional)]
267        phase: Option<MessagePhase>,
268    },
269    Reasoning {
270        #[serde(default, skip_serializing)]
271        #[ts(skip)]
272        id: String,
273        summary: Vec<ReasoningItemReasoningSummary>,
274        #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
275        #[ts(optional)]
276        content: Option<Vec<ReasoningItemContent>>,
277        encrypted_content: Option<String>,
278    },
279    LocalShellCall {
280        /// Legacy id field retained for compatibility with older payloads.
281        #[serde(default, skip_serializing)]
282        #[ts(skip)]
283        id: Option<String>,
284        /// Set when using the Responses API.
285        call_id: Option<String>,
286        status: LocalShellStatus,
287        action: LocalShellAction,
288    },
289    FunctionCall {
290        #[serde(default, skip_serializing)]
291        #[ts(skip)]
292        id: Option<String>,
293        name: String,
294        // The Responses API returns the function call arguments as a *string* that contains
295        // JSON, not as an already‑parsed object. We keep it as a raw string here and let
296        // Session::handle_function_call parse it into a Value.
297        arguments: String,
298        call_id: String,
299    },
300    // NOTE: The `output` field for `function_call_output` uses a dedicated payload type with
301    // custom serialization. On the wire it is either:
302    //   - a plain string (`content`)
303    //   - an array of structured content items (`content_items`)
304    // We keep this behavior centralized in `FunctionCallOutputPayload`.
305    FunctionCallOutput {
306        call_id: String,
307        output: FunctionCallOutputPayload,
308    },
309    CustomToolCall {
310        #[serde(default, skip_serializing)]
311        #[ts(skip)]
312        id: Option<String>,
313        #[serde(default, skip_serializing_if = "Option::is_none")]
314        #[ts(optional)]
315        status: Option<String>,
316
317        call_id: String,
318        name: String,
319        input: String,
320    },
321    CustomToolCallOutput {
322        call_id: String,
323        output: FunctionCallOutputPayload,
324    },
325    // Emitted by the Responses API when the agent triggers a web search.
326    // Example payload (from SSE `response.output_item.done`):
327    // {
328    //   "id":"ws_...",
329    //   "type":"web_search_call",
330    //   "status":"completed",
331    //   "action": {"type":"search","query":"weather: San Francisco, CA"}
332    // }
333    WebSearchCall {
334        #[serde(default, skip_serializing)]
335        #[ts(skip)]
336        id: Option<String>,
337        #[serde(default, skip_serializing_if = "Option::is_none")]
338        #[ts(optional)]
339        status: Option<String>,
340        #[serde(default, skip_serializing_if = "Option::is_none")]
341        #[ts(optional)]
342        action: Option<WebSearchAction>,
343    },
344    // Emitted by the Responses API when the agent triggers image generation.
345    ImageGenerationCall {
346        id: String,
347        status: String,
348        #[serde(default, skip_serializing_if = "Option::is_none")]
349        #[ts(optional)]
350        revised_prompt: Option<String>,
351        result: String,
352    },
353    // Generated by the harness but considered exactly as a model response.
354    GhostSnapshot {
355        ghost_commit: GhostCommit,
356    },
357    #[serde(alias = "compaction")]
358    CompactionSummary {
359        encrypted_content: String,
360    },
361    #[serde(other)]
362    Other,
363}
364
365pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md");
366
367/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI.
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
369#[serde(rename = "base_instructions", rename_all = "snake_case")]
370pub struct BaseInstructions {
371    pub text: String,
372}
373
374impl Default for BaseInstructions {
375    fn default() -> Self {
376        Self {
377            text: BASE_INSTRUCTIONS_DEFAULT.to_string(),
378        }
379    }
380}
381
382/// Developer-provided guidance that is injected into a turn as a developer role
383/// message.
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
385#[serde(rename = "developer_instructions", rename_all = "snake_case")]
386pub struct DeveloperInstructions {
387    text: String,
388}
389
390const APPROVAL_POLICY_NEVER: &str = include_str!("prompts/permissions/approval_policy/never.md");
391const APPROVAL_POLICY_UNLESS_TRUSTED: &str =
392    include_str!("prompts/permissions/approval_policy/unless_trusted.md");
393const APPROVAL_POLICY_ON_FAILURE: &str =
394    include_str!("prompts/permissions/approval_policy/on_failure.md");
395const APPROVAL_POLICY_ON_REQUEST_RULE: &str =
396    include_str!("prompts/permissions/approval_policy/on_request_rule.md");
397const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str =
398    include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md");
399
400const SANDBOX_MODE_DANGER_FULL_ACCESS: &str =
401    include_str!("prompts/permissions/sandbox_mode/danger_full_access.md");
402const SANDBOX_MODE_WORKSPACE_WRITE: &str =
403    include_str!("prompts/permissions/sandbox_mode/workspace_write.md");
404const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_mode/read_only.md");
405
406impl DeveloperInstructions {
407    pub fn new<T: Into<String>>(text: T) -> Self {
408        Self { text: text.into() }
409    }
410
411    pub fn from(
412        approval_policy: AskForApproval,
413        exec_policy: &Policy,
414        request_permission_enabled: bool,
415    ) -> DeveloperInstructions {
416        let on_request_instructions = || {
417            let on_request_rule = if request_permission_enabled {
418                APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION
419            } else {
420                APPROVAL_POLICY_ON_REQUEST_RULE
421            };
422            let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes());
423            match command_prefixes {
424                Some(prefixes) => {
425                    format!(
426                        "{on_request_rule}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
427                    )
428                }
429                None => on_request_rule.to_string(),
430            }
431        };
432        let text = match approval_policy {
433            AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(),
434            AskForApproval::UnlessTrusted => APPROVAL_POLICY_UNLESS_TRUSTED.to_string(),
435            AskForApproval::OnFailure => APPROVAL_POLICY_ON_FAILURE.to_string(),
436            AskForApproval::OnRequest => on_request_instructions(),
437            AskForApproval::Reject(reject_config) => {
438                let on_request_instructions = on_request_instructions();
439                let sandbox_approval = reject_config.sandbox_approval;
440                let rules = reject_config.rules;
441                let mcp_elicitations = reject_config.mcp_elicitations;
442                format!(
443                    "{on_request_instructions}\n\n\
444                     Approval policy is `reject`.\n\
445                     - `sandbox_approval`: {sandbox_approval}\n\
446                     - `rules`: {rules}\n\
447                     - `mcp_elicitations`: {mcp_elicitations}\n\
448                     When a category is `true`, requests in that category are auto-rejected instead of prompting the user."
449                )
450            }
451        };
452
453        DeveloperInstructions::new(text)
454    }
455
456    pub fn into_text(self) -> String {
457        self.text
458    }
459
460    pub fn concat(self, other: impl Into<DeveloperInstructions>) -> Self {
461        let mut text = self.text;
462        if !text.ends_with('\n') {
463            text.push('\n');
464        }
465        text.push_str(&other.into().text);
466        Self { text }
467    }
468
469    pub fn model_switch_message(model_instructions: String) -> Self {
470        DeveloperInstructions::new(format!(
471            "<model_switch>\nThe user was previously using a different model. Please continue the conversation according to the following instructions:\n\n{model_instructions}\n</model_switch>"
472        ))
473    }
474
475    pub fn personality_spec_message(spec: String) -> Self {
476        let message = format!(
477            "<personality_spec> The user has requested a new communication style. Future messages should adhere to the following personality: \n{spec} </personality_spec>"
478        );
479        DeveloperInstructions::new(message)
480    }
481
482    pub fn from_policy(
483        sandbox_policy: &SandboxPolicy,
484        approval_policy: AskForApproval,
485        exec_policy: &Policy,
486        cwd: &Path,
487        request_permission_enabled: bool,
488    ) -> Self {
489        let network_access = if sandbox_policy.has_full_network_access() {
490            NetworkAccess::Enabled
491        } else {
492            NetworkAccess::Restricted
493        };
494
495        let (sandbox_mode, writable_roots) = match sandbox_policy {
496            SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None),
497            SandboxPolicy::ReadOnly => (SandboxMode::ReadOnly, None),
498            SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None),
499            SandboxPolicy::WorkspaceWrite { .. } => {
500                let roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
501                (SandboxMode::WorkspaceWrite, Some(roots))
502            }
503        };
504
505        DeveloperInstructions::from_permissions_with_network(
506            sandbox_mode,
507            network_access,
508            approval_policy,
509            exec_policy,
510            writable_roots,
511            request_permission_enabled,
512        )
513    }
514
515    /// Returns developer instructions from a collaboration mode if they exist and are non-empty.
516    pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option<Self> {
517        collaboration_mode
518            .settings
519            .developer_instructions
520            .as_ref()
521            .filter(|instructions| !instructions.is_empty())
522            .map(|instructions| {
523                DeveloperInstructions::new(format!(
524                    "{COLLABORATION_MODE_OPEN_TAG}{instructions}{COLLABORATION_MODE_CLOSE_TAG}"
525                ))
526            })
527    }
528
529    fn from_permissions_with_network(
530        sandbox_mode: SandboxMode,
531        network_access: NetworkAccess,
532        approval_policy: AskForApproval,
533        exec_policy: &Policy,
534        writable_roots: Option<Vec<WritableRoot>>,
535        request_permission_enabled: bool,
536    ) -> Self {
537        let start_tag = DeveloperInstructions::new("<permissions instructions>");
538        let end_tag = DeveloperInstructions::new("</permissions instructions>");
539        start_tag
540            .concat(DeveloperInstructions::sandbox_text(
541                sandbox_mode,
542                network_access,
543            ))
544            .concat(DeveloperInstructions::from(
545                approval_policy,
546                exec_policy,
547                request_permission_enabled,
548            ))
549            .concat(DeveloperInstructions::from_writable_roots(writable_roots))
550            .concat(end_tag)
551    }
552
553    fn from_writable_roots(writable_roots: Option<Vec<WritableRoot>>) -> Self {
554        let Some(roots) = writable_roots else {
555            return DeveloperInstructions::new("");
556        };
557
558        if roots.is_empty() {
559            return DeveloperInstructions::new("");
560        }
561
562        let roots_list: Vec<String> = roots
563            .iter()
564            .map(|r| format!("`{}`", r.root.to_string_lossy()))
565            .collect();
566        let text = if roots_list.len() == 1 {
567            format!(" The writable root is {}.", roots_list[0])
568        } else {
569            format!(" The writable roots are {}.", roots_list.join(", "))
570        };
571        DeveloperInstructions::new(text)
572    }
573
574    fn sandbox_text(mode: SandboxMode, network_access: NetworkAccess) -> DeveloperInstructions {
575        let template = match mode {
576            SandboxMode::DangerFullAccess => SANDBOX_MODE_DANGER_FULL_ACCESS.trim_end(),
577            SandboxMode::WorkspaceWrite => SANDBOX_MODE_WORKSPACE_WRITE.trim_end(),
578            SandboxMode::ReadOnly => SANDBOX_MODE_READ_ONLY.trim_end(),
579        };
580        let text = template.replace("{network_access}", &network_access.to_string());
581
582        DeveloperInstructions::new(text)
583    }
584}
585
586const MAX_RENDERED_PREFIXES: usize = 100;
587const MAX_ALLOW_PREFIX_TEXT_BYTES: usize = 5000;
588const TRUNCATED_MARKER: &str = "...\n[Some commands were truncated]";
589
590pub fn format_allow_prefixes(prefixes: Vec<Vec<String>>) -> Option<String> {
591    let mut truncated = false;
592    if prefixes.len() > MAX_RENDERED_PREFIXES {
593        truncated = true;
594    }
595
596    let mut prefixes = prefixes;
597    prefixes.sort_by(|a, b| {
598        a.len()
599            .cmp(&b.len())
600            .then_with(|| prefix_combined_str_len(a).cmp(&prefix_combined_str_len(b)))
601            .then_with(|| a.cmp(b))
602    });
603
604    let full_text = prefixes
605        .into_iter()
606        .take(MAX_RENDERED_PREFIXES)
607        .map(|prefix| format!("- {}", render_command_prefix(&prefix)))
608        .collect::<Vec<_>>()
609        .join("\n");
610
611    // truncate to last UTF8 char
612    let mut output = full_text;
613    let byte_idx = output
614        .char_indices()
615        .nth(MAX_ALLOW_PREFIX_TEXT_BYTES)
616        .map(|(i, _)| i);
617    if let Some(byte_idx) = byte_idx {
618        truncated = true;
619        output = output[..byte_idx].to_string();
620    }
621
622    if truncated {
623        Some(format!("{output}{TRUNCATED_MARKER}"))
624    } else {
625        Some(output)
626    }
627}
628
629fn prefix_combined_str_len(prefix: &[String]) -> usize {
630    prefix.iter().map(String::len).sum()
631}
632
633fn render_command_prefix(prefix: &[String]) -> String {
634    let tokens = prefix
635        .iter()
636        .map(|token| serde_json::to_string(token).unwrap_or_else(|_| format!("{token:?}")))
637        .collect::<Vec<_>>()
638        .join(", ");
639    format!("[{tokens}]")
640}
641
642impl From<DeveloperInstructions> for ResponseItem {
643    fn from(di: DeveloperInstructions) -> Self {
644        ResponseItem::Message {
645            id: None,
646            role: "developer".to_string(),
647            content: vec![ContentItem::InputText {
648                text: di.into_text(),
649            }],
650            end_turn: None,
651            phase: None,
652        }
653    }
654}
655
656impl From<SandboxMode> for DeveloperInstructions {
657    fn from(mode: SandboxMode) -> Self {
658        let network_access = match mode {
659            SandboxMode::DangerFullAccess => NetworkAccess::Enabled,
660            SandboxMode::WorkspaceWrite | SandboxMode::ReadOnly => NetworkAccess::Restricted,
661        };
662
663        DeveloperInstructions::sandbox_text(mode, network_access)
664    }
665}
666
667fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>>) -> bool {
668    match content {
669        Some(content) => !content
670            .iter()
671            .any(|c| matches!(c, ReasoningItemContent::ReasoningText { .. })),
672        None => false,
673    }
674}
675
676fn local_image_error_placeholder(
677    path: &std::path::Path,
678    error: impl std::fmt::Display,
679) -> ContentItem {
680    ContentItem::InputText {
681        text: format!(
682            "Codex could not read the local image at `{}`: {}",
683            path.display(),
684            error
685        ),
686    }
687}
688
689pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
690
691const IMAGE_OPEN_TAG: &str = "<image>";
692const IMAGE_CLOSE_TAG: &str = "</image>";
693const LOCAL_IMAGE_OPEN_TAG_PREFIX: &str = "<image name=";
694const LOCAL_IMAGE_OPEN_TAG_SUFFIX: &str = ">";
695const LOCAL_IMAGE_CLOSE_TAG: &str = IMAGE_CLOSE_TAG;
696
697pub fn image_open_tag_text() -> String {
698    IMAGE_OPEN_TAG.to_string()
699}
700
701pub fn image_close_tag_text() -> String {
702    IMAGE_CLOSE_TAG.to_string()
703}
704
705pub fn local_image_label_text(label_number: usize) -> String {
706    format!("[Image #{label_number}]")
707}
708
709pub fn local_image_open_tag_text(label_number: usize) -> String {
710    let label = local_image_label_text(label_number);
711    format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label}{LOCAL_IMAGE_OPEN_TAG_SUFFIX}")
712}
713
714pub fn is_local_image_open_tag_text(text: &str) -> bool {
715    text.strip_prefix(LOCAL_IMAGE_OPEN_TAG_PREFIX)
716        .is_some_and(|rest| rest.ends_with(LOCAL_IMAGE_OPEN_TAG_SUFFIX))
717}
718
719pub fn is_local_image_close_tag_text(text: &str) -> bool {
720    is_image_close_tag_text(text)
721}
722
723pub fn is_image_open_tag_text(text: &str) -> bool {
724    text == IMAGE_OPEN_TAG
725}
726
727pub fn is_image_close_tag_text(text: &str) -> bool {
728    text == IMAGE_CLOSE_TAG
729}
730
731fn invalid_image_error_placeholder(
732    path: &std::path::Path,
733    error: impl std::fmt::Display,
734) -> ContentItem {
735    ContentItem::InputText {
736        text: format!(
737            "Image located at `{}` is invalid: {}",
738            path.display(),
739            error
740        ),
741    }
742}
743
744fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem {
745    ContentItem::InputText {
746        text: format!(
747            "Codex cannot attach image at `{}`: unsupported image format `{}`.",
748            path.display(),
749            mime
750        ),
751    }
752}
753
754pub fn local_image_content_items_with_label_number(
755    path: &std::path::Path,
756    label_number: Option<usize>,
757) -> Vec<ContentItem> {
758    match load_and_resize_to_fit(path) {
759        Ok(image) => {
760            let mut items = Vec::with_capacity(3);
761            if let Some(label_number) = label_number {
762                items.push(ContentItem::InputText {
763                    text: local_image_open_tag_text(label_number),
764                });
765            }
766            items.push(ContentItem::InputImage {
767                image_url: image.into_data_url(),
768            });
769            if label_number.is_some() {
770                items.push(ContentItem::InputText {
771                    text: LOCAL_IMAGE_CLOSE_TAG.to_string(),
772                });
773            }
774            items
775        }
776        Err(err) => {
777            if matches!(&err, ImageProcessingError::Read { .. }) {
778                vec![local_image_error_placeholder(path, &err)]
779            } else if err.is_invalid_image() {
780                vec![invalid_image_error_placeholder(path, &err)]
781            } else {
782                let Some(mime_guess) = mime_guess::from_path(path).first() else {
783                    return vec![local_image_error_placeholder(
784                        path,
785                        "unsupported MIME type (unknown)",
786                    )];
787                };
788                let mime = mime_guess.essence_str().to_owned();
789                if !mime.starts_with("image/") {
790                    return vec![local_image_error_placeholder(
791                        path,
792                        format!("unsupported MIME type `{mime}`"),
793                    )];
794                }
795                vec![unsupported_image_error_placeholder(path, &mime)]
796            }
797        }
798    }
799}
800
801impl From<ResponseInputItem> for ResponseItem {
802    fn from(item: ResponseInputItem) -> Self {
803        match item {
804            ResponseInputItem::Message { role, content } => Self::Message {
805                role,
806                content,
807                id: None,
808                end_turn: None,
809                phase: None,
810            },
811            ResponseInputItem::FunctionCallOutput { call_id, output } => {
812                Self::FunctionCallOutput { call_id, output }
813            }
814            ResponseInputItem::McpToolCallOutput { call_id, result } => {
815                let output = match result {
816                    Ok(result) => FunctionCallOutputPayload::from(&result),
817                    Err(tool_call_err) => FunctionCallOutputPayload {
818                        body: FunctionCallOutputBody::Text(format!("err: {tool_call_err:?}")),
819                        success: Some(false),
820                    },
821                };
822                Self::FunctionCallOutput { call_id, output }
823            }
824            ResponseInputItem::CustomToolCallOutput { call_id, output } => {
825                Self::CustomToolCallOutput { call_id, output }
826            }
827        }
828    }
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
832#[serde(rename_all = "snake_case")]
833pub enum LocalShellStatus {
834    Completed,
835    InProgress,
836    Incomplete,
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
840#[serde(tag = "type", rename_all = "snake_case")]
841pub enum LocalShellAction {
842    Exec(LocalShellExecAction),
843}
844
845#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
846pub struct LocalShellExecAction {
847    pub command: Vec<String>,
848    pub timeout_ms: Option<u64>,
849    pub working_directory: Option<String>,
850    pub env: Option<HashMap<String, String>>,
851    pub user: Option<String>,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
855#[serde(tag = "type", rename_all = "snake_case")]
856pub enum WebSearchAction {
857    Search {
858        #[serde(default, skip_serializing_if = "Option::is_none")]
859        #[ts(optional)]
860        query: Option<String>,
861        #[serde(default, skip_serializing_if = "Option::is_none")]
862        #[ts(optional)]
863        queries: Option<Vec<String>>,
864    },
865    OpenPage {
866        #[serde(default, skip_serializing_if = "Option::is_none")]
867        #[ts(optional)]
868        url: Option<String>,
869    },
870    FindInPage {
871        #[serde(default, skip_serializing_if = "Option::is_none")]
872        #[ts(optional)]
873        url: Option<String>,
874        #[serde(default, skip_serializing_if = "Option::is_none")]
875        #[ts(optional)]
876        pattern: Option<String>,
877    },
878
879    #[serde(other)]
880    Other,
881}
882
883#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
884#[serde(tag = "type", rename_all = "snake_case")]
885pub enum ReasoningItemReasoningSummary {
886    SummaryText { text: String },
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
890#[serde(tag = "type", rename_all = "snake_case")]
891pub enum ReasoningItemContent {
892    ReasoningText { text: String },
893    Text { text: String },
894}
895
896impl From<Vec<UserInput>> for ResponseInputItem {
897    fn from(items: Vec<UserInput>) -> Self {
898        let mut image_index = 0;
899        Self::Message {
900            role: "user".to_string(),
901            content: items
902                .into_iter()
903                .flat_map(|c| match c {
904                    UserInput::Text { text, .. } => vec![ContentItem::InputText { text }],
905                    UserInput::Image { image_url } => vec![
906                        ContentItem::InputText {
907                            text: image_open_tag_text(),
908                        },
909                        ContentItem::InputImage { image_url },
910                        ContentItem::InputText {
911                            text: image_close_tag_text(),
912                        },
913                    ],
914                    UserInput::LocalImage { path } => {
915                        image_index += 1;
916                        local_image_content_items_with_label_number(&path, Some(image_index))
917                    }
918                    UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core
919                })
920                .collect::<Vec<ContentItem>>(),
921        }
922    }
923}
924
925/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
926/// or `shell`, the `arguments` field should deserialize to this struct.
927#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
928pub struct ShellToolCallParams {
929    pub command: Vec<String>,
930    pub workdir: Option<String>,
931
932    /// This is the maximum time in milliseconds that the command is allowed to run.
933    #[serde(alias = "timeout")]
934    pub timeout_ms: Option<u64>,
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    #[ts(optional)]
937    pub sandbox_permissions: Option<SandboxPermissions>,
938    /// Suggests a command prefix to persist for future sessions
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    #[ts(optional)]
941    pub prefix_rule: Option<Vec<String>>,
942    #[serde(default, skip_serializing_if = "Option::is_none")]
943    #[ts(optional)]
944    pub additional_permissions: Option<PermissionProfile>,
945    #[serde(skip_serializing_if = "Option::is_none")]
946    pub justification: Option<String>,
947}
948
949/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
950/// `arguments` field should deserialize to this struct.
951#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
952pub struct ShellCommandToolCallParams {
953    pub command: String,
954    pub workdir: Option<String>,
955
956    /// Whether to run the shell with login shell semantics
957    #[serde(skip_serializing_if = "Option::is_none")]
958    pub login: Option<bool>,
959    /// This is the maximum time in milliseconds that the command is allowed to run.
960    #[serde(alias = "timeout")]
961    pub timeout_ms: Option<u64>,
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    #[ts(optional)]
964    pub sandbox_permissions: Option<SandboxPermissions>,
965    #[serde(default, skip_serializing_if = "Option::is_none")]
966    #[ts(optional)]
967    pub prefix_rule: Option<Vec<String>>,
968    #[serde(default, skip_serializing_if = "Option::is_none")]
969    #[ts(optional)]
970    pub additional_permissions: Option<PermissionProfile>,
971    #[serde(skip_serializing_if = "Option::is_none")]
972    pub justification: Option<String>,
973}
974
975/// Responses API compatible content items that can be returned by a tool call.
976/// This is a subset of ContentItem with the types we support as function call outputs.
977#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
978#[serde(tag = "type", rename_all = "snake_case")]
979pub enum FunctionCallOutputContentItem {
980    // Do not rename, these are serialized and used directly in the responses API.
981    InputText { text: String },
982    // Do not rename, these are serialized and used directly in the responses API.
983    InputImage {
984        image_url: String,
985        #[serde(default, skip_serializing_if = "Option::is_none")]
986        #[ts(optional)]
987        detail: Option<ImageDetail>,
988    },
989}
990
991/// Converts structured function-call output content into plain text for
992/// human-readable surfaces.
993///
994/// This conversion is intentionally lossy:
995/// - only `input_text` items are included
996/// - image items are ignored
997///
998/// We use this helper where callers still need a string representation (for
999/// example telemetry previews or legacy string-only output paths) while keeping
1000/// the original multimodal `content_items` as the authoritative payload sent to
1001/// the model.
1002pub fn function_call_output_content_items_to_text(
1003    content_items: &[FunctionCallOutputContentItem],
1004) -> Option<String> {
1005    let text_segments = content_items
1006        .iter()
1007        .filter_map(|item| match item {
1008            FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => {
1009                Some(text.as_str())
1010            }
1011            FunctionCallOutputContentItem::InputText { .. }
1012            | FunctionCallOutputContentItem::InputImage { .. } => None,
1013        })
1014        .collect::<Vec<_>>();
1015
1016    if text_segments.is_empty() {
1017        None
1018    } else {
1019        Some(text_segments.join("\n"))
1020    }
1021}
1022
1023impl From<crate::dynamic_tools::DynamicToolCallOutputContentItem>
1024    for FunctionCallOutputContentItem
1025{
1026    fn from(item: crate::dynamic_tools::DynamicToolCallOutputContentItem) -> Self {
1027        match item {
1028            crate::dynamic_tools::DynamicToolCallOutputContentItem::InputText { text } => {
1029                Self::InputText { text }
1030            }
1031            crate::dynamic_tools::DynamicToolCallOutputContentItem::InputImage { image_url } => {
1032                Self::InputImage {
1033                    image_url,
1034                    detail: None,
1035                }
1036            }
1037        }
1038    }
1039}
1040
1041/// The payload we send back to OpenAI when reporting a tool call result.
1042///
1043/// `body` serializes directly as the wire value for `function_call_output.output`.
1044/// `success` remains internal metadata for downstream handling.
1045#[derive(Debug, Default, Clone, PartialEq, JsonSchema, TS)]
1046pub struct FunctionCallOutputPayload {
1047    pub body: FunctionCallOutputBody,
1048    pub success: Option<bool>,
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
1052#[serde(untagged)]
1053pub enum FunctionCallOutputBody {
1054    Text(String),
1055    ContentItems(Vec<FunctionCallOutputContentItem>),
1056}
1057
1058impl FunctionCallOutputBody {
1059    /// Best-effort conversion of a function-call output body to plain text for
1060    /// human-readable surfaces.
1061    ///
1062    /// This conversion is intentionally lossy when the body contains content
1063    /// items: image entries are dropped and text entries are joined with
1064    /// newlines.
1065    pub fn to_text(&self) -> Option<String> {
1066        match self {
1067            Self::Text(content) => Some(content.clone()),
1068            Self::ContentItems(items) => function_call_output_content_items_to_text(items),
1069        }
1070    }
1071}
1072
1073impl Default for FunctionCallOutputBody {
1074    fn default() -> Self {
1075        Self::Text(String::new())
1076    }
1077}
1078
1079impl FunctionCallOutputPayload {
1080    pub fn from_text(content: String) -> Self {
1081        Self {
1082            body: FunctionCallOutputBody::Text(content),
1083            success: None,
1084        }
1085    }
1086
1087    pub fn from_content_items(content_items: Vec<FunctionCallOutputContentItem>) -> Self {
1088        Self {
1089            body: FunctionCallOutputBody::ContentItems(content_items),
1090            success: None,
1091        }
1092    }
1093
1094    pub fn text_content(&self) -> Option<&str> {
1095        match &self.body {
1096            FunctionCallOutputBody::Text(content) => Some(content),
1097            FunctionCallOutputBody::ContentItems(_) => None,
1098        }
1099    }
1100
1101    pub fn text_content_mut(&mut self) -> Option<&mut String> {
1102        match &mut self.body {
1103            FunctionCallOutputBody::Text(content) => Some(content),
1104            FunctionCallOutputBody::ContentItems(_) => None,
1105        }
1106    }
1107
1108    pub fn content_items(&self) -> Option<&[FunctionCallOutputContentItem]> {
1109        match &self.body {
1110            FunctionCallOutputBody::Text(_) => None,
1111            FunctionCallOutputBody::ContentItems(items) => Some(items),
1112        }
1113    }
1114
1115    pub fn content_items_mut(&mut self) -> Option<&mut Vec<FunctionCallOutputContentItem>> {
1116        match &mut self.body {
1117            FunctionCallOutputBody::Text(_) => None,
1118            FunctionCallOutputBody::ContentItems(items) => Some(items),
1119        }
1120    }
1121}
1122
1123// `function_call_output.output` is encoded as either:
1124//   - an array of structured content items
1125//   - a plain string
1126impl Serialize for FunctionCallOutputPayload {
1127    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1128    where
1129        S: Serializer,
1130    {
1131        match &self.body {
1132            FunctionCallOutputBody::Text(content) => serializer.serialize_str(content),
1133            FunctionCallOutputBody::ContentItems(items) => items.serialize(serializer),
1134        }
1135    }
1136}
1137
1138impl<'de> Deserialize<'de> for FunctionCallOutputPayload {
1139    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1140    where
1141        D: Deserializer<'de>,
1142    {
1143        let body = FunctionCallOutputBody::deserialize(deserializer)?;
1144        Ok(FunctionCallOutputPayload {
1145            body,
1146            success: None,
1147        })
1148    }
1149}
1150
1151impl From<&CallToolResult> for FunctionCallOutputPayload {
1152    fn from(call_tool_result: &CallToolResult) -> Self {
1153        let CallToolResult {
1154            content,
1155            structured_content,
1156            is_error,
1157            meta: _,
1158        } = call_tool_result;
1159
1160        let is_success = is_error != &Some(true);
1161
1162        if let Some(structured_content) = structured_content
1163            && !structured_content.is_null()
1164        {
1165            match serde_json::to_string(structured_content) {
1166                Ok(serialized_structured_content) => {
1167                    return FunctionCallOutputPayload {
1168                        body: FunctionCallOutputBody::Text(serialized_structured_content),
1169                        success: Some(is_success),
1170                    };
1171                }
1172                Err(err) => {
1173                    return FunctionCallOutputPayload {
1174                        body: FunctionCallOutputBody::Text(err.to_string()),
1175                        success: Some(false),
1176                    };
1177                }
1178            }
1179        }
1180
1181        let serialized_content = match serde_json::to_string(content) {
1182            Ok(serialized_content) => serialized_content,
1183            Err(err) => {
1184                return FunctionCallOutputPayload {
1185                    body: FunctionCallOutputBody::Text(err.to_string()),
1186                    success: Some(false),
1187                };
1188            }
1189        };
1190
1191        let content_items = convert_mcp_content_to_items(content);
1192
1193        let body = match content_items {
1194            Some(content_items) => FunctionCallOutputBody::ContentItems(content_items),
1195            None => FunctionCallOutputBody::Text(serialized_content),
1196        };
1197
1198        FunctionCallOutputPayload {
1199            body,
1200            success: Some(is_success),
1201        }
1202    }
1203}
1204
1205fn convert_mcp_content_to_items(
1206    contents: &[serde_json::Value],
1207) -> Option<Vec<FunctionCallOutputContentItem>> {
1208    #[derive(serde::Deserialize)]
1209    #[serde(tag = "type")]
1210    enum McpContent {
1211        #[serde(rename = "text")]
1212        Text { text: String },
1213        #[serde(rename = "image")]
1214        Image {
1215            data: String,
1216            #[serde(rename = "mimeType", alias = "mime_type")]
1217            mime_type: Option<String>,
1218        },
1219        #[serde(other)]
1220        Unknown,
1221    }
1222
1223    let mut saw_image = false;
1224    let mut items = Vec::with_capacity(contents.len());
1225
1226    for content in contents {
1227        let item = match serde_json::from_value::<McpContent>(content.clone()) {
1228            Ok(McpContent::Text { text }) => FunctionCallOutputContentItem::InputText { text },
1229            Ok(McpContent::Image { data, mime_type }) => {
1230                saw_image = true;
1231                let image_url = if data.starts_with("data:") {
1232                    data
1233                } else {
1234                    let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
1235                    format!("data:{mime_type};base64,{data}")
1236                };
1237                FunctionCallOutputContentItem::InputImage {
1238                    image_url,
1239                    detail: None,
1240                }
1241            }
1242            Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText {
1243                text: serde_json::to_string(content).unwrap_or_else(|_| "<content>".to_string()),
1244            },
1245        };
1246        items.push(item);
1247    }
1248
1249    if saw_image { Some(items) } else { None }
1250}
1251
1252// Implement Display so callers can treat the payload like a plain string when logging or doing
1253// trivial substring checks in tests (existing tests call `.contains()` on the output). For
1254// `ContentItems`, Display emits a JSON representation.
1255
1256impl std::fmt::Display for FunctionCallOutputPayload {
1257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1258        match &self.body {
1259            FunctionCallOutputBody::Text(content) => f.write_str(content),
1260            FunctionCallOutputBody::ContentItems(items) => {
1261                let content = serde_json::to_string(items).unwrap_or_default();
1262                f.write_str(content.as_str())
1263            }
1264        }
1265    }
1266}
1267
1268// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.)
1269
1270#[cfg(test)]
1271mod tests {
1272    use super::*;
1273    use crate::config_types::SandboxMode;
1274    use crate::protocol::AskForApproval;
1275    use anyhow::Result;
1276    use hanzo_execpolicy::PolicyParser;
1277    use hanzo_execpolicy::Policy;
1278    use pretty_assertions::assert_eq;
1279    use std::path::PathBuf;
1280    use tempfile::tempdir;
1281
1282    fn empty_exec_policy() -> Policy {
1283        hanzo_execpolicy::get_default_policy().expect("default policy")
1284    }
1285
1286    fn exec_policy_with_programs(programs: impl IntoIterator<Item = String>) -> Policy {
1287        let mut source = String::new();
1288        for program in programs {
1289            source.push_str(&format!(
1290                "define_program(\n    program=\"{program}\",\n    args=[],\n)\n"
1291            ));
1292        }
1293
1294        PolicyParser::new("#test", &source)
1295            .parse()
1296            .expect("policy with programs")
1297    }
1298
1299    #[test]
1300    fn convert_mcp_content_to_items_preserves_data_urls() {
1301        let contents = vec![serde_json::json!({
1302            "type": "image",
1303            "data": "data:image/png;base64,Zm9v",
1304            "mimeType": "image/png",
1305        })];
1306
1307        let items = convert_mcp_content_to_items(&contents).expect("expected image items");
1308        assert_eq!(
1309            items,
1310            vec![FunctionCallOutputContentItem::InputImage {
1311                image_url: "data:image/png;base64,Zm9v".to_string(),
1312                detail: None,
1313            }]
1314        );
1315    }
1316
1317    #[test]
1318    fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
1319        let contents = vec![serde_json::json!({
1320            "type": "image",
1321            "data": "Zm9v",
1322            "mimeType": "image/png",
1323        })];
1324
1325        let items = convert_mcp_content_to_items(&contents).expect("expected image items");
1326        assert_eq!(
1327            items,
1328            vec![FunctionCallOutputContentItem::InputImage {
1329                image_url: "data:image/png;base64,Zm9v".to_string(),
1330                detail: None,
1331            }]
1332        );
1333    }
1334
1335    #[test]
1336    fn convert_mcp_content_to_items_returns_none_without_images() {
1337        let contents = vec![serde_json::json!({
1338            "type": "text",
1339            "text": "hello",
1340        })];
1341
1342        assert_eq!(convert_mcp_content_to_items(&contents), None);
1343    }
1344
1345    #[test]
1346    fn function_call_output_content_items_to_text_joins_text_segments() {
1347        let content_items = vec![
1348            FunctionCallOutputContentItem::InputText {
1349                text: "line 1".to_string(),
1350            },
1351            FunctionCallOutputContentItem::InputImage {
1352                image_url: "data:image/png;base64,AAA".to_string(),
1353                detail: None,
1354            },
1355            FunctionCallOutputContentItem::InputText {
1356                text: "line 2".to_string(),
1357            },
1358        ];
1359
1360        let text = function_call_output_content_items_to_text(&content_items);
1361        assert_eq!(text, Some("line 1\nline 2".to_string()));
1362    }
1363
1364    #[test]
1365    fn function_call_output_content_items_to_text_ignores_blank_text_and_images() {
1366        let content_items = vec![
1367            FunctionCallOutputContentItem::InputText {
1368                text: "   ".to_string(),
1369            },
1370            FunctionCallOutputContentItem::InputImage {
1371                image_url: "data:image/png;base64,AAA".to_string(),
1372                detail: None,
1373            },
1374        ];
1375
1376        let text = function_call_output_content_items_to_text(&content_items);
1377        assert_eq!(text, None);
1378    }
1379
1380    #[test]
1381    fn function_call_output_body_to_text_returns_plain_text_content() {
1382        let body = FunctionCallOutputBody::Text("ok".to_string());
1383        let text = body.to_text();
1384        assert_eq!(text, Some("ok".to_string()));
1385    }
1386
1387    #[test]
1388    fn function_call_output_body_to_text_uses_content_item_fallback() {
1389        let body = FunctionCallOutputBody::ContentItems(vec![
1390            FunctionCallOutputContentItem::InputText {
1391                text: "line 1".to_string(),
1392            },
1393            FunctionCallOutputContentItem::InputImage {
1394                image_url: "data:image/png;base64,AAA".to_string(),
1395                detail: None,
1396            },
1397        ]);
1398
1399        let text = body.to_text();
1400        assert_eq!(text, Some("line 1".to_string()));
1401    }
1402
1403    #[test]
1404    fn converts_sandbox_mode_into_developer_instructions() {
1405        let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into();
1406        assert_eq!(
1407            workspace_write,
1408            DeveloperInstructions::new(
1409                "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted."
1410            )
1411        );
1412
1413        let read_only: DeveloperInstructions = SandboxMode::ReadOnly.into();
1414        assert_eq!(
1415            read_only,
1416            DeveloperInstructions::new(
1417                "Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted."
1418            )
1419        );
1420    }
1421
1422    #[test]
1423    fn builds_permissions_with_network_access_override() {
1424        let instructions = DeveloperInstructions::from_permissions_with_network(
1425            SandboxMode::WorkspaceWrite,
1426            NetworkAccess::Enabled,
1427            AskForApproval::OnRequest,
1428            &empty_exec_policy(),
1429            None,
1430            false,
1431        );
1432
1433        let text = instructions.into_text();
1434        assert!(
1435            text.contains("Network access is enabled."),
1436            "expected network access to be enabled in message"
1437        );
1438        assert!(
1439            text.contains("How to request escalation"),
1440            "expected approval guidance to be included"
1441        );
1442    }
1443
1444    #[test]
1445    fn builds_permissions_from_policy() {
1446        let policy = SandboxPolicy::WorkspaceWrite {
1447            writable_roots: vec![],
1448            network_access: true,
1449            exclude_tmpdir_env_var: false,
1450            exclude_slash_tmp: false,
1451            allow_git_writes: false,
1452        };
1453
1454        let instructions = DeveloperInstructions::from_policy(
1455            &policy,
1456            AskForApproval::UnlessTrusted,
1457            &empty_exec_policy(),
1458            &PathBuf::from("/tmp"),
1459            false,
1460        );
1461        let text = instructions.into_text();
1462        assert!(text.contains("Network access is enabled."));
1463        assert!(text.contains("`approval_policy` is `unless-trusted`"));
1464    }
1465
1466    #[test]
1467    fn includes_request_rule_instructions_for_on_request() {
1468        let exec_policy = exec_policy_with_programs(["git".to_string()]);
1469        let instructions = DeveloperInstructions::from_permissions_with_network(
1470            SandboxMode::WorkspaceWrite,
1471            NetworkAccess::Enabled,
1472            AskForApproval::OnRequest,
1473            &exec_policy,
1474            None,
1475            false,
1476        );
1477
1478        let text = instructions.into_text();
1479        assert!(text.contains("prefix_rule"));
1480        assert!(text.contains("Approved command prefixes"));
1481        assert!(text.contains(r#"["git"]"#));
1482    }
1483
1484    #[test]
1485    fn render_command_prefix_list_sorts_by_len_then_total_len_then_alphabetical() {
1486        let prefixes = vec![
1487            vec!["b".to_string(), "zz".to_string()],
1488            vec!["aa".to_string()],
1489            vec!["b".to_string()],
1490            vec!["a".to_string(), "b".to_string(), "c".to_string()],
1491            vec!["a".to_string()],
1492            vec!["b".to_string(), "a".to_string()],
1493        ];
1494
1495        let output = format_allow_prefixes(prefixes).expect("rendered list");
1496        assert_eq!(
1497            output,
1498            r#"- ["a"]
1499- ["b"]
1500- ["aa"]
1501- ["b", "a"]
1502- ["b", "zz"]
1503- ["a", "b", "c"]"#
1504                .to_string(),
1505        );
1506    }
1507
1508    #[test]
1509    fn render_command_prefix_list_limits_output_to_max_prefixes() {
1510        let prefixes = (0..(MAX_RENDERED_PREFIXES + 5))
1511            .map(|i| vec![format!("{i:03}")])
1512            .collect::<Vec<_>>();
1513
1514        let output = format_allow_prefixes(prefixes).expect("rendered list");
1515        assert_eq!(output.ends_with(TRUNCATED_MARKER), true);
1516        eprintln!("output: {output}");
1517        assert_eq!(output.lines().count(), MAX_RENDERED_PREFIXES + 1);
1518    }
1519
1520    #[test]
1521    fn format_allow_prefixes_limits_output() {
1522        let exec_policy =
1523            exec_policy_with_programs((0..200).map(|i| format!("tool-{i:03}")));
1524
1525        let output =
1526            format_allow_prefixes(exec_policy.get_allowed_prefixes()).expect("formatted prefixes");
1527        assert!(
1528            output.len() <= MAX_ALLOW_PREFIX_TEXT_BYTES + TRUNCATED_MARKER.len(),
1529            "output length exceeds expected limit: {output}",
1530        );
1531    }
1532
1533    #[test]
1534    fn serializes_success_as_plain_string() -> Result<()> {
1535        let item = ResponseInputItem::FunctionCallOutput {
1536            call_id: "call1".into(),
1537            output: FunctionCallOutputPayload::from_text("ok".into()),
1538        };
1539
1540        let json = serde_json::to_string(&item)?;
1541        let v: serde_json::Value = serde_json::from_str(&json)?;
1542
1543        // Success case -> output should be a plain string
1544        assert_eq!(v.get("output").unwrap().as_str().unwrap(), "ok");
1545        Ok(())
1546    }
1547
1548    #[test]
1549    fn serializes_failure_as_string() -> Result<()> {
1550        let item = ResponseInputItem::FunctionCallOutput {
1551            call_id: "call1".into(),
1552            output: FunctionCallOutputPayload {
1553                body: FunctionCallOutputBody::Text("bad".into()),
1554                success: Some(false),
1555            },
1556        };
1557
1558        let json = serde_json::to_string(&item)?;
1559        let v: serde_json::Value = serde_json::from_str(&json)?;
1560
1561        assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
1562        Ok(())
1563    }
1564
1565    #[test]
1566    fn serializes_image_outputs_as_array() -> Result<()> {
1567        let call_tool_result = CallToolResult {
1568            content: vec![
1569                serde_json::json!({"type":"text","text":"caption"}),
1570                serde_json::json!({"type":"image","data":"BASE64","mimeType":"image/png"}),
1571            ],
1572            structured_content: None,
1573            is_error: Some(false),
1574            meta: None,
1575        };
1576
1577        let payload = FunctionCallOutputPayload::from(&call_tool_result);
1578        assert_eq!(payload.success, Some(true));
1579        let Some(items) = payload.content_items() else {
1580            panic!("expected content items");
1581        };
1582        let items = items.to_vec();
1583        assert_eq!(
1584            items,
1585            vec![
1586                FunctionCallOutputContentItem::InputText {
1587                    text: "caption".into(),
1588                },
1589                FunctionCallOutputContentItem::InputImage {
1590                    image_url: "data:image/png;base64,BASE64".into(),
1591                    detail: None,
1592                },
1593            ]
1594        );
1595
1596        let item = ResponseInputItem::FunctionCallOutput {
1597            call_id: "call1".into(),
1598            output: payload,
1599        };
1600
1601        let json = serde_json::to_string(&item)?;
1602        let v: serde_json::Value = serde_json::from_str(&json)?;
1603
1604        let output = v.get("output").expect("output field");
1605        assert!(output.is_array(), "expected array output");
1606
1607        Ok(())
1608    }
1609
1610    #[test]
1611    fn serializes_custom_tool_image_outputs_as_array() -> Result<()> {
1612        let item = ResponseInputItem::CustomToolCallOutput {
1613            call_id: "call1".into(),
1614            output: FunctionCallOutputPayload::from_content_items(vec![
1615                FunctionCallOutputContentItem::InputImage {
1616                    image_url: "data:image/png;base64,BASE64".into(),
1617                    detail: None,
1618                },
1619            ]),
1620        };
1621
1622        let json = serde_json::to_string(&item)?;
1623        let v: serde_json::Value = serde_json::from_str(&json)?;
1624
1625        let output = v.get("output").expect("output field");
1626        assert!(output.is_array(), "expected array output");
1627
1628        Ok(())
1629    }
1630
1631    #[test]
1632    fn preserves_existing_image_data_urls() -> Result<()> {
1633        let call_tool_result = CallToolResult {
1634            content: vec![serde_json::json!({
1635                "type": "image",
1636                "data": "data:image/png;base64,BASE64",
1637                "mimeType": "image/png"
1638            })],
1639            structured_content: None,
1640            is_error: Some(false),
1641            meta: None,
1642        };
1643
1644        let payload = FunctionCallOutputPayload::from(&call_tool_result);
1645        let Some(items) = payload.content_items() else {
1646            panic!("expected content items");
1647        };
1648        let items = items.to_vec();
1649        assert_eq!(
1650            items,
1651            vec![FunctionCallOutputContentItem::InputImage {
1652                image_url: "data:image/png;base64,BASE64".into(),
1653                detail: None,
1654            }]
1655        );
1656
1657        Ok(())
1658    }
1659
1660    #[test]
1661    fn deserializes_array_payload_into_items() -> Result<()> {
1662        let json = r#"[
1663            {"type": "input_text", "text": "note"},
1664            {"type": "input_image", "image_url": "data:image/png;base64,XYZ"}
1665        ]"#;
1666
1667        let payload: FunctionCallOutputPayload = serde_json::from_str(json)?;
1668
1669        assert_eq!(payload.success, None);
1670        let expected_items = vec![
1671            FunctionCallOutputContentItem::InputText {
1672                text: "note".into(),
1673            },
1674            FunctionCallOutputContentItem::InputImage {
1675                image_url: "data:image/png;base64,XYZ".into(),
1676                detail: None,
1677            },
1678        ];
1679        assert_eq!(
1680            payload.body,
1681            FunctionCallOutputBody::ContentItems(expected_items.clone())
1682        );
1683        assert_eq!(
1684            serde_json::to_string(&payload)?,
1685            serde_json::to_string(&expected_items)?
1686        );
1687
1688        Ok(())
1689    }
1690
1691    #[test]
1692    fn deserializes_compaction_alias() -> Result<()> {
1693        let json = r#"{"type":"compaction_summary","encrypted_content":"abc"}"#;
1694
1695        let item: ResponseItem = serde_json::from_str(json)?;
1696
1697        assert_eq!(
1698            item,
1699            ResponseItem::CompactionSummary {
1700                encrypted_content: "abc".into(),
1701            }
1702        );
1703        Ok(())
1704    }
1705
1706    #[test]
1707    fn response_item_parses_image_generation_call() {
1708        let item = serde_json::from_value::<ResponseItem>(serde_json::json!({
1709            "id": "ig_123",
1710            "type": "image_generation_call",
1711            "status": "completed",
1712            "revised_prompt": "A small blue square",
1713            "result": "Zm9v",
1714        }))
1715        .expect("image generation item should deserialize");
1716
1717        assert_eq!(
1718            item,
1719            ResponseItem::ImageGenerationCall {
1720                id: "ig_123".to_string(),
1721                status: "completed".to_string(),
1722                revised_prompt: Some("A small blue square".to_string()),
1723                result: "Zm9v".to_string(),
1724            }
1725        );
1726    }
1727
1728    #[test]
1729    fn response_item_parses_image_generation_call_without_revised_prompt() {
1730        let item = serde_json::from_value::<ResponseItem>(serde_json::json!({
1731            "id": "ig_123",
1732            "type": "image_generation_call",
1733            "status": "completed",
1734            "result": "Zm9v",
1735        }))
1736        .expect("image generation item should deserialize");
1737
1738        assert_eq!(
1739            item,
1740            ResponseItem::ImageGenerationCall {
1741                id: "ig_123".to_string(),
1742                status: "completed".to_string(),
1743                revised_prompt: None,
1744                result: "Zm9v".to_string(),
1745            }
1746        );
1747    }
1748
1749    #[test]
1750    fn roundtrips_web_search_call_actions() -> Result<()> {
1751        let cases = vec![
1752            (
1753                r#"{
1754                    "type": "web_search_call",
1755                    "status": "completed",
1756                    "action": {
1757                        "type": "search",
1758                        "query": "weather seattle",
1759                        "queries": ["weather seattle", "seattle weather now"]
1760                    }
1761                }"#,
1762                None,
1763                Some(WebSearchAction::Search {
1764                    query: Some("weather seattle".into()),
1765                    queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]),
1766                }),
1767                Some("completed".into()),
1768                true,
1769            ),
1770            (
1771                r#"{
1772                    "type": "web_search_call",
1773                    "status": "open",
1774                    "action": {
1775                        "type": "open_page",
1776                        "url": "https://example.com"
1777                    }
1778                }"#,
1779                None,
1780                Some(WebSearchAction::OpenPage {
1781                    url: Some("https://example.com".into()),
1782                }),
1783                Some("open".into()),
1784                true,
1785            ),
1786            (
1787                r#"{
1788                    "type": "web_search_call",
1789                    "status": "in_progress",
1790                    "action": {
1791                        "type": "find_in_page",
1792                        "url": "https://example.com/docs",
1793                        "pattern": "installation"
1794                    }
1795                }"#,
1796                None,
1797                Some(WebSearchAction::FindInPage {
1798                    url: Some("https://example.com/docs".into()),
1799                    pattern: Some("installation".into()),
1800                }),
1801                Some("in_progress".into()),
1802                true,
1803            ),
1804            (
1805                r#"{
1806                    "type": "web_search_call",
1807                    "status": "in_progress",
1808                    "id": "ws_partial"
1809                }"#,
1810                Some("ws_partial".into()),
1811                None,
1812                Some("in_progress".into()),
1813                false,
1814            ),
1815        ];
1816
1817        for (json_literal, expected_id, expected_action, expected_status, expect_roundtrip) in cases
1818        {
1819            let parsed: ResponseItem = serde_json::from_str(json_literal)?;
1820            let expected = ResponseItem::WebSearchCall {
1821                id: expected_id.clone(),
1822                status: expected_status.clone(),
1823                action: expected_action.clone(),
1824            };
1825            assert_eq!(parsed, expected);
1826
1827            let serialized = serde_json::to_value(&parsed)?;
1828            let mut expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?;
1829            if !expect_roundtrip && let Some(obj) = expected_serialized.as_object_mut() {
1830                obj.remove("id");
1831            }
1832            assert_eq!(serialized, expected_serialized);
1833        }
1834
1835        Ok(())
1836    }
1837
1838    #[test]
1839    fn deserialize_shell_tool_call_params() -> Result<()> {
1840        let json = r#"{
1841            "command": ["ls", "-l"],
1842            "workdir": "/tmp",
1843            "timeout": 1000
1844        }"#;
1845
1846        let params: ShellToolCallParams = serde_json::from_str(json)?;
1847        assert_eq!(
1848            ShellToolCallParams {
1849                command: vec!["ls".to_string(), "-l".to_string()],
1850                workdir: Some("/tmp".to_string()),
1851                timeout_ms: Some(1000),
1852                sandbox_permissions: None,
1853                prefix_rule: None,
1854                additional_permissions: None,
1855                justification: None,
1856            },
1857            params
1858        );
1859        Ok(())
1860    }
1861
1862    #[test]
1863    fn wraps_image_user_input_with_tags() -> Result<()> {
1864        let image_url = "data:image/png;base64,abc".to_string();
1865
1866        let item = ResponseInputItem::from(vec![UserInput::Image {
1867            image_url: image_url.clone(),
1868        }]);
1869
1870        match item {
1871            ResponseInputItem::Message { content, .. } => {
1872                let expected = vec![
1873                    ContentItem::InputText {
1874                        text: image_open_tag_text(),
1875                    },
1876                    ContentItem::InputImage { image_url },
1877                    ContentItem::InputText {
1878                        text: image_close_tag_text(),
1879                    },
1880                ];
1881                assert_eq!(content, expected);
1882            }
1883            other => panic!("expected message response but got {other:?}"),
1884        }
1885
1886        Ok(())
1887    }
1888
1889    #[test]
1890    fn local_image_read_error_adds_placeholder() -> Result<()> {
1891        let dir = tempdir()?;
1892        let missing_path = dir.path().join("missing-image.png");
1893
1894        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
1895            path: missing_path.clone(),
1896        }]);
1897
1898        match item {
1899            ResponseInputItem::Message { content, .. } => {
1900                assert_eq!(content.len(), 1);
1901                match &content[0] {
1902                    ContentItem::InputText { text } => {
1903                        let display_path = missing_path.display().to_string();
1904                        assert!(
1905                            text.contains(&display_path),
1906                            "placeholder should mention missing path: {text}"
1907                        );
1908                        assert!(
1909                            text.contains("could not read"),
1910                            "placeholder should mention read issue: {text}"
1911                        );
1912                    }
1913                    other => panic!("expected placeholder text but found {other:?}"),
1914                }
1915            }
1916            other => panic!("expected message response but got {other:?}"),
1917        }
1918
1919        Ok(())
1920    }
1921
1922    #[test]
1923    fn local_image_non_image_adds_placeholder() -> Result<()> {
1924        let dir = tempdir()?;
1925        let json_path = dir.path().join("example.json");
1926        std::fs::write(&json_path, br#"{"hello":"world"}"#)?;
1927
1928        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
1929            path: json_path.clone(),
1930        }]);
1931
1932        match item {
1933            ResponseInputItem::Message { content, .. } => {
1934                assert_eq!(content.len(), 1);
1935                match &content[0] {
1936                    ContentItem::InputText { text } => {
1937                        assert!(
1938                            text.contains("unsupported MIME type `application/json`"),
1939                            "placeholder should mention unsupported MIME: {text}"
1940                        );
1941                        assert!(
1942                            text.contains(&json_path.display().to_string()),
1943                            "placeholder should mention path: {text}"
1944                        );
1945                    }
1946                    other => panic!("expected placeholder text but found {other:?}"),
1947                }
1948            }
1949            other => panic!("expected message response but got {other:?}"),
1950        }
1951
1952        Ok(())
1953    }
1954
1955    #[test]
1956    fn local_image_unsupported_image_format_adds_placeholder() -> Result<()> {
1957        let dir = tempdir()?;
1958        let svg_path = dir.path().join("example.svg");
1959        std::fs::write(
1960            &svg_path,
1961            br#"<?xml version="1.0" encoding="UTF-8"?>
1962<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#,
1963        )?;
1964
1965        let item = ResponseInputItem::from(vec![UserInput::LocalImage {
1966            path: svg_path.clone(),
1967        }]);
1968
1969        match item {
1970            ResponseInputItem::Message { content, .. } => {
1971                assert_eq!(content.len(), 1);
1972                let expected = format!(
1973                    "Codex cannot attach image at `{}`: unsupported image format `image/svg+xml`.",
1974                    svg_path.display()
1975                );
1976                match &content[0] {
1977                    ContentItem::InputText { text } => assert_eq!(text, &expected),
1978                    other => panic!("expected placeholder text but found {other:?}"),
1979                }
1980            }
1981            other => panic!("expected message response but got {other:?}"),
1982        }
1983
1984        Ok(())
1985    }
1986
1987    #[test]
1988    fn permission_profile_accepts_network_object_shape() {
1989        let profile: PermissionProfile = serde_json::from_value(serde_json::json!({
1990            "network": { "enabled": true }
1991        }))
1992        .expect("deserialize network permission profile");
1993
1994        assert_eq!(profile.network, Some(true));
1995        assert_eq!(
1996            serde_json::to_value(profile).expect("serialize network permission profile"),
1997            serde_json::json!({
1998                "network": { "enabled": true },
1999                "file_system": null,
2000                "macos": null,
2001            })
2002        );
2003    }
2004
2005    #[test]
2006    fn macos_automation_value_accepts_bundle_ids_object() {
2007        let value: MacOsAutomationValue = serde_json::from_value(serde_json::json!({
2008            "bundle_ids": ["com.apple.Notes"]
2009        }))
2010        .expect("deserialize automation bundle ids object");
2011
2012        assert_eq!(
2013            value,
2014            MacOsAutomationValue::BundleIds(vec!["com.apple.Notes".to_string()])
2015        );
2016    }
2017
2018    #[test]
2019    fn macos_automation_value_serializes_as_bool_or_array() {
2020        assert_eq!(
2021            serde_json::to_value(MacOsAutomationValue::Bool(false)).unwrap(),
2022            serde_json::json!(false)
2023        );
2024
2025        assert_eq!(
2026            serde_json::to_value(MacOsAutomationValue::BundleIds(vec!["com.apple.Notes".to_string()]))
2027                .unwrap(),
2028            serde_json::json!(["com.apple.Notes"])
2029        );
2030    }
2031}