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