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#[derive(
30 Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
31)]
32#[serde(rename_all = "snake_case")]
33pub enum SandboxPermissions {
34 #[default]
36 UseDefault,
37 RequireEscalated,
39 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")]
235pub enum MessagePhase {
240 Commentary,
245 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 #[serde(default, skip_serializing_if = "Option::is_none")]
260 #[ts(optional)]
261 end_turn: Option<bool>,
262 #[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 #[serde(default, skip_serializing)]
282 #[ts(skip)]
283 id: Option<String>,
284 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 arguments: String,
298 call_id: String,
299 },
300 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 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 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 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#[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#[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 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 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(), })
920 .collect::<Vec<ContentItem>>(),
921 }
922 }
923}
924
925#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
928pub struct ShellToolCallParams {
929 pub command: Vec<String>,
930 pub workdir: Option<String>,
931
932 #[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 #[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#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
952pub struct ShellCommandToolCallParams {
953 pub command: String,
954 pub workdir: Option<String>,
955
956 #[serde(skip_serializing_if = "Option::is_none")]
958 pub login: Option<bool>,
959 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
978#[serde(tag = "type", rename_all = "snake_case")]
979pub enum FunctionCallOutputContentItem {
980 InputText { text: String },
982 InputImage {
984 image_url: String,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
986 #[ts(optional)]
987 detail: Option<ImageDetail>,
988 },
989}
990
991pub 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#[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 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
1123impl 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
1252impl 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#[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 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}