Files
codex/codex-rs/protocol/src/models.rs
felixxia-oai 0d502a4230 Support durable reasoning configuration updates (#42328)
## Why

Reasoning configuration changes need to retain their position and trusted provenance when model history is persisted and replayed. Client-injected history must not be able to forge these controls.

## What changed

- Add a typed `configuration_update` response item carrying reasoning effort, including custom model-defined values.
- Persist harness-authored updates with provenance and preserve them across history reconstruction, thread resume, raw response notifications, and agent forks.
- Exclude untrusted configuration updates from model history, strip client-supplied provenance metadata, and reject configuration updates supplied as turn input.
- Export the new item through the JSON and TypeScript app-server schemas and classify it in telemetry and persistence metrics.

## Testing

- Cover serialization, provenance persistence, history filtering and rollback, resume reconstruction, and injection attempts before and after restart.

GitOrigin-RevId: eb5559d2b52b7a931621e7c9812f009ff9fb8939
2026-09-02 18:52:44 +00:00

4401 lines
150 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use std::io;
use std::num::NonZeroUsize;
use std::path::Path;
use codex_utils_image::PromptImageMode;
use codex_utils_image::data_url_from_bytes;
use codex_utils_image::load_for_prompt_bytes;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::ser::Serializer;
use serde_with::serde_as;
use ts_rs::TS;
use crate::local_media::audio_mime_for_path;
use crate::permissions::FileSystemAccessMode;
use crate::permissions::FileSystemPath;
use crate::permissions::FileSystemSandboxEntry;
use crate::permissions::FileSystemSandboxKind;
use crate::permissions::FileSystemSandboxPolicy;
use crate::permissions::FileSystemSpecialPath;
use crate::permissions::NetworkSandboxPolicy;
use crate::permissions::RawFileSystemSandboxEntry;
use crate::protocol::SandboxPolicy;
use crate::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_image::ImageProcessingError;
use schemars::JsonSchema;
use crate::ResponseItemId;
use crate::mcp::CallToolResult;
use codex_utils_path_uri::PathUri;
mod configuration_update;
mod executed_tool_calls;
mod item_metadata;
pub use crate::local_media::MAX_PROMPT_AUDIO_INPUT_BYTES;
pub use crate::local_media::snapshot_local_user_input;
pub use crate::permission_profile_snapshot::PermissionProfileSnapshot;
pub use configuration_update::ConfigurationReasoning;
pub use executed_tool_calls::ExecutedToolCall;
pub use executed_tool_calls::ExecutedToolCallArguments;
pub use executed_tool_calls::ExecutedToolCallTruncation;
pub use executed_tool_calls::MAX_TOOL_RESULT_SOURCE_FIELD_BYTES;
pub use executed_tool_calls::ToolResultSource;
pub use executed_tool_calls::ToolResultSources;
pub use executed_tool_calls::bound_executed_tool_calls_for_prompt;
pub use executed_tool_calls::bound_executed_tool_calls_for_prompt_prioritizing_recent;
pub use executed_tool_calls::executed_tool_call_metadata_bytes;
pub use item_metadata::ContentItemKind;
/// Controls the per-command sandbox override requested by a shell-like tool call.
#[derive(
Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
)]
#[serde(rename_all = "snake_case")]
pub enum SandboxPermissions {
/// Run with the turn's configured sandbox policy unchanged.
#[default]
UseDefault,
/// Request to run outside the sandbox.
RequireEscalated,
/// Request to stay in the sandbox while widening permissions for this
/// command only.
WithAdditionalPermissions,
}
impl SandboxPermissions {
/// True if SandboxPermissions requires full unsandboxed execution (i.e. RequireEscalated)
pub fn requires_escalated_permissions(self) -> bool {
matches!(self, SandboxPermissions::RequireEscalated)
}
/// True if SandboxPermissions requests any explicit per-command override
/// beyond `UseDefault`.
pub fn requests_sandbox_override(self) -> bool {
!matches!(self, SandboxPermissions::UseDefault)
}
/// True if SandboxPermissions uses the sandboxed per-command permission
/// widening flow.
pub fn uses_additional_permissions(self) -> bool {
matches!(self, SandboxPermissions::WithAdditionalPermissions)
}
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, JsonSchema, TS)]
pub struct FileSystemPermissions {
#[schemars(with = "Vec<RawFileSystemSandboxEntry>")]
#[ts(as = "Vec<RawFileSystemSandboxEntry>")]
pub entries: Vec<FileSystemSandboxEntry>,
pub glob_scan_max_depth: Option<NonZeroUsize>,
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyReadWriteRoots {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read: Option<Vec<AbsolutePathBuf>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write: Option<Vec<AbsolutePathBuf>>,
}
impl FileSystemPermissions {
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn from_read_write_roots(
read: Option<Vec<AbsolutePathBuf>>,
write: Option<Vec<AbsolutePathBuf>>,
) -> Self {
Self::from_paths(read, write)
}
pub fn from_read_write_path_uris(
read: Option<Vec<PathUri>>,
write: Option<Vec<PathUri>>,
) -> Self {
Self::from_paths(read, write)
}
fn from_paths<P>(read: Option<Vec<P>>, write: Option<Vec<P>>) -> Self
where
P: Into<FileSystemPath>,
{
let mut entries = Vec::new();
if let Some(read) = read {
entries.extend(
read.into_iter().map(|path| {
FileSystemSandboxEntry::new(path.into(), FileSystemAccessMode::Read)
}),
);
}
if let Some(write) = write {
entries.extend(
write.into_iter().map(|path| {
FileSystemSandboxEntry::new(path.into(), FileSystemAccessMode::Write)
}),
);
}
Self {
entries,
glob_scan_max_depth: None,
}
}
pub fn legacy_read_write_roots(&self) -> Option<LegacyReadWriteRoots> {
self.as_legacy_permissions()
}
fn as_legacy_permissions(&self) -> Option<LegacyReadWriteRoots> {
if self.glob_scan_max_depth.is_some() {
return None;
}
let mut read = Vec::new();
let mut write = Vec::new();
for entry in &self.entries {
let FileSystemPath::Path { path } = &entry.path else {
return None;
};
let path = path.to_abs_path().ok()?;
match entry.access {
FileSystemAccessMode::Read => read.push(path),
FileSystemAccessMode::Write => write.push(path),
FileSystemAccessMode::Deny => return None,
}
}
Some(LegacyReadWriteRoots {
read: (!read.is_empty()).then_some(read),
write: (!write.is_empty()).then_some(write),
})
}
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CanonicalFileSystemPermissions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
entries: Vec<RawFileSystemSandboxEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
glob_scan_max_depth: Option<NonZeroUsize>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum FileSystemPermissionsDe {
Canonical(CanonicalFileSystemPermissions),
Legacy(LegacyReadWriteRoots),
}
impl Serialize for FileSystemPermissions {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Some(legacy) = self.as_legacy_permissions() {
legacy.serialize(serializer)
} else {
CanonicalFileSystemPermissions {
entries: self
.entries
.clone()
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()
.map_err(serde::ser::Error::custom)?,
glob_scan_max_depth: self.glob_scan_max_depth,
}
.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for FileSystemPermissions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
match FileSystemPermissionsDe::deserialize(deserializer)? {
FileSystemPermissionsDe::Canonical(CanonicalFileSystemPermissions {
entries,
glob_scan_max_depth,
}) => Ok(Self {
entries: entries
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()
.map_err(serde::de::Error::custom)?,
glob_scan_max_depth,
}),
FileSystemPermissionsDe::Legacy(LegacyReadWriteRoots { read, write }) => {
Ok(Self::from_read_write_roots(read, write))
}
}
}
}
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
pub struct NetworkPermissions {
pub enabled: Option<bool>,
}
impl NetworkPermissions {
pub fn is_empty(&self) -> bool {
self.enabled.is_none()
}
}
/// Partial permission overlay used for per-command requests and approved
/// session/turn grants.
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
pub struct AdditionalPermissionProfile {
pub network: Option<NetworkPermissions>,
pub file_system: Option<FileSystemPermissions>,
}
impl AdditionalPermissionProfile {
pub fn is_empty(&self) -> bool {
self.network.is_none() && self.file_system.is_none()
}
}
#[derive(
Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
)]
#[serde(rename_all = "snake_case")]
pub enum SandboxEnforcement {
/// Codex owns sandbox construction for this profile.
#[default]
Managed,
/// No outer filesystem sandbox should be applied.
Disabled,
/// Filesystem isolation is enforced by an external caller.
External,
}
impl SandboxEnforcement {
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
match sandbox_policy {
SandboxPolicy::DangerFullAccess => Self::Disabled,
SandboxPolicy::ExternalSandbox { .. } => Self::External,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => Self::Managed,
}
}
}
/// Filesystem permissions for profiles where Codex owns sandbox construction.
#[derive(Debug, Clone, Eq, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum ManagedFileSystemPermissions {
/// Apply a managed filesystem sandbox from the listed entries.
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
Restricted {
#[schemars(with = "Vec<RawFileSystemSandboxEntry>")]
#[ts(as = "Vec<RawFileSystemSandboxEntry>")]
entries: Vec<FileSystemSandboxEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
glob_scan_max_depth: Option<NonZeroUsize>,
},
/// Apply a managed sandbox that allows all filesystem access.
Unrestricted,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum SerializedManagedFileSystemPermissions {
#[serde(rename_all = "snake_case")]
Restricted {
entries: Vec<RawFileSystemSandboxEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
glob_scan_max_depth: Option<NonZeroUsize>,
},
Unrestricted,
}
impl Serialize for ManagedFileSystemPermissions {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::Restricted {
entries,
glob_scan_max_depth,
} => SerializedManagedFileSystemPermissions::Restricted {
entries: entries
.clone()
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()
.map_err(serde::ser::Error::custom)?,
glob_scan_max_depth: *glob_scan_max_depth,
}
.serialize(serializer),
Self::Unrestricted => {
SerializedManagedFileSystemPermissions::Unrestricted.serialize(serializer)
}
}
}
}
impl<'de> Deserialize<'de> for ManagedFileSystemPermissions {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(
match SerializedManagedFileSystemPermissions::deserialize(deserializer)? {
SerializedManagedFileSystemPermissions::Restricted {
entries,
glob_scan_max_depth,
} => Self::Restricted {
entries: entries
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()
.map_err(serde::de::Error::custom)?,
glob_scan_max_depth,
},
SerializedManagedFileSystemPermissions::Unrestricted => Self::Unrestricted,
},
)
}
}
impl ManagedFileSystemPermissions {
fn from_sandbox_policy(file_system_sandbox_policy: &FileSystemSandboxPolicy) -> Self {
match file_system_sandbox_policy.kind {
FileSystemSandboxKind::Restricted => Self::Restricted {
entries: file_system_sandbox_policy.entries.clone(),
glob_scan_max_depth: file_system_sandbox_policy
.glob_scan_max_depth
.and_then(NonZeroUsize::new),
},
FileSystemSandboxKind::Unrestricted => Self::Unrestricted,
FileSystemSandboxKind::ExternalSandbox => unreachable!(
"external filesystem policies are represented by PermissionProfile::External"
),
}
}
pub fn to_sandbox_policy(&self) -> FileSystemSandboxPolicy {
match self {
Self::Restricted {
entries,
glob_scan_max_depth,
} => FileSystemSandboxPolicy {
kind: FileSystemSandboxKind::Restricted,
glob_scan_max_depth: glob_scan_max_depth.map(usize::from),
entries: entries.clone(),
},
Self::Unrestricted => FileSystemSandboxPolicy::unrestricted(),
}
}
}
/// Reserved identifier for the built-in read-only permission profile.
pub const BUILT_IN_PERMISSION_PROFILE_READ_ONLY: &str = ":read-only";
/// Reserved identifier for the built-in workspace-write permission profile.
pub const BUILT_IN_PERMISSION_PROFILE_WORKSPACE: &str = ":workspace";
/// Reserved identifier for the built-in full-access permission profile.
pub const BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS: &str = ":danger-full-access";
/// Canonical active runtime permissions for a conversation, turn, or command.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum PermissionProfile {
/// Codex owns sandbox construction for this profile.
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
Managed {
file_system: ManagedFileSystemPermissions,
network: NetworkSandboxPolicy,
},
/// Do not apply an outer sandbox.
Disabled,
/// Filesystem isolation is enforced by an external caller.
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
External { network: NetworkSandboxPolicy },
}
/// Metadata for the named or implicit built-in permissions profile that
/// produced the active `PermissionProfile`.
///
/// The runtime must honor `PermissionProfile`; this sidecar exists so clients
/// can display stable profile identity without trying to reverse-engineer a
/// name from the compiled permissions.
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct ActivePermissionProfile {
/// Profile identifier from `default_permissions` or the implicit built-in
/// default, such as `:workspace` or a user-defined `[permissions.<id>]`
/// profile.
pub id: String,
/// Optional parent profile identifier from the selected permissions
/// profile's `extends` setting.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub extends: Option<String>,
}
impl ActivePermissionProfile {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
extends: None,
}
}
pub fn read_only() -> Self {
Self::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY)
}
}
impl Default for PermissionProfile {
fn default() -> Self {
Self::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
entries: Vec::new(),
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
}
}
}
impl PermissionProfile {
/// Managed read-only filesystem access with restricted network access.
pub fn read_only() -> Self {
let file_system = FileSystemSandboxPolicy::read_only();
Self::Managed {
file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
network: NetworkSandboxPolicy::Restricted,
}
}
/// Intersects managed filesystem permissions with read-only access and restricts network.
///
/// Returns `None` when filesystem enforcement belongs to an external caller.
pub fn intersect_with_read_only(&self) -> Option<Self> {
let mut file_system = self.file_system_sandbox_policy();
match file_system.kind {
FileSystemSandboxKind::Restricted => {
for entry in &mut file_system.entries {
entry.access = match entry.access {
FileSystemAccessMode::Read | FileSystemAccessMode::Write => {
FileSystemAccessMode::Read
}
FileSystemAccessMode::Deny => FileSystemAccessMode::Deny,
};
}
}
FileSystemSandboxKind::Unrestricted => {
file_system = FileSystemSandboxPolicy::read_only();
}
FileSystemSandboxKind::ExternalSandbox => return None,
}
Some(Self::from_runtime_permissions(
&file_system,
NetworkSandboxPolicy::Restricted,
))
}
/// Managed workspace-write filesystem access with restricted network
/// access.
///
/// The returned profile contains symbolic `:workspace_roots` entries that
/// must be resolved against the active permission root before enforcement.
pub fn workspace_write() -> Self {
Self::workspace_write_with(
&[],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
)
}
/// Managed workspace-write filesystem access with the legacy
/// `sandbox_workspace_write` knobs applied directly to the profile.
///
/// The returned profile contains symbolic `:workspace_roots` entries that
/// must be resolved against the active permission root before enforcement.
pub fn workspace_write_with(
writable_roots: &[AbsolutePathBuf],
network: NetworkSandboxPolicy,
exclude_tmpdir_env_var: bool,
exclude_slash_tmp: bool,
) -> Self {
let file_system = FileSystemSandboxPolicy::workspace_write(
writable_roots,
exclude_tmpdir_env_var,
exclude_slash_tmp,
);
Self::Managed {
file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
network,
}
}
pub fn materialize_project_roots_with_workspace_roots(
self,
workspace_roots: &[AbsolutePathBuf],
) -> Self {
self.materialize_project_roots_with(|policy| {
policy.materialize_project_roots_with_workspace_roots(workspace_roots)
})
}
pub fn materialize_project_roots_with_path_uris(self, workspace_roots: &[PathUri]) -> Self {
self.materialize_project_roots_with(|policy| {
policy.materialize_project_roots_with_path_uris(workspace_roots)
})
}
fn materialize_project_roots_with(
self,
materialize: impl FnOnce(FileSystemSandboxPolicy) -> FileSystemSandboxPolicy,
) -> Self {
match self {
Self::Managed {
file_system,
network,
} => {
let file_system = materialize(file_system.to_sandbox_policy());
Self::Managed {
file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
network,
}
}
Self::Disabled => Self::Disabled,
Self::External { network } => Self::External { network },
}
}
pub fn from_runtime_permissions(
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
) -> Self {
let enforcement = match file_system_sandbox_policy.kind {
FileSystemSandboxKind::Restricted | FileSystemSandboxKind::Unrestricted => {
SandboxEnforcement::Managed
}
FileSystemSandboxKind::ExternalSandbox => SandboxEnforcement::External,
};
Self::from_runtime_permissions_with_enforcement(
enforcement,
file_system_sandbox_policy,
network_sandbox_policy,
)
}
pub fn from_runtime_permissions_with_enforcement(
enforcement: SandboxEnforcement,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
) -> Self {
match file_system_sandbox_policy.kind {
FileSystemSandboxKind::ExternalSandbox => Self::External {
network: network_sandbox_policy,
},
FileSystemSandboxKind::Unrestricted if enforcement == SandboxEnforcement::Disabled => {
Self::Disabled
}
FileSystemSandboxKind::Restricted | FileSystemSandboxKind::Unrestricted => {
Self::Managed {
file_system: ManagedFileSystemPermissions::from_sandbox_policy(
file_system_sandbox_policy,
),
network: network_sandbox_policy,
}
}
}
}
pub fn from_legacy_sandbox_policy(sandbox_policy: &SandboxPolicy) -> Self {
Self::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
&FileSystemSandboxPolicy::from(sandbox_policy),
NetworkSandboxPolicy::from(sandbox_policy),
)
}
pub fn from_legacy_sandbox_policy_for_cwd(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Self {
Self::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(sandbox_policy),
&FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(sandbox_policy, cwd),
NetworkSandboxPolicy::from(sandbox_policy),
)
}
pub fn enforcement(&self) -> SandboxEnforcement {
match self {
Self::Managed { .. } => SandboxEnforcement::Managed,
Self::Disabled => SandboxEnforcement::Disabled,
Self::External { .. } => SandboxEnforcement::External,
}
}
pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy {
match self {
Self::Managed { file_system, .. } => file_system.to_sandbox_policy(),
Self::Disabled => FileSystemSandboxPolicy::unrestricted(),
Self::External { .. } => FileSystemSandboxPolicy::external_sandbox(),
}
}
pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy {
match self {
Self::Managed { network, .. } | Self::External { network } => *network,
Self::Disabled => NetworkSandboxPolicy::Enabled,
}
}
pub fn to_legacy_sandbox_policy(&self, cwd: &Path) -> io::Result<SandboxPolicy> {
match self {
Self::Managed {
file_system,
network,
} => file_system
.to_sandbox_policy()
.to_legacy_sandbox_policy(*network, cwd),
Self::Disabled => Ok(SandboxPolicy::DangerFullAccess),
Self::External { network } => Ok(SandboxPolicy::ExternalSandbox {
network_access: if network.is_enabled() {
crate::protocol::NetworkAccess::Enabled
} else {
crate::protocol::NetworkAccess::Restricted
},
}),
}
}
pub fn to_runtime_permissions(&self) -> (FileSystemSandboxPolicy, NetworkSandboxPolicy) {
(
self.file_system_sandbox_policy(),
self.network_sandbox_policy(),
)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum TaggedPermissionProfile {
#[serde(rename_all = "snake_case")]
Managed {
file_system: ManagedFileSystemPermissions,
network: NetworkSandboxPolicy,
},
Disabled,
#[serde(rename_all = "snake_case")]
External {
network: NetworkSandboxPolicy,
},
}
impl From<TaggedPermissionProfile> for PermissionProfile {
fn from(value: TaggedPermissionProfile) -> Self {
match value {
TaggedPermissionProfile::Managed {
file_system,
network,
} => Self::Managed {
file_system,
network,
},
TaggedPermissionProfile::Disabled => Self::Disabled,
TaggedPermissionProfile::External { network } => Self::External { network },
}
}
}
/// Pre-tagged shape written to rollout files before `PermissionProfile`
/// represented enforcement explicitly.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyPermissionProfile {
network: Option<NetworkPermissions>,
file_system: Option<FileSystemPermissions>,
}
impl From<LegacyPermissionProfile> for PermissionProfile {
fn from(value: LegacyPermissionProfile) -> Self {
let file_system = value.file_system.map_or_else(
|| ManagedFileSystemPermissions::Restricted {
entries: Vec::new(),
glob_scan_max_depth: None,
},
|permissions| ManagedFileSystemPermissions::Restricted {
entries: permissions.entries,
glob_scan_max_depth: permissions.glob_scan_max_depth,
},
);
let network_sandbox_policy = if value
.network
.as_ref()
.and_then(|network| network.enabled)
.unwrap_or(false)
{
NetworkSandboxPolicy::Enabled
} else {
NetworkSandboxPolicy::Restricted
};
Self::Managed {
file_system,
network: network_sandbox_policy,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum PermissionProfileDe {
Tagged(TaggedPermissionProfile),
Legacy(LegacyPermissionProfile),
}
impl<'de> Deserialize<'de> for PermissionProfile {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match PermissionProfileDe::deserialize(deserializer)? {
PermissionProfileDe::Tagged(tagged) => tagged.into(),
PermissionProfileDe::Legacy(legacy) => legacy.into(),
})
}
}
impl From<NetworkSandboxPolicy> for NetworkPermissions {
fn from(value: NetworkSandboxPolicy) -> Self {
Self {
enabled: Some(value.is_enabled()),
}
}
}
impl From<&FileSystemSandboxPolicy> for FileSystemPermissions {
fn from(value: &FileSystemSandboxPolicy) -> Self {
let entries = match value.kind {
FileSystemSandboxKind::Restricted => value.entries.clone(),
FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => {
vec![FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
FileSystemAccessMode::Write,
)]
}
};
Self {
entries,
glob_scan_max_depth: value.glob_scan_max_depth.and_then(NonZeroUsize::new),
}
}
}
impl From<&FileSystemPermissions> for FileSystemSandboxPolicy {
fn from(value: &FileSystemPermissions) -> Self {
let mut policy = FileSystemSandboxPolicy::restricted(value.entries.clone());
policy.glob_scan_max_depth = value.glob_scan_max_depth.map(usize::from);
policy
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseInputItem {
Message {
role: String,
content: Vec<ContentItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
phase: Option<MessagePhase>,
},
FunctionCallOutput {
call_id: String,
#[ts(as = "FunctionCallOutputBody")]
#[schemars(with = "FunctionCallOutputBody")]
output: FunctionCallOutputPayload,
},
McpToolCallOutput {
call_id: String,
output: CallToolResult,
},
CustomToolCallOutput {
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
name: Option<String>,
#[ts(as = "FunctionCallOutputBody")]
#[schemars(with = "FunctionCallOutputBody")]
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
call_id: String,
status: String,
execution: String,
#[ts(type = "unknown[]")]
tools: Vec<serde_json::Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentItem {
InputText {
text: String,
},
InputImage {
image_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
detail: Option<ImageDetail>,
},
InputAudio {
audio_url: String,
},
OutputText {
text: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentMessageInputContent {
InputText { text: String },
EncryptedContent { encrypted_content: String },
}
/// Returns the locally readable text when an agent message is entirely plaintext.
pub fn plaintext_agent_message_content(content: &[AgentMessageInputContent]) -> Option<String> {
let mut text_parts = Vec::with_capacity(content.len());
for part in content {
match part {
AgentMessageInputContent::InputText { text } => text_parts.push(text.as_str()),
AgentMessageInputContent::EncryptedContent { .. } => return None,
}
}
let text = text_parts.join("\n");
(!text.trim().is_empty()).then_some(text)
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
pub enum ImageDetail {
Auto,
Low,
High,
Original,
}
pub const DEFAULT_IMAGE_DETAIL: ImageDetail = ImageDetail::High;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
/// Classifies an assistant message as interim commentary or final answer text.
///
/// Providers do not emit this consistently, so callers must treat `None` as
/// "phase unknown" and keep compatibility behavior for legacy models.
pub enum MessagePhase {
/// Mid-turn assistant text (for example preamble/progress narration).
///
/// Additional tool calls or assistant output may follow before turn
/// completion.
Commentary,
/// The assistant's terminal answer text for the current turn.
FinalAnswer,
}
/// Internal Responses API passthrough metadata copied into underlying chat messages.
///
/// Responses API strongly types this payload. Do not modify it without first getting API
/// approval and making the corresponding Responses API change.
#[serde_as]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
pub struct InternalChatMessageMetadataPassthrough {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub turn_id: Option<String>,
/// Message creation time in fractional Unix seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
#[ts(skip)]
pub create_time: Option<serde_json::Number>,
/// Harness-owned classifications aligned with the item's content entries.
#[serde_as(deserialize_as = "serde_with::DefaultOnError")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
#[ts(skip)]
pub content_item_kinds: Option<Vec<ContentItemKind>>,
// Ignore input values so requests cannot fake tool call records.
/// Host-owned Code Mode cell shared by its `exec` and subsequent `wait` outputs.
#[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
#[ts(skip)]
pub cell_id: Option<String>,
/// Warehouse-only Responses metadata, not part of the public app-server protocol.
#[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
#[ts(skip)]
pub executed_tool_calls: Option<Vec<ExecutedToolCall>>,
/// Whether the host finished recording this cell's calls without losing calls or arguments.
/// This describes the call inventory across the cell's outputs, not tool success.
#[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
#[ts(skip)]
pub tool_calls_complete: Option<bool>,
}
impl InternalChatMessageMetadataPassthrough {
pub(crate) fn set_turn_id_if_missing(metadata: &mut Option<Self>, turn_id: &str) {
if turn_id.is_empty()
|| metadata
.as_ref()
.and_then(|metadata| metadata.turn_id.as_deref())
.is_some_and(|turn_id| !turn_id.is_empty())
{
return;
}
metadata.get_or_insert_with(Self::default).turn_id = Some(turn_id.to_string());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem {
#[schemars(skip)]
#[ts(skip)]
AdditionalTools {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<ResponseItemId>,
role: String,
tools: Vec<serde_json::Value>,
},
Message {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
role: String,
content: Vec<ContentItem>,
// Optional output-message phase (for example: "commentary", "final_answer").
// Availability varies by provider/model, so downstream consumers must
// preserve fallback behavior when this is absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
phase: Option<MessagePhase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
AgentMessage {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
author: String,
recipient: String,
content: Vec<AgentMessageInputContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
Reasoning {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
summary: Vec<ReasoningItemReasoningSummary>,
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
#[ts(optional)]
content: Option<Vec<ReasoningItemContent>>,
encrypted_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
LocalShellCall {
/// Legacy id field retained for compatibility with older payloads.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
/// Set when using the Responses API.
call_id: Option<String>,
status: LocalShellStatus,
action: LocalShellAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
FunctionCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
namespace: Option<String>,
// The Responses API returns the function call arguments as a *string* that contains
// JSON, not as an alreadyparsed object. We keep it as a raw string here and let
// Session::handle_function_call parse it into a Value.
arguments: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
encrypted_function_args: Option<Vec<String>>,
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
ToolSearchCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
status: Option<String>,
execution: String,
#[ts(type = "unknown")]
arguments: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
// NOTE: The `output` field for `function_call_output` uses a dedicated payload type with
// custom serialization. On the wire it is either:
// - a plain string (`content`)
// - an array of structured content items (`content_items`)
// We keep this behavior centralized in `FunctionCallOutputPayload`.
FunctionCallOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
namespace: Option<String>,
#[ts(as = "FunctionCallOutputBody")]
#[schemars(with = "FunctionCallOutputBody")]
output: FunctionCallOutputPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
CustomToolCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
status: Option<String>,
call_id: String,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
namespace: Option<String>,
input: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
// `custom_tool_call_output.output` uses the same wire encoding as
// `function_call_output.output` so freeform tools can return either plain
// text or structured content items.
CustomToolCallOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
name: Option<String>,
#[ts(as = "FunctionCallOutputBody")]
#[schemars(with = "FunctionCallOutputBody")]
output: FunctionCallOutputPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
ToolSearchOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
call_id: Option<String>,
status: String,
execution: String,
#[ts(type = "unknown[]")]
tools: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
// Emitted by the Responses API when the agent triggers a web search.
// Example payload (from SSE `response.output_item.done`):
// {
// "id":"ws_...",
// "type":"web_search_call",
// "status":"completed",
// "action": {"type":"search","query":"weather: San Francisco, CA"}
// }
WebSearchCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
action: Option<WebSearchAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
// Emitted by the Responses API when the agent triggers image generation.
// Example payload:
// {
// "id":"ig_123",
// "type":"image_generation_call",
// "status":"completed",
// "revised_prompt":"A gray tabby cat hugging an otter...",
// "result":"..."
// }
ImageGenerationCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
revised_prompt: Option<String>,
result: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
#[serde(alias = "compaction_summary")]
Compaction {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
encrypted_content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
/// A durable input control interpreted by the backend at its position in history.
ConfigurationUpdate {
reasoning: ConfigurationReasoning,
},
// Compaction triggers are request controls, not durable response items.
CompactionTrigger {},
ContextCompaction {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
id: Option<ResponseItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
encrypted_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
},
#[serde(other)]
Other,
}
impl ResponseItem {
/// Returns whether this item is an ordinary user-role message.
pub fn is_user_message(&self) -> bool {
matches!(self, Self::Message { role, .. } if role == "user")
}
/// Returns the Responses API item ID, if present.
pub fn id(&self) -> Option<&ResponseItemId> {
match self {
Self::AdditionalTools { id, .. }
| Self::Message { id, .. }
| Self::AgentMessage { id, .. }
| Self::LocalShellCall { id, .. }
| Self::FunctionCall { id, .. }
| Self::ToolSearchCall { id, .. }
| Self::FunctionCallOutput { id, .. }
| Self::CustomToolCall { id, .. }
| Self::CustomToolCallOutput { id, .. }
| Self::ToolSearchOutput { id, .. }
| Self::WebSearchCall { id, .. }
| Self::Reasoning { id, .. }
| Self::ImageGenerationCall { id, .. }
| Self::Compaction { id, .. }
| Self::ContextCompaction { id, .. } => id.as_ref(),
Self::ConfigurationUpdate { .. } | Self::CompactionTrigger { .. } | Self::Other => None,
}
}
/// Sets or clears the Responses API item ID for variants that carry one.
pub fn set_id(&mut self, new_id: Option<ResponseItemId>) {
match self {
Self::AdditionalTools { id, .. }
| Self::Message { id, .. }
| Self::AgentMessage { id, .. }
| Self::LocalShellCall { id, .. }
| Self::FunctionCall { id, .. }
| Self::ToolSearchCall { id, .. }
| Self::FunctionCallOutput { id, .. }
| Self::CustomToolCall { id, .. }
| Self::CustomToolCallOutput { id, .. }
| Self::ToolSearchOutput { id, .. }
| Self::WebSearchCall { id, .. }
| Self::Reasoning { id, .. }
| Self::ImageGenerationCall { id, .. }
| Self::Compaction { id, .. }
| Self::ContextCompaction { id, .. } => *id = new_id,
Self::ConfigurationUpdate { .. } | Self::CompactionTrigger { .. } | Self::Other => {}
}
}
/// Returns the Responses API item ID prefix for variants that carry an ID.
pub fn id_prefix(&self) -> Option<&'static str> {
match self {
Self::AdditionalTools { .. } => Some("at"),
Self::Message { .. } => Some("msg"),
Self::AgentMessage { .. } => Some("amsg"),
Self::Reasoning { .. } => Some("rs"),
Self::LocalShellCall { .. } => Some("lsh"),
Self::FunctionCall { .. } => Some("fc"),
Self::ToolSearchCall { .. } => Some("tsc"),
Self::FunctionCallOutput { .. } => Some("fco"),
Self::CustomToolCall { .. } => Some("ctc"),
Self::CustomToolCallOutput { .. } => Some("ctco"),
Self::ToolSearchOutput { .. } => Some("tso"),
Self::WebSearchCall { .. } => Some("ws"),
Self::ImageGenerationCall { .. } => Some("ig"),
Self::Compaction { .. } | Self::ContextCompaction { .. } => Some("cmp"),
Self::ConfigurationUpdate { .. } | Self::CompactionTrigger { .. } | Self::Other => None,
}
}
/// Returns the non-empty turn ID stamped onto this item, if present.
pub fn turn_id(&self) -> Option<&str> {
self.internal_chat_message_metadata_passthrough()
.and_then(|metadata| metadata.turn_id.as_deref())
.filter(|turn_id| !turn_id.is_empty())
}
/// Stamps the item with `turn_id` unless it already has a non-empty turn ID.
pub fn set_turn_id_if_missing(&mut self, turn_id: &str) {
let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else {
return;
};
InternalChatMessageMetadataPassthrough::set_turn_id_if_missing(metadata, turn_id);
}
/// Stamps a harness-authored durable item without replacing its creation time.
pub fn set_create_time_if_missing(&mut self, create_time: serde_json::Number) {
let metadata = match self {
Self::Message {
role,
internal_chat_message_metadata_passthrough: metadata,
..
} if matches!(role.as_str(), "user" | "developer") => metadata,
Self::AgentMessage {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::FunctionCallOutput {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::CustomToolCallOutput {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ToolSearchOutput {
internal_chat_message_metadata_passthrough: metadata,
..
} => metadata,
_ => return,
};
metadata
.get_or_insert_default()
.create_time
.get_or_insert(create_time);
}
/// Removes internal chat message metadata passthrough before sending to a provider that does
/// not accept it.
pub fn clear_internal_chat_message_metadata_passthrough(&mut self) {
if let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() {
*metadata = None;
}
}
/// Removes content item classifications while preserving other passthrough metadata.
pub fn clear_content_item_kinds(&mut self) {
let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else {
return;
};
let Some(metadata_value) = metadata else {
return;
};
metadata_value.content_item_kinds = None;
if metadata_value == &InternalChatMessageMetadataPassthrough::default() {
*metadata = None;
}
}
fn internal_chat_message_metadata_passthrough(
&self,
) -> Option<&InternalChatMessageMetadataPassthrough> {
match self {
Self::Message {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::AgentMessage {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::Reasoning {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::LocalShellCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::FunctionCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ToolSearchCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::FunctionCallOutput {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::CustomToolCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::CustomToolCallOutput {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ToolSearchOutput {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::WebSearchCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ImageGenerationCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::Compaction {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ContextCompaction {
internal_chat_message_metadata_passthrough: metadata,
..
} => metadata.as_ref(),
Self::ConfigurationUpdate { .. }
| Self::CompactionTrigger { .. }
| Self::AdditionalTools { .. }
| Self::Other => None,
}
}
fn internal_chat_message_metadata_passthrough_mut(
&mut self,
) -> Option<&mut Option<InternalChatMessageMetadataPassthrough>> {
match self {
Self::Message {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::AgentMessage {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::Reasoning {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::LocalShellCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::FunctionCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ToolSearchCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::FunctionCallOutput {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::CustomToolCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::CustomToolCallOutput {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ToolSearchOutput {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::WebSearchCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ImageGenerationCall {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::Compaction {
internal_chat_message_metadata_passthrough: metadata,
..
}
| Self::ContextCompaction {
internal_chat_message_metadata_passthrough: metadata,
..
} => Some(metadata),
Self::ConfigurationUpdate { .. }
| Self::CompactionTrigger { .. }
| Self::AdditionalTools { .. }
| Self::Other => None,
}
}
}
pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md");
/// Describes whether persisted base instructions were supplied by the user or generated for a model.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[ts(tag = "type")]
pub enum BaseInstructionsProvenance {
/// The instructions were explicitly configured and must survive model changes unchanged.
Custom,
/// The instructions were generated from this model's instruction template.
Model { model: String },
}
/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename = "base_instructions", rename_all = "snake_case")]
pub struct BaseInstructions {
pub text: String,
/// Missing on rollouts written before base-instruction provenance was persisted.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub provenance: Option<BaseInstructionsProvenance>,
}
impl Default for BaseInstructions {
fn default() -> Self {
Self {
text: BASE_INSTRUCTIONS_DEFAULT.to_string(),
provenance: None,
}
}
}
const MAX_RENDERED_PREFIXES: usize = 100;
const MAX_ALLOW_PREFIX_TEXT_BYTES: usize = 5000;
const TRUNCATED_MARKER: &str = "...\n[Some commands were truncated]";
pub fn format_allow_prefixes(prefixes: Vec<Vec<String>>) -> Option<String> {
let mut truncated = false;
if prefixes.len() > MAX_RENDERED_PREFIXES {
truncated = true;
}
let mut prefixes = prefixes;
prefixes.sort_by(|a, b| {
a.len()
.cmp(&b.len())
.then_with(|| prefix_combined_str_len(a).cmp(&prefix_combined_str_len(b)))
.then_with(|| a.cmp(b))
});
let full_text = prefixes
.into_iter()
.take(MAX_RENDERED_PREFIXES)
.map(|prefix| format!("- {}", render_command_prefix(&prefix)))
.collect::<Vec<_>>()
.join("\n");
// truncate to last UTF8 char
let mut output = full_text;
let byte_idx = output
.char_indices()
.nth(MAX_ALLOW_PREFIX_TEXT_BYTES)
.map(|(i, _)| i);
if let Some(byte_idx) = byte_idx {
truncated = true;
output = output[..byte_idx].to_string();
}
if truncated {
Some(format!("{output}{TRUNCATED_MARKER}"))
} else {
Some(output)
}
}
fn prefix_combined_str_len(prefix: &[String]) -> usize {
prefix.iter().map(String::len).sum()
}
fn render_command_prefix(prefix: &[String]) -> String {
let tokens = prefix
.iter()
.map(|token| serde_json::to_string(token).unwrap_or_else(|_| format!("{token:?}")))
.collect::<Vec<_>>()
.join(", ");
format!("[{tokens}]")
}
fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>>) -> bool {
match content {
Some(content) => !content
.iter()
.any(|c| matches!(c, ReasoningItemContent::ReasoningText { .. })),
None => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LocalMediaKind {
Audio,
Image,
}
impl LocalMediaKind {
fn name(self) -> &'static str {
match self {
Self::Audio => "audio",
Self::Image => "image",
}
}
}
fn local_media_error_placeholder(
path: &std::path::Path,
error: impl std::fmt::Display,
media_kind: LocalMediaKind,
) -> ContentItem {
let media_name = media_kind.name();
let path = path.display();
ContentItem::InputText {
text: format!("Codex could not read the local {media_name} at `{path}`: {error}"),
}
}
pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
const IMAGE_OPEN_TAG: &str = "<image>";
const IMAGE_CLOSE_TAG: &str = "</image>";
const LOCAL_IMAGE_OPEN_TAG_PREFIX: &str = "<image name=";
const LOCAL_IMAGE_OPEN_TAG_SUFFIX: &str = ">";
const LOCAL_IMAGE_CLOSE_TAG: &str = IMAGE_CLOSE_TAG;
const AUDIO_OPEN_TAG: &str = "<audio>";
const AUDIO_CLOSE_TAG: &str = "</audio>";
const LOCAL_AUDIO_OPEN_TAG_PREFIX: &str = "<audio name=";
const LOCAL_AUDIO_OPEN_TAG_SUFFIX: &str = ">";
const LOCAL_AUDIO_CLOSE_TAG: &str = AUDIO_CLOSE_TAG;
pub fn image_open_tag_text() -> String {
IMAGE_OPEN_TAG.to_string()
}
pub fn image_close_tag_text() -> String {
IMAGE_CLOSE_TAG.to_string()
}
pub fn local_image_label_text(label_number: usize) -> String {
format!("[Image #{label_number}]")
}
pub fn local_image_open_tag_text_with_path(label_number: usize, path: &std::path::Path) -> String {
let label = local_image_label_text(label_number);
let path = path.display();
format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label} path=\"{path}\"{LOCAL_IMAGE_OPEN_TAG_SUFFIX}")
}
pub fn is_local_image_open_tag_text(text: &str) -> bool {
text.strip_prefix(LOCAL_IMAGE_OPEN_TAG_PREFIX)
.is_some_and(|rest| rest.ends_with(LOCAL_IMAGE_OPEN_TAG_SUFFIX))
}
pub fn is_local_image_close_tag_text(text: &str) -> bool {
is_image_close_tag_text(text)
}
pub fn is_image_open_tag_text(text: &str) -> bool {
text == IMAGE_OPEN_TAG
}
pub fn is_image_close_tag_text(text: &str) -> bool {
text == IMAGE_CLOSE_TAG
}
pub fn audio_open_tag_text() -> String {
AUDIO_OPEN_TAG.to_string()
}
pub fn audio_close_tag_text() -> String {
AUDIO_CLOSE_TAG.to_string()
}
pub fn local_audio_label_text(label_number: usize) -> String {
format!("[Audio #{label_number}]")
}
pub fn local_audio_open_tag_text_with_path(label_number: usize, path: &std::path::Path) -> String {
let label = local_audio_label_text(label_number);
let path = path.display();
format!("{LOCAL_AUDIO_OPEN_TAG_PREFIX}{label} path=\"{path}\"{LOCAL_AUDIO_OPEN_TAG_SUFFIX}")
}
pub fn is_local_audio_open_tag_text(text: &str) -> bool {
text.strip_prefix(LOCAL_AUDIO_OPEN_TAG_PREFIX)
.is_some_and(|rest| rest.ends_with(LOCAL_AUDIO_OPEN_TAG_SUFFIX))
}
pub fn is_local_audio_close_tag_text(text: &str) -> bool {
is_audio_close_tag_text(text)
}
pub fn is_audio_open_tag_text(text: &str) -> bool {
text == AUDIO_OPEN_TAG
}
pub fn is_audio_close_tag_text(text: &str) -> bool {
text == AUDIO_CLOSE_TAG
}
fn invalid_image_error_placeholder(
path: &std::path::Path,
error: impl std::fmt::Display,
) -> ContentItem {
ContentItem::InputText {
text: format!(
"Image located at `{}` is invalid: {}",
path.display(),
error
),
}
}
fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem {
ContentItem::InputText {
text: format!(
"Codex cannot attach image at `{}`: unsupported image `{}`.",
path.display(),
mime
),
}
}
pub fn local_image_content_items_with_label_number(
path: &std::path::Path,
file_bytes: Vec<u8>,
label_number: Option<usize>,
detail: ImageDetail,
) -> Vec<ContentItem> {
let mode = match detail {
ImageDetail::Original => PromptImageMode::Original,
ImageDetail::Auto | ImageDetail::Low | ImageDetail::High => PromptImageMode::ResizeToFit,
};
match load_for_prompt_bytes(path, file_bytes, mode) {
Ok(image) => local_image_content_items(path, image.into_data_url(), label_number, detail),
Err(err) => match &err {
ImageProcessingError::Read { .. }
| ImageProcessingError::Encode { .. }
| ImageProcessingError::InvalidDataUrl { .. }
| ImageProcessingError::ImageTooLarge { .. } => {
vec![local_media_error_placeholder(
path,
&err,
LocalMediaKind::Image,
)]
}
ImageProcessingError::Decode { .. } if err.is_invalid_image() => {
vec![invalid_image_error_placeholder(path, &err)]
}
ImageProcessingError::Decode { .. } => {
vec![local_media_error_placeholder(
path,
&err,
LocalMediaKind::Image,
)]
}
ImageProcessingError::UnsupportedImageFormat { mime } => {
vec![unsupported_image_error_placeholder(path, mime)]
}
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalImagePreparation {
Process,
Defer,
}
fn unsupported_audio_error_placeholder(path: &std::path::Path) -> ContentItem {
ContentItem::InputText {
text: format!(
"Codex cannot attach audio at `{}`: unsupported audio format; use wav, mp3, m4a, webm, or ogg.",
path.display()
),
}
}
fn local_audio_content_items(
path: &std::path::Path,
file_bytes: &[u8],
label_number: usize,
) -> Vec<ContentItem> {
let Some(mime) = audio_mime_for_path(path) else {
return vec![unsupported_audio_error_placeholder(path)];
};
vec![
ContentItem::InputText {
text: local_audio_open_tag_text_with_path(label_number, path),
},
ContentItem::InputAudio {
audio_url: data_url_from_bytes(mime, file_bytes),
},
ContentItem::InputText {
text: LOCAL_AUDIO_CLOSE_TAG.to_string(),
},
]
}
fn local_image_content_items(
path: &std::path::Path,
image_url: String,
label_number: Option<usize>,
detail: ImageDetail,
) -> Vec<ContentItem> {
let mut items = Vec::with_capacity(3);
if let Some(label_number) = label_number {
items.push(ContentItem::InputText {
text: local_image_open_tag_text_with_path(label_number, path),
});
}
items.push(ContentItem::InputImage {
image_url,
detail: Some(detail),
});
if label_number.is_some() {
items.push(ContentItem::InputText {
text: LOCAL_IMAGE_CLOSE_TAG.to_string(),
});
}
items
}
impl From<ResponseInputItem> for ResponseItem {
fn from(item: ResponseInputItem) -> Self {
match item {
ResponseInputItem::Message {
role,
content,
phase,
} => Self::Message {
role,
content,
id: None,
phase,
internal_chat_message_metadata_passthrough: None,
},
ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput {
id: None,
call_id: Some(call_id),
name: None,
namespace: None,
output,
internal_chat_message_metadata_passthrough: None,
},
ResponseInputItem::McpToolCallOutput { call_id, output } => {
let output = output.into_function_call_output_payload();
Self::FunctionCallOutput {
id: None,
call_id: Some(call_id),
name: None,
namespace: None,
output,
internal_chat_message_metadata_passthrough: None,
}
}
ResponseInputItem::CustomToolCallOutput {
call_id,
name,
output,
} => Self::CustomToolCallOutput {
id: None,
call_id,
name,
output,
internal_chat_message_metadata_passthrough: None,
},
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => Self::ToolSearchOutput {
call_id: Some(call_id),
status,
execution,
tools,
id: None,
internal_chat_message_metadata_passthrough: None,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum LocalShellStatus {
Completed,
InProgress,
Incomplete,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LocalShellAction {
Exec(LocalShellExecAction),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
pub struct LocalShellExecAction {
pub command: Vec<String>,
pub timeout_ms: Option<u64>,
pub working_directory: Option<String>,
pub env: Option<HashMap<String, String>>,
pub user: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[schemars(rename = "ResponsesApiWebSearchAction")]
pub enum WebSearchAction {
Search {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
queries: Option<Vec<String>>,
},
OpenPage {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
url: Option<String>,
},
FindInPage {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pattern: Option<String>,
},
#[serde(other)]
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReasoningItemReasoningSummary {
SummaryText { text: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReasoningItemContent {
ReasoningText { text: String },
Text { text: String },
}
impl From<Vec<UserInput>> for ResponseInputItem {
fn from(items: Vec<UserInput>) -> Self {
Self::from_user_input(items, LocalImagePreparation::Process)
}
}
impl ResponseInputItem {
pub fn from_user_input(
items: Vec<UserInput>,
local_image_preparation: LocalImagePreparation,
) -> Self {
let mut image_index = 0;
let mut audio_index = 0;
Self::Message {
role: "user".to_string(),
content: items
.into_iter()
.flat_map(|c| match c {
UserInput::Text { text, .. } => vec![ContentItem::InputText { text }],
UserInput::Image {
image_url, detail, ..
} => {
image_index += 1;
let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
vec![ContentItem::InputImage {
image_url,
detail: Some(detail),
}]
}
UserInput::LocalImage { path, detail, .. } => {
image_index += 1;
let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
match std::fs::read(&path) {
Ok(file_bytes) => match local_image_preparation {
LocalImagePreparation::Process => {
local_image_content_items_with_label_number(
&path,
file_bytes,
Some(image_index),
detail,
)
}
LocalImagePreparation::Defer => local_image_content_items(
&path,
data_url_from_bytes("application/octet-stream", &file_bytes),
Some(image_index),
detail,
),
},
Err(err) => vec![local_media_error_placeholder(
&path,
err,
LocalMediaKind::Image,
)],
}
}
UserInput::Audio { audio_url } => {
audio_index += 1;
vec![ContentItem::InputAudio { audio_url }]
}
UserInput::LocalAudio { path } => {
audio_index += 1;
match std::fs::read(&path) {
Ok(file_bytes) => {
local_audio_content_items(&path, &file_bytes, audio_index)
}
Err(err) => vec![local_media_error_placeholder(
&path,
err,
LocalMediaKind::Audio,
)],
}
}
UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core
})
.collect::<Vec<ContentItem>>(),
phase: None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
pub struct SearchToolCallParams {
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub limit: Option<usize>,
}
/// Responses API compatible content items that can be returned by a tool call.
/// This is a subset of ContentItem with the types we support as function call outputs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FunctionCallOutputContentItem {
// Do not rename, these are serialized and used directly in the responses API.
InputText {
text: String,
},
// Do not rename, these are serialized and used directly in the responses API.
InputImage {
image_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
detail: Option<ImageDetail>,
},
// Do not rename, these are serialized and used directly in the responses API.
InputAudio {
audio_url: String,
},
EncryptedContent {
encrypted_content: String,
},
}
/// Converts structured function-call output content into plain text for
/// human-readable surfaces.
///
/// This conversion is intentionally lossy:
/// - only `input_text` items are included
/// - image and audio items are ignored
///
/// We use this helper where callers still need a string representation (for
/// example telemetry previews or legacy string-only output paths) while keeping
/// the original multimodal `content_items` as the authoritative payload sent to
/// the model.
pub fn function_call_output_content_items_to_text(
content_items: &[FunctionCallOutputContentItem],
) -> Option<String> {
let text_segments = content_items
.iter()
.filter_map(|item| match item {
FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => {
Some(text.as_str())
}
FunctionCallOutputContentItem::InputText { .. }
| FunctionCallOutputContentItem::InputImage { .. }
| FunctionCallOutputContentItem::InputAudio { .. }
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>();
if text_segments.is_empty() {
None
} else {
Some(text_segments.join("\n"))
}
}
impl From<crate::dynamic_tools::DynamicToolCallOutputContentItem>
for FunctionCallOutputContentItem
{
fn from(item: crate::dynamic_tools::DynamicToolCallOutputContentItem) -> Self {
match item {
crate::dynamic_tools::DynamicToolCallOutputContentItem::InputText { text } => {
Self::InputText { text }
}
crate::dynamic_tools::DynamicToolCallOutputContentItem::InputImage { image_url } => {
Self::InputImage {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
}
}
crate::dynamic_tools::DynamicToolCallOutputContentItem::InputAudio { audio_url } => {
Self::InputAudio { audio_url }
}
}
}
}
/// The payload we send back to OpenAI when reporting a tool call result.
///
/// `body` serializes directly as the wire value for `function_call_output.output`.
/// `success` remains internal metadata for downstream handling.
#[derive(Debug, Default, Clone, PartialEq, JsonSchema, TS)]
pub struct FunctionCallOutputPayload {
pub body: FunctionCallOutputBody,
pub success: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(untagged)]
pub enum FunctionCallOutputBody {
Text(String),
ContentItems(Vec<FunctionCallOutputContentItem>),
}
impl FunctionCallOutputBody {
/// Best-effort conversion of a function-call output body to plain text for
/// human-readable surfaces.
///
/// This conversion is intentionally lossy when the body contains content
/// items: image entries are dropped and text entries are joined with
/// newlines.
pub fn to_text(&self) -> Option<String> {
match self {
Self::Text(content) => Some(content.clone()),
Self::ContentItems(items) => function_call_output_content_items_to_text(items),
}
}
}
impl Default for FunctionCallOutputBody {
fn default() -> Self {
Self::Text(String::new())
}
}
impl FunctionCallOutputPayload {
pub fn from_text(content: String) -> Self {
Self {
body: FunctionCallOutputBody::Text(content),
success: None,
}
}
pub fn from_content_items(content_items: Vec<FunctionCallOutputContentItem>) -> Self {
Self {
body: FunctionCallOutputBody::ContentItems(content_items),
success: None,
}
}
pub fn text_content(&self) -> Option<&str> {
match &self.body {
FunctionCallOutputBody::Text(content) => Some(content),
FunctionCallOutputBody::ContentItems(_) => None,
}
}
pub fn content_items(&self) -> Option<&[FunctionCallOutputContentItem]> {
match &self.body {
FunctionCallOutputBody::Text(_) => None,
FunctionCallOutputBody::ContentItems(items) => Some(items),
}
}
pub fn content_items_mut(&mut self) -> Option<&mut Vec<FunctionCallOutputContentItem>> {
match &mut self.body {
FunctionCallOutputBody::Text(_) => None,
FunctionCallOutputBody::ContentItems(items) => Some(items),
}
}
}
// `function_call_output.output` is encoded as either:
// - an array of structured content items
// - a plain string
impl Serialize for FunctionCallOutputPayload {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match &self.body {
FunctionCallOutputBody::Text(content) => serializer.serialize_str(content),
FunctionCallOutputBody::ContentItems(items) => items.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for FunctionCallOutputPayload {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let body = FunctionCallOutputBody::deserialize(deserializer)?;
Ok(FunctionCallOutputPayload {
body,
success: None,
})
}
}
impl CallToolResult {
pub fn from_result(result: Result<Self, String>) -> Self {
match result {
Ok(result) => result,
Err(error) => Self::from_error_text(error),
}
}
pub fn from_error_text(text: String) -> Self {
Self {
content: vec![serde_json::json!({
"type": "text",
"text": text,
})],
structured_content: None,
is_error: Some(true),
meta: None,
}
}
pub fn success(&self) -> bool {
self.is_error != Some(true)
}
pub fn as_function_call_output_payload(&self) -> FunctionCallOutputPayload {
let content_items = convert_mcp_content_to_items(&self.content);
if content_items
.iter()
.any(|item| matches!(item, FunctionCallOutputContentItem::EncryptedContent { .. }))
{
return FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(content_items),
success: Some(self.success()),
};
}
if let Some(structured_content) = &self.structured_content
&& !structured_content.is_null()
{
match serde_json::to_string(structured_content) {
Ok(serialized_structured_content) => {
return FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(serialized_structured_content),
success: Some(self.success()),
};
}
Err(err) => {
return FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(err.to_string()),
success: Some(false),
};
}
}
}
FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(content_items),
success: Some(self.success()),
}
}
pub fn into_function_call_output_payload(self) -> FunctionCallOutputPayload {
self.as_function_call_output_payload()
}
}
fn convert_mcp_content_to_items(
contents: &[serde_json::Value],
) -> Vec<FunctionCallOutputContentItem> {
const CODEX_ENCRYPTED_CONTENT_META_KEY: &str = "codex/encryptedContent";
const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
#[derive(serde::Deserialize)]
#[serde(tag = "type")]
enum McpContent {
#[serde(rename = "text")]
Text {
text: String,
#[serde(rename = "_meta", default)]
meta: Option<serde_json::Value>,
},
#[serde(rename = "image")]
Image {
data: String,
#[serde(rename = "mimeType", alias = "mime_type")]
mime_type: Option<String>,
#[serde(rename = "_meta", default)]
meta: Option<serde_json::Value>,
},
#[serde(rename = "audio")]
Audio {
data: String,
#[serde(rename = "mimeType", alias = "mime_type")]
mime_type: Option<String>,
#[serde(rename = "_meta", default)]
_meta: Option<serde_json::Value>,
},
#[serde(other)]
Unknown,
}
let mut items = Vec::with_capacity(contents.len());
for content in contents {
let item = match serde_json::from_value::<McpContent>(content.clone()) {
Ok(McpContent::Text { text, meta }) => {
if meta
.as_ref()
.and_then(|meta| meta.get(CODEX_ENCRYPTED_CONTENT_META_KEY))
.and_then(serde_json::Value::as_bool)
== Some(true)
{
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: text,
}
} else {
FunctionCallOutputContentItem::InputText { text }
}
}
Ok(McpContent::Image {
data,
mime_type,
meta,
}) => {
let image_url = if data.starts_with("data:") {
data
} else {
let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
format!("data:{mime_type};base64,{data}")
};
FunctionCallOutputContentItem::InputImage {
image_url,
detail: meta
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY))
.and_then(serde_json::Value::as_str)
.and_then(|detail| match detail {
"auto" => Some(ImageDetail::Auto),
"low" => Some(ImageDetail::Low),
"high" => Some(ImageDetail::High),
"original" => Some(ImageDetail::Original),
_ => None,
})
.or(Some(DEFAULT_IMAGE_DETAIL)),
}
}
Ok(McpContent::Audio {
data, mime_type, ..
}) => {
let audio_url = if data.starts_with("data:") {
data
} else {
let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
format!("data:{mime_type};base64,{data}")
};
FunctionCallOutputContentItem::InputAudio { audio_url }
}
Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText {
text: serde_json::to_string(content).unwrap_or_else(|_| "<content>".to_string()),
},
};
items.push(item);
}
items
}
// Implement Display so callers can treat the payload like a plain string when logging or doing
// trivial substring checks in tests (existing tests call `.contains()` on the output). For
// `ContentItems`, Display emits a JSON representation.
impl std::fmt::Display for FunctionCallOutputPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.body {
FunctionCallOutputBody::Text(content) => f.write_str(content),
FunctionCallOutputBody::ContentItems(items) => {
let content = serde_json::to_string(items).unwrap_or_default();
f.write_str(content.as_str())
}
}
}
}
// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.)
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use codex_execpolicy::Policy;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::tempdir;
// A tiny valid PNG (1x1) so image conversion tests don't depend on cross-crate
// file paths, which break under Bazel sandboxing.
const TINY_PNG_BYTES: &[u8] = &[
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,
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,
1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
#[test]
fn base_instructions_preserve_provenance_and_accept_legacy_rollouts() -> Result<()> {
let legacy: BaseInstructions =
serde_json::from_value(serde_json::json!({ "text": "legacy instructions" }))?;
assert_eq!(legacy.provenance, None);
for provenance in [
BaseInstructionsProvenance::Custom,
BaseInstructionsProvenance::Model {
model: "gpt-5.2".to_string(),
},
] {
let instructions = BaseInstructions {
text: "persisted instructions".to_string(),
provenance: Some(provenance),
};
assert_eq!(
serde_json::from_value::<BaseInstructions>(serde_json::to_value(&instructions)?)?,
instructions
);
}
Ok(())
}
#[test]
fn plaintext_agent_message_content_rejects_mixed_encrypted_content() {
let content = vec![
AgentMessageInputContent::InputText {
text: "Message Type: MESSAGE\nPayload:\n".to_string(),
},
AgentMessageInputContent::EncryptedContent {
encrypted_content: "encrypted-payload".to_string(),
},
];
assert_eq!(plaintext_agent_message_content(&content), None);
}
#[test]
fn response_input_message_conversion_preserves_phase() {
let item = ResponseItem::from(ResponseInputItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "still working".to_string(),
}],
phase: Some(MessagePhase::Commentary),
});
assert_eq!(
item,
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "still working".to_string(),
}],
phase: Some(MessagePhase::Commentary),
internal_chat_message_metadata_passthrough: None,
}
);
}
#[test]
fn response_item_passthrough_metadata_round_trips_and_stamps_turn_ids() -> Result<()> {
let mut item =
response_item_with_passthrough_metadata(Some(passthrough_metadata("turn-1")));
let round_trip: ResponseItem = serde_json::from_value(serde_json::to_value(&item)?)?;
assert_eq!(round_trip, item);
let unknown_metadata: ResponseItem = serde_json::from_value(serde_json::json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
"internal_chat_message_metadata_passthrough": {
"turn_id": "turn-1",
"other": "ignored",
},
}))?;
assert_eq!(unknown_metadata, item);
item.append_executed_tool_calls(vec![ExecutedToolCall::new(
"test_tool".to_string(),
serde_json::json!({"value": 1}),
)]);
let serialized = serde_json::to_value(&item)?;
assert_eq!(
serialized["internal_chat_message_metadata_passthrough"]["executed_tool_calls"],
serde_json::json!([{"name": "test_tool", "arguments": {"value": 1}}]),
);
let deserialized = serde_json::from_value::<ResponseItem>(serialized)?;
assert_eq!(deserialized.turn_id(), Some("turn-1"));
assert!(
deserialized
.executed_tool_call_metadata()
.and_then(|metadata| metadata.executed_tool_calls.as_ref())
.is_none(),
"provider and rollout input cannot forge local attempted-tool metadata",
);
item.clear_executed_tool_calls();
item.set_turn_id_if_missing("turn-2");
assert_eq!(item.turn_id(), Some("turn-1"));
let mut empty_turn_id =
response_item_with_passthrough_metadata(Some(passthrough_metadata("")));
empty_turn_id.set_turn_id_if_missing("turn-1");
assert_eq!(empty_turn_id.turn_id(), Some("turn-1"));
let mut missing_turn_id = response_item_with_passthrough_metadata(
/*internal_chat_message_metadata_passthrough*/ None,
);
let create_time = serde_json::Number::from(123);
missing_turn_id.set_create_time_if_missing(create_time.clone());
missing_turn_id.set_turn_id_if_missing("");
missing_turn_id.set_turn_id_if_missing("turn-1");
missing_turn_id.set_create_time_if_missing(serde_json::Number::from(456));
assert_eq!(
missing_turn_id.executed_tool_call_metadata(),
Some(&InternalChatMessageMetadataPassthrough {
turn_id: Some("turn-1".to_string()),
create_time: Some(create_time),
..Default::default()
})
);
let mut other = ResponseItem::Other;
other.set_turn_id_if_missing("turn-1");
assert_eq!(other.turn_id(), None);
Ok(())
}
#[test]
fn response_item_id_getter_and_setter() {
let mut item = response_item_with_passthrough_metadata(
/*internal_chat_message_metadata_passthrough*/ None,
);
assert_eq!(item.id(), None);
item.set_id(Some(ResponseItemId::with_suffix("msg", "test")));
assert_eq!(item.id().map(ResponseItemId::as_str), Some("msg_test"));
item.set_id(/*new_id*/ None);
assert_eq!(item.id(), None);
let mut additional_tools = ResponseItem::AdditionalTools {
id: None,
role: "developer".to_string(),
tools: Vec::new(),
};
additional_tools.set_id(Some(ResponseItemId::with_suffix("at", "test")));
assert_eq!(
additional_tools.id().map(ResponseItemId::as_str),
Some("at_test")
);
}
fn response_item_with_passthrough_metadata(
internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "hello".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough,
}
}
fn passthrough_metadata(turn_id: &str) -> InternalChatMessageMetadataPassthrough {
InternalChatMessageMetadataPassthrough {
turn_id: Some(turn_id.to_string()),
..Default::default()
}
}
#[test]
fn image_detail_roundtrips_all_wire_values() -> Result<()> {
assert_eq!(
serde_json::from_str::<ImageDetail>("\"auto\"")?,
ImageDetail::Auto
);
assert_eq!(
serde_json::from_str::<ImageDetail>("\"low\"")?,
ImageDetail::Low
);
assert_eq!(serde_json::to_string(&ImageDetail::Auto)?, "\"auto\"");
assert_eq!(serde_json::to_string(&ImageDetail::Low)?, "\"low\"");
let content_item: ContentItem = serde_json::from_value(serde_json::json!({
"type": "input_image",
"image_url": "data:image/png;base64,abc",
"detail": "auto",
}))?;
assert_eq!(
content_item,
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: Some(ImageDetail::Auto),
}
);
Ok(())
}
#[test]
fn sandbox_permissions_helpers_match_documented_semantics() {
let cases = [
(SandboxPermissions::UseDefault, false, false, false),
(SandboxPermissions::RequireEscalated, true, true, false),
(
SandboxPermissions::WithAdditionalPermissions,
false,
true,
true,
),
];
for (
sandbox_permissions,
requires_escalated_permissions,
requests_sandbox_override,
uses_additional_permissions,
) in cases
{
assert_eq!(
sandbox_permissions.requires_escalated_permissions(),
requires_escalated_permissions
);
assert_eq!(
sandbox_permissions.requests_sandbox_override(),
requests_sandbox_override
);
assert_eq!(
sandbox_permissions.uses_additional_permissions(),
uses_additional_permissions
);
}
}
#[test]
fn convert_mcp_content_to_items_preserves_data_urls() {
let contents = vec![serde_json::json!({
"type": "image",
"data": "data:image/png;base64,Zm9v",
"mimeType": "image/png",
})];
let items = convert_mcp_content_to_items(&contents);
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,Zm9v".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
}]
);
}
#[test]
fn response_item_parses_image_generation_call() {
let item = serde_json::from_value::<ResponseItem>(serde_json::json!({
"id": "ig_123",
"type": "image_generation_call",
"status": "completed",
"revised_prompt": "A small blue square",
"result": "Zm9v",
}))
.expect("image generation item should deserialize");
assert_eq!(
item,
ResponseItem::ImageGenerationCall {
id: Some(ResponseItemId::with_suffix("ig", "123")),
status: "completed".to_string(),
revised_prompt: Some("A small blue square".to_string()),
result: "Zm9v".to_string(),
internal_chat_message_metadata_passthrough: None,
}
);
}
#[test]
fn response_item_parses_image_generation_call_without_revised_prompt() {
let item = serde_json::from_value::<ResponseItem>(serde_json::json!({
"id": "ig_123",
"type": "image_generation_call",
"status": "completed",
"result": "Zm9v",
}))
.expect("image generation item should deserialize");
assert_eq!(
item,
ResponseItem::ImageGenerationCall {
id: Some(ResponseItemId::with_suffix("ig", "123")),
status: "completed".to_string(),
revised_prompt: None,
result: "Zm9v".to_string(),
internal_chat_message_metadata_passthrough: None,
}
);
}
#[test]
fn additional_permission_profile_is_empty_when_all_fields_are_none() {
assert_eq!(AdditionalPermissionProfile::default().is_empty(), true);
}
#[test]
fn additional_permission_profile_is_not_empty_when_field_is_present_but_nested_empty() {
let permission_profile = AdditionalPermissionProfile {
network: Some(NetworkPermissions { enabled: None }),
file_system: None,
};
assert_eq!(permission_profile.is_empty(), false);
}
#[test]
fn permission_profile_round_trip_preserves_glob_scan_max_depth() {
let mut file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/*.env".to_string(),
},
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
}]);
file_system_sandbox_policy.glob_scan_max_depth = Some(2);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
permission_profile.file_system_sandbox_policy(),
file_system_sandbox_policy
);
}
#[cfg(unix)]
#[test]
fn permission_profile_round_trip_preserves_ambiguous_native_denies() -> Result<()> {
let denied_path = AbsolutePathBuf::try_from(PathBuf::from("/C:/secret"))?;
let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry::new(
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
FileSystemAccessMode::Read,
),
FileSystemSandboxEntry::new(denied_path.into(), FileSystemAccessMode::Deny),
]);
let permission_profile = PermissionProfile::from_runtime_permissions(
&file_system_sandbox_policy,
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
serde_json::from_value::<PermissionProfile>(serde_json::to_value(
&permission_profile
)?)?,
permission_profile
);
Ok(())
}
#[test]
fn permission_profile_deserializes_legacy_rollout_shape() -> Result<()> {
let legacy = serde_json::json!({
"network": {
"enabled": true,
},
"file_system": {
"entries": [{
"path": {
"type": "special",
"value": {
"kind": "root",
},
},
"access": "write",
}],
"glob_scan_max_depth": 2,
},
});
let permission_profile: PermissionProfile = serde_json::from_value(legacy)?;
assert_eq!(
permission_profile,
PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Write,
missing_path_behavior: None,
}],
glob_scan_max_depth: NonZeroUsize::new(2),
},
network: NetworkSandboxPolicy::Enabled,
}
);
Ok(())
}
#[test]
fn permission_profile_presets_match_legacy_defaults() {
assert_eq!(
PermissionProfile::read_only(),
PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy())
);
assert_eq!(
PermissionProfile::workspace_write(),
PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_workspace_write_policy()
)
);
}
#[test]
fn permission_profile_round_trip_preserves_disabled_sandbox() -> Result<()> {
let cwd = tempdir()?;
let permission_profile =
PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::DangerFullAccess);
assert_eq!(permission_profile, PermissionProfile::Disabled);
assert_eq!(
permission_profile.to_legacy_sandbox_policy(cwd.path())?,
SandboxPolicy::DangerFullAccess
);
assert_eq!(
permission_profile.to_runtime_permissions(),
(
FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled
)
);
Ok(())
}
#[test]
fn disabled_permission_profile_ignores_runtime_network_policy() {
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::Disabled,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
);
assert_eq!(permission_profile, PermissionProfile::Disabled);
}
#[test]
fn permission_profile_from_runtime_permissions_preserves_external_sandbox() {
let permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::external_sandbox(),
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
permission_profile,
PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
}
);
assert_eq!(
PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::Managed,
&FileSystemSandboxPolicy::external_sandbox(),
NetworkSandboxPolicy::Restricted,
),
permission_profile,
);
}
#[test]
fn permission_profile_from_runtime_permissions_preserves_unrestricted_managed_network() {
let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::External,
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
);
assert_eq!(
permission_profile,
PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Unrestricted,
network: NetworkSandboxPolicy::Restricted,
},
"the legacy ExternalSandbox projection must not hide a split unrestricted filesystem policy"
);
assert_eq!(
permission_profile.to_runtime_permissions(),
(
FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Restricted,
)
);
}
#[test]
fn permission_profile_round_trip_preserves_external_sandbox() -> Result<()> {
let cwd = tempdir()?;
let sandbox_policy = SandboxPolicy::ExternalSandbox {
network_access: crate::protocol::NetworkAccess::Restricted,
};
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy);
assert_eq!(
permission_profile,
PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
}
);
assert_eq!(
permission_profile.to_legacy_sandbox_policy(cwd.path())?,
sandbox_policy
);
assert_eq!(
permission_profile.to_runtime_permissions(),
(
FileSystemSandboxPolicy::external_sandbox(),
NetworkSandboxPolicy::Restricted
)
);
Ok(())
}
#[test]
fn file_system_permissions_with_glob_scan_depth_uses_canonical_json() -> Result<()> {
let path = AbsolutePathBuf::try_from(PathBuf::from(if cfg!(windows) {
r"C:\tmp\allowed"
} else {
"/tmp/allowed"
}))
.expect("absolute path");
let file_system_permissions = FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: path.into(),
access: FileSystemAccessMode::Read,
missing_path_behavior: None,
}],
glob_scan_max_depth: NonZeroUsize::new(2),
};
let serialized = serde_json::to_value(&file_system_permissions)?;
assert_eq!(serialized.get("read"), None);
assert_eq!(serialized.get("write"), None);
assert_eq!(
serialized.get("glob_scan_max_depth"),
Some(&serde_json::json!(2))
);
assert!(serialized.get("entries").is_some());
assert_eq!(
serde_json::from_value::<FileSystemPermissions>(serialized)?,
file_system_permissions
);
Ok(())
}
#[test]
fn file_system_permissions_rejects_zero_glob_scan_depth() {
serde_json::from_value::<FileSystemPermissions>(serde_json::json!({
"entries": [],
"glob_scan_max_depth": 0,
}))
.expect_err("zero glob scan depth should fail deserialization");
}
#[test]
fn convert_mcp_content_to_items_builds_data_urls_when_missing_prefix() {
let contents = vec![serde_json::json!({
"type": "image",
"data": "Zm9v",
"mimeType": "image/png",
})];
let items = convert_mcp_content_to_items(&contents);
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,Zm9v".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
}]
);
}
#[test]
fn convert_mcp_audio_content_builds_data_urls_and_preserves_existing_data_urls() {
let contents = vec![
serde_json::json!({
"type": "audio",
"data": "Zm9v",
"mimeType": "audio/wav",
"_meta": {"source": "microphone"},
}),
serde_json::json!({
"type": "audio",
"data": "data:audio/ogg;base64,YmFy",
"mimeType": "audio/ogg",
}),
];
assert_eq!(
convert_mcp_content_to_items(&contents),
vec![
FunctionCallOutputContentItem::InputAudio {
audio_url: "data:audio/wav;base64,Zm9v".to_string(),
},
FunctionCallOutputContentItem::InputAudio {
audio_url: "data:audio/ogg;base64,YmFy".to_string(),
},
]
);
}
#[test]
fn convert_mcp_content_to_items_converts_text_without_media() {
let contents = vec![serde_json::json!({
"type": "text",
"text": "hello",
})];
assert_eq!(
convert_mcp_content_to_items(&contents),
vec![FunctionCallOutputContentItem::InputText {
text: "hello".to_string(),
}]
);
}
#[test]
fn function_call_output_content_items_to_text_joins_text_segments() {
let content_items = vec![
FunctionCallOutputContentItem::InputText {
text: "line 1".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::InputText {
text: "line 2".to_string(),
},
];
let text = function_call_output_content_items_to_text(&content_items);
assert_eq!(text, Some("line 1\nline 2".to_string()));
}
#[test]
fn function_call_output_content_items_to_text_ignores_blank_text_and_media() {
let content_items = vec![
FunctionCallOutputContentItem::InputText {
text: " ".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::InputAudio {
audio_url: "data:audio/wav;base64,AAA".to_string(),
},
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_opaque".to_string(),
},
];
let text = function_call_output_content_items_to_text(&content_items);
assert_eq!(text, None);
}
#[test]
fn function_call_output_body_to_text_returns_plain_text_content() {
let body = FunctionCallOutputBody::Text("ok".to_string());
let text = body.to_text();
assert_eq!(text, Some("ok".to_string()));
}
#[test]
fn function_call_output_body_to_text_uses_content_item_fallback() {
let body = FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "line 1".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
]);
let text = body.to_text();
assert_eq!(text, Some("line 1".to_string()));
}
#[test]
fn function_call_deserializes_optional_namespace() {
let item: ResponseItem = serde_json::from_value(serde_json::json!({
"type": "function_call",
"name": "mcp__codex_apps__gmail_get_recent_emails",
"namespace": "mcp__codex_apps__gmail",
"arguments": "{\"top_k\":5}",
"call_id": "call-1",
}))
.expect("function_call should deserialize");
assert_eq!(
item,
ResponseItem::FunctionCall {
id: None,
name: "mcp__codex_apps__gmail_get_recent_emails".to_string(),
namespace: Some("mcp__codex_apps__gmail".to_string()),
arguments: "{\"top_k\":5}".to_string(),
encrypted_function_args: None,
call_id: "call-1".to_string(),
internal_chat_message_metadata_passthrough: None,
}
);
}
#[test]
fn paired_function_call_output_preserves_existing_wire_shape() {
let value = serde_json::json!({
"type": "function_call_output",
"call_id": "call-1",
"output": "done",
});
let item: ResponseItem = serde_json::from_value(value.clone())
.expect("paired function call output should deserialize");
assert_eq!(
item,
ResponseItem::FunctionCallOutput {
id: None,
call_id: Some("call-1".to_string()),
name: None,
namespace: None,
output: FunctionCallOutputPayload::from_text("done".to_string()),
internal_chat_message_metadata_passthrough: None,
}
);
assert_eq!(serde_json::to_value(item).expect("serialize item"), value);
}
#[test]
fn named_unpaired_function_call_output_round_trips_without_call_id() {
let value = serde_json::json!({
"type": "function_call_output",
"name": "notifications",
"namespace": "slack",
"output": "Alice mentioned you.",
});
let item: ResponseItem = serde_json::from_value(value.clone())
.expect("named unpaired function call output should deserialize");
assert_eq!(
item,
ResponseItem::FunctionCallOutput {
id: None,
call_id: None,
name: Some("notifications".to_string()),
namespace: Some("slack".to_string()),
output: FunctionCallOutputPayload::from_text("Alice mentioned you.".to_string()),
internal_chat_message_metadata_passthrough: None,
}
);
assert_eq!(serde_json::to_value(item).expect("serialize item"), value);
}
#[test]
fn function_call_preserves_empty_encrypted_function_args() {
let value = serde_json::json!({
"type": "function_call",
"name": "spawn_agent",
"namespace": "collaboration",
"arguments": "{\"message\":\"hello\",\"task_name\":\"worker\"}",
"encrypted_function_args": [],
"call_id": "call-1",
});
let item: ResponseItem =
serde_json::from_value(value.clone()).expect("function_call should deserialize");
assert_eq!(serde_json::to_value(item).expect("serialize item"), value);
}
#[test]
fn render_command_prefix_list_sorts_by_len_then_total_len_then_alphabetical() {
let prefixes = vec![
vec!["b".to_string(), "zz".to_string()],
vec!["aa".to_string()],
vec!["b".to_string()],
vec!["a".to_string(), "b".to_string(), "c".to_string()],
vec!["a".to_string()],
vec!["b".to_string(), "a".to_string()],
];
let output = format_allow_prefixes(prefixes).expect("rendered list");
assert_eq!(
output,
r#"- ["a"]
- ["b"]
- ["aa"]
- ["b", "a"]
- ["b", "zz"]
- ["a", "b", "c"]"#
.to_string(),
);
}
#[test]
fn render_command_prefix_list_limits_output_to_max_prefixes() {
let prefixes = (0..(MAX_RENDERED_PREFIXES + 5))
.map(|i| vec![format!("{i:03}")])
.collect::<Vec<_>>();
let output = format_allow_prefixes(prefixes).expect("rendered list");
assert_eq!(output.ends_with(TRUNCATED_MARKER), true);
eprintln!("output: {output}");
assert_eq!(output.lines().count(), MAX_RENDERED_PREFIXES + 1);
}
#[test]
fn format_allow_prefixes_limits_output() {
let mut exec_policy = Policy::empty();
for i in 0..200 {
exec_policy
.add_prefix_rule(
&[format!("tool-{i:03}"), "x".repeat(500)],
codex_execpolicy::Decision::Allow,
)
.expect("add rule");
}
let output =
format_allow_prefixes(exec_policy.get_allowed_prefixes()).expect("formatted prefixes");
assert!(
output.len() <= MAX_ALLOW_PREFIX_TEXT_BYTES + TRUNCATED_MARKER.len(),
"output length exceeds expected limit: {output}",
);
}
#[test]
fn serializes_success_as_plain_string() -> Result<()> {
let item = ResponseInputItem::FunctionCallOutput {
call_id: "call1".into(),
output: FunctionCallOutputPayload::from_text("ok".into()),
};
let json = serde_json::to_string(&item)?;
let v: serde_json::Value = serde_json::from_str(&json)?;
// Success case -> output should be a plain string
assert_eq!(v.get("output").unwrap().as_str().unwrap(), "ok");
Ok(())
}
#[test]
fn serializes_failure_as_string() -> Result<()> {
let item = ResponseInputItem::FunctionCallOutput {
call_id: "call1".into(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text("bad".into()),
success: Some(false),
},
};
let json = serde_json::to_string(&item)?;
let v: serde_json::Value = serde_json::from_str(&json)?;
assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
Ok(())
}
#[test]
fn converts_unstructured_mcp_content_to_items() {
let content = vec![
serde_json::json!({"type":"text","text":"caption"}),
serde_json::json!({
"type": "resource_link",
"uri": "file:///notes.txt",
"name": "notes",
}),
serde_json::json!({
"type": "audio",
"mimeType": "audio/wav",
}),
];
let call_tool_result = CallToolResult {
content: content.clone(),
structured_content: Some(serde_json::Value::Null),
is_error: Some(false),
meta: None,
};
let resource_link = serde_json::to_string(&content[1]).expect("serialize resource link");
let malformed_audio =
serde_json::to_string(&content[2]).expect("serialize malformed audio");
assert_eq!(
call_tool_result.as_function_call_output_payload(),
FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "caption".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: resource_link,
},
FunctionCallOutputContentItem::InputText {
text: malformed_audio,
},
]),
success: Some(true),
}
);
}
#[test]
fn preserves_structured_mcp_content() {
let call_tool_result = CallToolResult {
content: vec![serde_json::json!({"type":"text","text":"ignored"})],
structured_content: Some(serde_json::json!({"result":"structured"})),
is_error: Some(false),
meta: None,
};
assert_eq!(
call_tool_result.as_function_call_output_payload(),
FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(
serde_json::json!({"result":"structured"}).to_string(),
),
success: Some(true),
}
);
}
#[test]
fn serializes_image_outputs_as_array() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![
serde_json::json!({"type":"text","text":"caption"}),
serde_json::json!({"type":"image","data":"BASE64","mimeType":"image/png"}),
],
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = call_tool_result.into_function_call_output_payload();
assert_eq!(payload.success, Some(true));
let Some(items) = payload.content_items() else {
panic!("expected content items");
};
let items = items.to_vec();
assert_eq!(
items,
vec![
FunctionCallOutputContentItem::InputText {
text: "caption".into(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
]
);
let item = ResponseInputItem::FunctionCallOutput {
call_id: "call1".into(),
output: payload,
};
let json = serde_json::to_string(&item)?;
let v: serde_json::Value = serde_json::from_str(&json)?;
let output = v.get("output").expect("output field");
assert!(output.is_array(), "expected array output");
Ok(())
}
#[test]
fn serializes_audio_outputs_as_array() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![
serde_json::json!({"type":"text","text":"caption"}),
serde_json::json!({"type":"audio","data":"BASE64","mimeType":"audio/wav"}),
],
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = call_tool_result.into_function_call_output_payload();
assert_eq!(
payload,
FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "caption".into(),
},
FunctionCallOutputContentItem::InputAudio {
audio_url: "data:audio/wav;base64,BASE64".into(),
},
]),
success: Some(true),
}
);
let item = ResponseInputItem::FunctionCallOutput {
call_id: "call1".into(),
output: payload,
};
assert_eq!(
serde_json::to_value(item)?,
serde_json::json!({
"type": "function_call_output",
"call_id": "call1",
"output": [
{"type": "input_text", "text": "caption"},
{"type": "input_audio", "audio_url": "data:audio/wav;base64,BASE64"},
],
})
);
Ok(())
}
#[test]
fn serializes_custom_tool_image_outputs_as_array() -> Result<()> {
let item = ResponseInputItem::CustomToolCallOutput {
call_id: "call1".into(),
name: None,
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
]),
};
let json = serde_json::to_string(&item)?;
let v: serde_json::Value = serde_json::from_str(&json)?;
let output = v.get("output").expect("output field");
assert!(output.is_array(), "expected array output");
Ok(())
}
#[test]
fn serializes_encrypted_function_output_content_as_array() -> Result<()> {
let item = ResponseInputItem::FunctionCallOutput {
call_id: "call1".into(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_opaque".into(),
},
]),
};
let json = serde_json::to_value(&item)?;
assert_eq!(
json,
serde_json::json!({
"type": "function_call_output",
"call_id": "call1",
"output": [
{
"type": "encrypted_content",
"encrypted_content": "enc_opaque",
}
],
})
);
Ok(())
}
#[test]
fn preserves_existing_image_data_urls() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![serde_json::json!({
"type": "image",
"data": "data:image/png;base64,BASE64",
"mimeType": "image/png"
})],
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = call_tool_result.into_function_call_output_payload();
let Some(items) = payload.content_items() else {
panic!("expected content items");
};
let items = items.to_vec();
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
detail: Some(DEFAULT_IMAGE_DETAIL),
}]
);
Ok(())
}
#[test]
fn preserves_original_detail_metadata_on_mcp_images() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![serde_json::json!({
"type": "image",
"data": "BASE64",
"mimeType": "image/png",
"_meta": {
"codex/imageDetail": "original",
},
})],
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = call_tool_result.into_function_call_output_payload();
let Some(items) = payload.content_items() else {
panic!("expected content items");
};
let items = items.to_vec();
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
detail: Some(ImageDetail::Original),
}]
);
Ok(())
}
#[test]
fn preserves_standard_detail_metadata_on_mcp_images() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![serde_json::json!({
"type": "image",
"data": "BASE64",
"mimeType": "image/png",
"_meta": {
"codex/imageDetail": "high",
},
})],
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = call_tool_result.into_function_call_output_payload();
let Some(items) = payload.content_items() else {
panic!("expected content items");
};
let items = items.to_vec();
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
detail: Some(ImageDetail::High),
}]
);
Ok(())
}
#[test]
fn deserializes_array_payload_into_items() -> Result<()> {
let json = r#"[
{"type": "input_text", "text": "note"},
{"type": "input_image", "image_url": "data:image/png;base64,XYZ"}
]"#;
let payload: FunctionCallOutputPayload = serde_json::from_str(json)?;
assert_eq!(payload.success, None);
let expected_items = vec![
FunctionCallOutputContentItem::InputText {
text: "note".into(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,XYZ".into(),
detail: None,
},
];
assert_eq!(
payload.body,
FunctionCallOutputBody::ContentItems(expected_items.clone())
);
assert_eq!(
serde_json::to_string(&payload)?,
serde_json::to_string(&expected_items)?
);
Ok(())
}
#[test]
fn deserializes_encrypted_array_payload_into_items() -> Result<()> {
let json = r#"[
{"type": "encrypted_content", "encrypted_content": "enc_opaque"}
]"#;
let payload: FunctionCallOutputPayload = serde_json::from_str(json)?;
let expected_items = vec![FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_opaque".into(),
}];
assert_eq!(payload.success, None);
assert_eq!(
payload.body,
FunctionCallOutputBody::ContentItems(expected_items.clone())
);
assert_eq!(
serde_json::to_string(&payload)?,
serde_json::to_string(&expected_items)?
);
Ok(())
}
#[test]
fn deserializes_compaction_alias() -> Result<()> {
let json = r#"{"type":"compaction_summary","encrypted_content":"abc"}"#;
let item: ResponseItem = serde_json::from_str(json)?;
assert_eq!(
item,
ResponseItem::Compaction {
id: None,
encrypted_content: "abc".into(),
internal_chat_message_metadata_passthrough: None,
}
);
Ok(())
}
#[test]
fn deserializes_context_compaction() -> Result<()> {
let json = r#"{"type":"context_compaction","encrypted_content":"abc"}"#;
let item: ResponseItem = serde_json::from_str(json)?;
assert_eq!(
item,
ResponseItem::ContextCompaction {
id: None,
encrypted_content: Some("abc".into()),
internal_chat_message_metadata_passthrough: None,
}
);
Ok(())
}
#[test]
fn serializes_compaction_trigger_without_payload() -> Result<()> {
let item = ResponseItem::CompactionTrigger {};
assert_eq!(
serde_json::to_value(item)?,
serde_json::json!({
"type": "compaction_trigger",
})
);
Ok(())
}
#[test]
fn deserializes_compaction_trigger_without_payload() -> Result<()> {
let json = r#"{"type":"compaction_trigger"}"#;
let item: ResponseItem = serde_json::from_str(json)?;
assert_eq!(item, ResponseItem::CompactionTrigger {});
Ok(())
}
#[test]
fn deserializes_legacy_ghost_snapshot_as_other() -> Result<()> {
let json = r#"{
"type":"ghost_snapshot",
"ghost_commit":{
"id":"ghost-1",
"parent":null,
"preexisting_untracked_files":[],
"preexisting_untracked_dirs":[]
}
}"#;
let item: ResponseItem = serde_json::from_str(json)?;
assert_eq!(item, ResponseItem::Other);
Ok(())
}
#[test]
fn roundtrips_web_search_call_actions() -> Result<()> {
let cases = vec![
(
r#"{
"type": "web_search_call",
"status": "completed",
"action": {
"type": "search",
"query": "weather seattle",
"queries": ["weather seattle", "seattle weather now"]
}
}"#,
None,
Some(WebSearchAction::Search {
query: Some("weather seattle".into()),
queries: Some(vec!["weather seattle".into(), "seattle weather now".into()]),
}),
Some("completed".into()),
),
(
r#"{
"type": "web_search_call",
"status": "open",
"action": {
"type": "open_page",
"url": "https://example.com"
}
}"#,
None,
Some(WebSearchAction::OpenPage {
url: Some("https://example.com".into()),
}),
Some("open".into()),
),
(
r#"{
"type": "web_search_call",
"status": "in_progress",
"action": {
"type": "find_in_page",
"url": "https://example.com/docs",
"pattern": "installation"
}
}"#,
None,
Some(WebSearchAction::FindInPage {
url: Some("https://example.com/docs".into()),
pattern: Some("installation".into()),
}),
Some("in_progress".into()),
),
(
r#"{
"type": "web_search_call",
"status": "in_progress",
"id": "ws_partial"
}"#,
Some(ResponseItemId::with_suffix("ws", "partial")),
None,
Some("in_progress".into()),
),
];
for (json_literal, expected_id, expected_action, expected_status) in cases {
let parsed: ResponseItem = serde_json::from_str(json_literal)?;
let expected = ResponseItem::WebSearchCall {
id: expected_id.clone(),
status: expected_status.clone(),
action: expected_action.clone(),
internal_chat_message_metadata_passthrough: None,
};
assert_eq!(parsed, expected);
let serialized = serde_json::to_value(&parsed)?;
let expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?;
assert_eq!(serialized, expected_serialized);
}
Ok(())
}
#[test]
fn serializes_image_user_input_without_tags() -> Result<()> {
let image_url = "data:image/png;base64,abc".to_string();
let item = ResponseInputItem::from(vec![UserInput::Image {
image_url: image_url.clone(),
detail: None,
}]);
match item {
ResponseInputItem::Message { content, .. } => {
let expected = vec![ContentItem::InputImage {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
}];
assert_eq!(content, expected);
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
#[test]
fn serializes_audio_user_input_without_tags() -> Result<()> {
let audio_url = "data:audio/wav;base64,abc".to_string();
let item = ResponseInputItem::from(vec![UserInput::Audio {
audio_url: audio_url.clone(),
}]);
assert_eq!(
item,
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputAudio { audio_url }],
phase: None,
}
);
assert_eq!(
serde_json::to_value(item)?,
serde_json::json!({
"type": "message",
"role": "user",
"content": [
{
"type": "input_audio",
"audio_url": "data:audio/wav;base64,abc",
},
],
})
);
Ok(())
}
#[test]
fn serializes_local_audio_user_input_with_label_and_data_url() -> Result<()> {
let temp_dir = tempdir()?;
for (extension, mime) in [
("wav", "audio/wav"),
("mp3", "audio/mpeg"),
("m4a", "audio/mp4"),
("webm", "audio/webm"),
("ogg", "audio/ogg"),
] {
let audio_path = temp_dir.path().join(format!("sample.{extension}"));
std::fs::write(&audio_path, b"audio")?;
let item = ResponseInputItem::from(vec![UserInput::LocalAudio {
path: audio_path.clone(),
}]);
assert_eq!(
item,
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: local_audio_open_tag_text_with_path(
/*label_number*/ 1,
&audio_path,
),
},
ContentItem::InputAudio {
audio_url: format!("data:{mime};base64,YXVkaW8="),
},
ContentItem::InputText {
text: audio_close_tag_text(),
},
],
phase: None,
}
);
}
Ok(())
}
#[test]
fn replaces_unsupported_local_audio_format_with_placeholder() -> Result<()> {
let temp_dir = tempdir()?;
let audio_path = temp_dir.path().join("sample.flac");
std::fs::write(&audio_path, b"audio")?;
let item = ResponseInputItem::from(vec![UserInput::LocalAudio {
path: audio_path.clone(),
}]);
assert_eq!(
item,
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"Codex cannot attach audio at `{}`: unsupported audio format; use wav, mp3, m4a, webm, or ogg.",
audio_path.display()
),
}],
phase: None,
}
);
Ok(())
}
#[test]
fn replaces_unreadable_local_audio_with_placeholder() {
let audio_path = PathBuf::from("missing.wav");
let item = ResponseInputItem::from(vec![UserInput::LocalAudio { path: audio_path }]);
let ResponseInputItem::Message { content, .. } = item else {
panic!("expected message response");
};
let [ContentItem::InputText { text }] = content.as_slice() else {
panic!("expected local audio error placeholder");
};
assert!(
text.starts_with("Codex could not read the local audio at `missing.wav`: "),
"unexpected placeholder: {text}"
);
}
#[test]
fn image_user_input_preserves_requested_detail() -> Result<()> {
let image_url = "data:image/png;base64,abc".to_string();
let item = ResponseInputItem::from(vec![UserInput::Image {
image_url: image_url.clone(),
detail: Some(ImageDetail::Original),
}]);
match item {
ResponseInputItem::Message { content, .. } => {
assert_eq!(
content.first(),
Some(&ContentItem::InputImage {
image_url,
detail: Some(ImageDetail::Original),
})
);
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
#[test]
fn tool_search_call_roundtrips() -> Result<()> {
let parsed: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1
}
}"#,
)?;
assert_eq!(
parsed,
ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-1".to_string()),
status: None,
execution: "client".to_string(),
arguments: serde_json::json!({
"query": "calendar create",
"limit": 1,
}),
internal_chat_message_metadata_passthrough: None,
}
);
assert_eq!(
serde_json::to_value(&parsed)?,
serde_json::json!({
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1,
}
})
);
Ok(())
}
#[test]
fn tool_search_output_roundtrips() -> Result<()> {
let input = ResponseInputItem::ToolSearchOutput {
call_id: "search-1".to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: vec![serde_json::json!({
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
})],
};
assert_eq!(
ResponseItem::from(input.clone()),
ResponseItem::ToolSearchOutput {
id: None,
call_id: Some("search-1".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: vec![serde_json::json!({
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
})],
internal_chat_message_metadata_passthrough: None,
}
);
assert_eq!(
serde_json::to_value(input)?,
serde_json::json!({
"type": "tool_search_output",
"call_id": "search-1",
"status": "completed",
"execution": "client",
"tools": [{
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
}]
})
);
Ok(())
}
#[test]
fn tool_search_server_items_allow_null_call_id() -> Result<()> {
let parsed_call: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_call",
"execution": "server",
"call_id": null,
"status": "completed",
"arguments": {
"paths": ["crm"]
}
}"#,
)?;
assert_eq!(
parsed_call,
ResponseItem::ToolSearchCall {
id: None,
call_id: None,
status: Some("completed".to_string()),
execution: "server".to_string(),
arguments: serde_json::json!({
"paths": ["crm"],
}),
internal_chat_message_metadata_passthrough: None,
}
);
let parsed_output: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_output",
"execution": "server",
"call_id": null,
"status": "completed",
"tools": []
}"#,
)?;
assert_eq!(
parsed_output,
ResponseItem::ToolSearchOutput {
id: None,
call_id: None,
status: "completed".to_string(),
execution: "server".to_string(),
tools: vec![],
internal_chat_message_metadata_passthrough: None,
}
);
Ok(())
}
#[test]
fn mixed_remote_and_local_images_share_label_sequence() -> Result<()> {
let image_url = "data:image/png;base64,abc".to_string();
let dir = tempdir()?;
let local_path = dir.path().join("local.png");
std::fs::write(&local_path, TINY_PNG_BYTES)?;
let item = ResponseInputItem::from(vec![
UserInput::Image {
image_url: image_url.clone(),
detail: None,
},
UserInput::LocalImage {
path: local_path.clone(),
detail: None,
},
]);
match item {
ResponseInputItem::Message { content, .. } => {
assert_eq!(
content.first(),
Some(&ContentItem::InputImage {
image_url,
detail: Some(DEFAULT_IMAGE_DETAIL),
})
);
assert_eq!(
content.get(1),
Some(&ContentItem::InputText {
text: local_image_open_tag_text_with_path(
/*label_number*/ 2,
&local_path
),
})
);
assert!(matches!(
content.get(2),
Some(ContentItem::InputImage { .. })
));
assert_eq!(
content.get(3),
Some(&ContentItem::InputText {
text: image_close_tag_text(),
})
);
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
#[test]
fn mixed_remote_and_local_audio_share_label_sequence() -> Result<()> {
let audio_url = "data:audio/wav;base64,abc".to_string();
let dir = tempdir()?;
let local_path = dir.path().join("local.mp3");
std::fs::write(&local_path, b"audio")?;
let item = ResponseInputItem::from(vec![
UserInput::Audio {
audio_url: audio_url.clone(),
},
UserInput::LocalAudio {
path: local_path.clone(),
},
]);
assert_eq!(
item,
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![
ContentItem::InputAudio { audio_url },
ContentItem::InputText {
text: local_audio_open_tag_text_with_path(
/*label_number*/ 2,
&local_path,
),
},
ContentItem::InputAudio {
audio_url: "data:audio/mpeg;base64,YXVkaW8=".to_string(),
},
ContentItem::InputText {
text: audio_close_tag_text(),
},
],
phase: None,
}
);
Ok(())
}
#[test]
fn local_image_open_tag_preserves_path() {
assert_eq!(
local_image_open_tag_text_with_path(
/*label_number*/ 1,
std::path::Path::new(r#"/tmp/a&"<b>.png"#),
),
r#"<image name=[Image #1] path="/tmp/a&"<b>.png">"#
);
}
#[test]
fn local_image_user_input_preserves_requested_detail() -> Result<()> {
let dir = tempdir()?;
let local_path = dir.path().join("local.png");
std::fs::write(&local_path, TINY_PNG_BYTES)?;
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
path: local_path,
detail: Some(ImageDetail::Original),
}]);
match item {
ResponseInputItem::Message { content, .. } => {
assert!(matches!(
content.get(1),
Some(ContentItem::InputImage {
detail: Some(ImageDetail::Original),
..
})
));
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
#[test]
fn local_image_read_error_adds_placeholder() -> Result<()> {
let dir = tempdir()?;
let missing_path = dir.path().join("missing-image.png");
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
path: missing_path.clone(),
detail: None,
}]);
match item {
ResponseInputItem::Message { content, .. } => {
assert_eq!(content.len(), 1);
match &content[0] {
ContentItem::InputText { text } => {
let display_path = missing_path.display().to_string();
assert!(
text.contains(&display_path),
"placeholder should mention missing path: {text}"
);
assert!(
text.contains("could not read"),
"placeholder should mention read issue: {text}"
);
}
other => panic!("expected placeholder text but found {other:?}"),
}
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
#[test]
fn local_image_non_image_adds_placeholder() -> Result<()> {
let dir = tempdir()?;
let json_path = dir.path().join("example.json");
std::fs::write(&json_path, br#"{"hello":"world"}"#)?;
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
path: json_path.clone(),
detail: None,
}]);
match item {
ResponseInputItem::Message { content, .. } => {
assert_eq!(content.len(), 1);
match &content[0] {
ContentItem::InputText { text } => {
assert!(
text.contains("unsupported image `application/json`"),
"placeholder should mention unsupported image MIME: {text}"
);
assert!(
text.contains(&json_path.display().to_string()),
"placeholder should mention path: {text}"
);
}
other => panic!("expected placeholder text but found {other:?}"),
}
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
#[test]
fn local_image_unsupported_image_format_adds_placeholder() -> Result<()> {
let dir = tempdir()?;
let svg_path = dir.path().join("example.svg");
std::fs::write(
&svg_path,
br#"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>"#,
)?;
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
path: svg_path.clone(),
detail: None,
}]);
match item {
ResponseInputItem::Message { content, .. } => {
assert_eq!(content.len(), 1);
let expected = format!(
"Codex cannot attach image at `{}`: unsupported image `image/svg+xml`.",
svg_path.display()
);
match &content[0] {
ContentItem::InputText { text } => assert_eq!(text, &expected),
other => panic!("expected placeholder text but found {other:?}"),
}
}
other => panic!("expected message response but got {other:?}"),
}
Ok(())
}
}