This PR replaces the old `additional_permissions.fs_read/fs_write` shape with a shared `PermissionProfile` model and wires it through the command approval, sandboxing, protocol, and TUI layers. The schema is adopted from the `SkillManifestPermissions`, which is also refactored to use this unified struct. This helps us easily expose permission profiles in app server/core as a follow-up.
1884 lines
64 KiB
Rust
1884 lines
64 KiB
Rust
use std::collections::HashMap;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
|
||
use codex_utils_image::load_and_resize_to_fit;
|
||
use serde::Deserialize;
|
||
use serde::Deserializer;
|
||
use serde::Serialize;
|
||
use serde::ser::Serializer;
|
||
use ts_rs::TS;
|
||
|
||
use crate::config_types::CollaborationMode;
|
||
use crate::config_types::SandboxMode;
|
||
use crate::protocol::AskForApproval;
|
||
use crate::protocol::COLLABORATION_MODE_CLOSE_TAG;
|
||
use crate::protocol::COLLABORATION_MODE_OPEN_TAG;
|
||
use crate::protocol::NetworkAccess;
|
||
use crate::protocol::SandboxPolicy;
|
||
use crate::protocol::WritableRoot;
|
||
use crate::user_input::UserInput;
|
||
use codex_execpolicy::Policy;
|
||
use codex_git::GhostCommit;
|
||
use codex_utils_image::error::ImageProcessingError;
|
||
use schemars::JsonSchema;
|
||
|
||
use crate::mcp::CallToolResult;
|
||
|
||
/// Controls whether a command should use the session sandbox or bypass it.
|
||
#[derive(
|
||
Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS,
|
||
)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum SandboxPermissions {
|
||
/// Run with the configured sandbox
|
||
#[default]
|
||
UseDefault,
|
||
/// Request to run outside the sandbox
|
||
RequireEscalated,
|
||
/// Request to run in the sandbox with additional per-command permissions.
|
||
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 requires permissions beyond UseDefault
|
||
pub fn requires_additional_permissions(self) -> bool {
|
||
!matches!(self, SandboxPermissions::UseDefault)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||
pub struct FileSystemPermissions {
|
||
pub read: Option<Vec<PathBuf>>,
|
||
pub write: Option<Vec<PathBuf>>,
|
||
}
|
||
|
||
impl FileSystemPermissions {
|
||
pub fn is_empty(&self) -> bool {
|
||
self.read.is_none() && self.write.is_none()
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||
pub struct MacOsPermissions {
|
||
pub preferences: Option<MacOsPreferencesValue>,
|
||
pub automations: Option<MacOsAutomationValue>,
|
||
pub accessibility: Option<bool>,
|
||
pub calendar: Option<bool>,
|
||
}
|
||
|
||
impl MacOsPermissions {
|
||
pub fn is_empty(&self) -> bool {
|
||
self.preferences.is_none()
|
||
&& self.automations.is_none()
|
||
&& self.accessibility.is_none()
|
||
&& self.calendar.is_none()
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||
#[serde(untagged)]
|
||
pub enum MacOsPreferencesValue {
|
||
Bool(bool),
|
||
Mode(String),
|
||
}
|
||
|
||
#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||
#[serde(untagged)]
|
||
pub enum MacOsAutomationValue {
|
||
Bool(bool),
|
||
BundleIds(Vec<String>),
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||
pub struct PermissionProfile {
|
||
pub network: Option<bool>,
|
||
pub file_system: Option<FileSystemPermissions>,
|
||
pub macos: Option<MacOsPermissions>,
|
||
}
|
||
|
||
impl PermissionProfile {
|
||
pub fn is_empty(&self) -> bool {
|
||
self.network.is_none()
|
||
&& self
|
||
.file_system
|
||
.as_ref()
|
||
.map(FileSystemPermissions::is_empty)
|
||
.unwrap_or(true)
|
||
&& self
|
||
.macos
|
||
.as_ref()
|
||
.map(MacOsPermissions::is_empty)
|
||
.unwrap_or(true)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||
#[serde(tag = "type", rename_all = "snake_case")]
|
||
pub enum ResponseInputItem {
|
||
Message {
|
||
role: String,
|
||
content: Vec<ContentItem>,
|
||
},
|
||
FunctionCallOutput {
|
||
call_id: String,
|
||
output: FunctionCallOutputPayload,
|
||
},
|
||
McpToolCallOutput {
|
||
call_id: String,
|
||
result: Result<CallToolResult, String>,
|
||
},
|
||
CustomToolCallOutput {
|
||
call_id: String,
|
||
output: String,
|
||
},
|
||
}
|
||
|
||
#[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 },
|
||
OutputText { text: String },
|
||
}
|
||
|
||
#[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,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||
#[serde(tag = "type", rename_all = "snake_case")]
|
||
pub enum ResponseItem {
|
||
Message {
|
||
#[serde(default, skip_serializing)]
|
||
#[ts(skip)]
|
||
id: Option<String>,
|
||
role: String,
|
||
content: Vec<ContentItem>,
|
||
// Do not use directly, no available consistently across all providers.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
#[ts(optional)]
|
||
end_turn: Option<bool>,
|
||
// 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>,
|
||
},
|
||
Reasoning {
|
||
#[serde(default, skip_serializing)]
|
||
#[ts(skip)]
|
||
id: String,
|
||
summary: Vec<ReasoningItemReasoningSummary>,
|
||
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
|
||
#[ts(optional)]
|
||
content: Option<Vec<ReasoningItemContent>>,
|
||
encrypted_content: Option<String>,
|
||
},
|
||
LocalShellCall {
|
||
/// Legacy id field retained for compatibility with older payloads.
|
||
#[serde(default, skip_serializing)]
|
||
#[ts(skip)]
|
||
id: Option<String>,
|
||
/// Set when using the Responses API.
|
||
call_id: Option<String>,
|
||
status: LocalShellStatus,
|
||
action: LocalShellAction,
|
||
},
|
||
FunctionCall {
|
||
#[serde(default, skip_serializing)]
|
||
#[ts(skip)]
|
||
id: Option<String>,
|
||
name: String,
|
||
// The Responses API returns the function call arguments as a *string* that contains
|
||
// JSON, not as an already‑parsed object. We keep it as a raw string here and let
|
||
// Session::handle_function_call parse it into a Value.
|
||
arguments: String,
|
||
call_id: String,
|
||
},
|
||
// 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 {
|
||
call_id: String,
|
||
output: FunctionCallOutputPayload,
|
||
},
|
||
CustomToolCall {
|
||
#[serde(default, skip_serializing)]
|
||
#[ts(skip)]
|
||
id: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
#[ts(optional)]
|
||
status: Option<String>,
|
||
|
||
call_id: String,
|
||
name: String,
|
||
input: String,
|
||
},
|
||
CustomToolCallOutput {
|
||
call_id: String,
|
||
output: String,
|
||
},
|
||
// 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)]
|
||
#[ts(skip)]
|
||
id: Option<String>,
|
||
#[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>,
|
||
},
|
||
// Generated by the harness but considered exactly as a model response.
|
||
GhostSnapshot {
|
||
ghost_commit: GhostCommit,
|
||
},
|
||
#[serde(alias = "compaction_summary")]
|
||
Compaction {
|
||
encrypted_content: String,
|
||
},
|
||
#[serde(other)]
|
||
Other,
|
||
}
|
||
|
||
pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md");
|
||
|
||
/// 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,
|
||
}
|
||
|
||
impl Default for BaseInstructions {
|
||
fn default() -> Self {
|
||
Self {
|
||
text: BASE_INSTRUCTIONS_DEFAULT.to_string(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Developer-provided guidance that is injected into a turn as a developer role
|
||
/// message.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
|
||
#[serde(rename = "developer_instructions", rename_all = "snake_case")]
|
||
pub struct DeveloperInstructions {
|
||
text: String,
|
||
}
|
||
|
||
const APPROVAL_POLICY_NEVER: &str = include_str!("prompts/permissions/approval_policy/never.md");
|
||
const APPROVAL_POLICY_UNLESS_TRUSTED: &str =
|
||
include_str!("prompts/permissions/approval_policy/unless_trusted.md");
|
||
const APPROVAL_POLICY_ON_FAILURE: &str =
|
||
include_str!("prompts/permissions/approval_policy/on_failure.md");
|
||
const APPROVAL_POLICY_ON_REQUEST_RULE: &str =
|
||
include_str!("prompts/permissions/approval_policy/on_request_rule.md");
|
||
const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str =
|
||
include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md");
|
||
|
||
const SANDBOX_MODE_DANGER_FULL_ACCESS: &str =
|
||
include_str!("prompts/permissions/sandbox_mode/danger_full_access.md");
|
||
const SANDBOX_MODE_WORKSPACE_WRITE: &str =
|
||
include_str!("prompts/permissions/sandbox_mode/workspace_write.md");
|
||
const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_mode/read_only.md");
|
||
|
||
impl DeveloperInstructions {
|
||
pub fn new<T: Into<String>>(text: T) -> Self {
|
||
Self { text: text.into() }
|
||
}
|
||
|
||
pub fn from(
|
||
approval_policy: AskForApproval,
|
||
exec_policy: &Policy,
|
||
request_permission_enabled: bool,
|
||
) -> DeveloperInstructions {
|
||
let on_request_instructions = || {
|
||
let on_request_rule = if request_permission_enabled {
|
||
APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION
|
||
} else {
|
||
APPROVAL_POLICY_ON_REQUEST_RULE
|
||
};
|
||
let command_prefixes = format_allow_prefixes(exec_policy.get_allowed_prefixes());
|
||
match command_prefixes {
|
||
Some(prefixes) => {
|
||
format!(
|
||
"{on_request_rule}\n## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
|
||
)
|
||
}
|
||
None => on_request_rule.to_string(),
|
||
}
|
||
};
|
||
let text = match approval_policy {
|
||
AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(),
|
||
AskForApproval::UnlessTrusted => APPROVAL_POLICY_UNLESS_TRUSTED.to_string(),
|
||
AskForApproval::OnFailure => APPROVAL_POLICY_ON_FAILURE.to_string(),
|
||
AskForApproval::OnRequest => on_request_instructions(),
|
||
AskForApproval::Reject(reject_config) => {
|
||
let on_request_instructions = on_request_instructions();
|
||
let sandbox_approval = reject_config.sandbox_approval;
|
||
let rules = reject_config.rules;
|
||
let mcp_elicitations = reject_config.mcp_elicitations;
|
||
format!(
|
||
"{on_request_instructions}\n\n\
|
||
Approval policy is `reject`.\n\
|
||
- `sandbox_approval`: {sandbox_approval}\n\
|
||
- `rules`: {rules}\n\
|
||
- `mcp_elicitations`: {mcp_elicitations}\n\
|
||
When a category is `true`, requests in that category are auto-rejected instead of prompting the user."
|
||
)
|
||
}
|
||
};
|
||
|
||
DeveloperInstructions::new(text)
|
||
}
|
||
|
||
pub fn into_text(self) -> String {
|
||
self.text
|
||
}
|
||
|
||
pub fn concat(self, other: impl Into<DeveloperInstructions>) -> Self {
|
||
let mut text = self.text;
|
||
if !text.ends_with('\n') {
|
||
text.push('\n');
|
||
}
|
||
text.push_str(&other.into().text);
|
||
Self { text }
|
||
}
|
||
|
||
pub fn model_switch_message(model_instructions: String) -> Self {
|
||
DeveloperInstructions::new(format!(
|
||
"<model_switch>\nThe user was previously using a different model. Please continue the conversation according to the following instructions:\n\n{model_instructions}\n</model_switch>"
|
||
))
|
||
}
|
||
|
||
pub fn personality_spec_message(spec: String) -> Self {
|
||
let message = format!(
|
||
"<personality_spec> The user has requested a new communication style. Future messages should adhere to the following personality: \n{spec} </personality_spec>"
|
||
);
|
||
DeveloperInstructions::new(message)
|
||
}
|
||
|
||
pub fn from_policy(
|
||
sandbox_policy: &SandboxPolicy,
|
||
approval_policy: AskForApproval,
|
||
exec_policy: &Policy,
|
||
cwd: &Path,
|
||
request_permission_enabled: bool,
|
||
) -> Self {
|
||
let network_access = if sandbox_policy.has_full_network_access() {
|
||
NetworkAccess::Enabled
|
||
} else {
|
||
NetworkAccess::Restricted
|
||
};
|
||
|
||
let (sandbox_mode, writable_roots) = match sandbox_policy {
|
||
SandboxPolicy::DangerFullAccess => (SandboxMode::DangerFullAccess, None),
|
||
SandboxPolicy::ReadOnly { .. } => (SandboxMode::ReadOnly, None),
|
||
SandboxPolicy::ExternalSandbox { .. } => (SandboxMode::DangerFullAccess, None),
|
||
SandboxPolicy::WorkspaceWrite { .. } => {
|
||
let roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
|
||
(SandboxMode::WorkspaceWrite, Some(roots))
|
||
}
|
||
};
|
||
|
||
DeveloperInstructions::from_permissions_with_network(
|
||
sandbox_mode,
|
||
network_access,
|
||
approval_policy,
|
||
exec_policy,
|
||
writable_roots,
|
||
request_permission_enabled,
|
||
)
|
||
}
|
||
|
||
/// Returns developer instructions from a collaboration mode if they exist and are non-empty.
|
||
pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option<Self> {
|
||
collaboration_mode
|
||
.settings
|
||
.developer_instructions
|
||
.as_ref()
|
||
.filter(|instructions| !instructions.is_empty())
|
||
.map(|instructions| {
|
||
DeveloperInstructions::new(format!(
|
||
"{COLLABORATION_MODE_OPEN_TAG}{instructions}{COLLABORATION_MODE_CLOSE_TAG}"
|
||
))
|
||
})
|
||
}
|
||
|
||
fn from_permissions_with_network(
|
||
sandbox_mode: SandboxMode,
|
||
network_access: NetworkAccess,
|
||
approval_policy: AskForApproval,
|
||
exec_policy: &Policy,
|
||
writable_roots: Option<Vec<WritableRoot>>,
|
||
request_permission_enabled: bool,
|
||
) -> Self {
|
||
let start_tag = DeveloperInstructions::new("<permissions instructions>");
|
||
let end_tag = DeveloperInstructions::new("</permissions instructions>");
|
||
start_tag
|
||
.concat(DeveloperInstructions::sandbox_text(
|
||
sandbox_mode,
|
||
network_access,
|
||
))
|
||
.concat(DeveloperInstructions::from(
|
||
approval_policy,
|
||
exec_policy,
|
||
request_permission_enabled,
|
||
))
|
||
.concat(DeveloperInstructions::from_writable_roots(writable_roots))
|
||
.concat(end_tag)
|
||
}
|
||
|
||
fn from_writable_roots(writable_roots: Option<Vec<WritableRoot>>) -> Self {
|
||
let Some(roots) = writable_roots else {
|
||
return DeveloperInstructions::new("");
|
||
};
|
||
|
||
if roots.is_empty() {
|
||
return DeveloperInstructions::new("");
|
||
}
|
||
|
||
let roots_list: Vec<String> = roots
|
||
.iter()
|
||
.map(|r| format!("`{}`", r.root.to_string_lossy()))
|
||
.collect();
|
||
let text = if roots_list.len() == 1 {
|
||
format!(" The writable root is {}.", roots_list[0])
|
||
} else {
|
||
format!(" The writable roots are {}.", roots_list.join(", "))
|
||
};
|
||
DeveloperInstructions::new(text)
|
||
}
|
||
|
||
fn sandbox_text(mode: SandboxMode, network_access: NetworkAccess) -> DeveloperInstructions {
|
||
let template = match mode {
|
||
SandboxMode::DangerFullAccess => SANDBOX_MODE_DANGER_FULL_ACCESS.trim_end(),
|
||
SandboxMode::WorkspaceWrite => SANDBOX_MODE_WORKSPACE_WRITE.trim_end(),
|
||
SandboxMode::ReadOnly => SANDBOX_MODE_READ_ONLY.trim_end(),
|
||
};
|
||
let text = template.replace("{network_access}", &network_access.to_string());
|
||
|
||
DeveloperInstructions::new(text)
|
||
}
|
||
}
|
||
|
||
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}]")
|
||
}
|
||
|
||
impl From<DeveloperInstructions> for ResponseItem {
|
||
fn from(di: DeveloperInstructions) -> Self {
|
||
ResponseItem::Message {
|
||
id: None,
|
||
role: "developer".to_string(),
|
||
content: vec![ContentItem::InputText {
|
||
text: di.into_text(),
|
||
}],
|
||
end_turn: None,
|
||
phase: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<SandboxMode> for DeveloperInstructions {
|
||
fn from(mode: SandboxMode) -> Self {
|
||
let network_access = match mode {
|
||
SandboxMode::DangerFullAccess => NetworkAccess::Enabled,
|
||
SandboxMode::WorkspaceWrite | SandboxMode::ReadOnly => NetworkAccess::Restricted,
|
||
};
|
||
|
||
DeveloperInstructions::sandbox_text(mode, network_access)
|
||
}
|
||
}
|
||
|
||
fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>>) -> bool {
|
||
match content {
|
||
Some(content) => !content
|
||
.iter()
|
||
.any(|c| matches!(c, ReasoningItemContent::ReasoningText { .. })),
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
fn local_image_error_placeholder(
|
||
path: &std::path::Path,
|
||
error: impl std::fmt::Display,
|
||
) -> ContentItem {
|
||
ContentItem::InputText {
|
||
text: format!(
|
||
"Codex could not read the local image at `{}`: {}",
|
||
path.display(),
|
||
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;
|
||
|
||
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(label_number: usize) -> String {
|
||
let label = local_image_label_text(label_number);
|
||
format!("{LOCAL_IMAGE_OPEN_TAG_PREFIX}{label}{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
|
||
}
|
||
|
||
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 format `{}`.",
|
||
path.display(),
|
||
mime
|
||
),
|
||
}
|
||
}
|
||
|
||
pub fn local_image_content_items_with_label_number(
|
||
path: &std::path::Path,
|
||
label_number: Option<usize>,
|
||
) -> Vec<ContentItem> {
|
||
match load_and_resize_to_fit(path) {
|
||
Ok(image) => {
|
||
let mut items = Vec::with_capacity(3);
|
||
if let Some(label_number) = label_number {
|
||
items.push(ContentItem::InputText {
|
||
text: local_image_open_tag_text(label_number),
|
||
});
|
||
}
|
||
items.push(ContentItem::InputImage {
|
||
image_url: image.into_data_url(),
|
||
});
|
||
if label_number.is_some() {
|
||
items.push(ContentItem::InputText {
|
||
text: LOCAL_IMAGE_CLOSE_TAG.to_string(),
|
||
});
|
||
}
|
||
items
|
||
}
|
||
Err(err) => {
|
||
if matches!(&err, ImageProcessingError::Read { .. }) {
|
||
vec![local_image_error_placeholder(path, &err)]
|
||
} else if err.is_invalid_image() {
|
||
vec![invalid_image_error_placeholder(path, &err)]
|
||
} else {
|
||
let Some(mime_guess) = mime_guess::from_path(path).first() else {
|
||
return vec![local_image_error_placeholder(
|
||
path,
|
||
"unsupported MIME type (unknown)",
|
||
)];
|
||
};
|
||
let mime = mime_guess.essence_str().to_owned();
|
||
if !mime.starts_with("image/") {
|
||
return vec![local_image_error_placeholder(
|
||
path,
|
||
format!("unsupported MIME type `{mime}`"),
|
||
)];
|
||
}
|
||
vec![unsupported_image_error_placeholder(path, &mime)]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<ResponseInputItem> for ResponseItem {
|
||
fn from(item: ResponseInputItem) -> Self {
|
||
match item {
|
||
ResponseInputItem::Message { role, content } => Self::Message {
|
||
role,
|
||
content,
|
||
id: None,
|
||
end_turn: None,
|
||
phase: None,
|
||
},
|
||
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
||
Self::FunctionCallOutput { call_id, output }
|
||
}
|
||
ResponseInputItem::McpToolCallOutput { call_id, result } => {
|
||
let output = match result {
|
||
Ok(result) => FunctionCallOutputPayload::from(&result),
|
||
Err(tool_call_err) => FunctionCallOutputPayload {
|
||
body: FunctionCallOutputBody::Text(format!("err: {tool_call_err:?}")),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
Self::FunctionCallOutput { call_id, output }
|
||
}
|
||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||
Self::CustomToolCallOutput { call_id, output }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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")]
|
||
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 {
|
||
let mut image_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 } => {
|
||
image_index += 1;
|
||
vec![
|
||
ContentItem::InputText {
|
||
text: image_open_tag_text(),
|
||
},
|
||
ContentItem::InputImage { image_url },
|
||
ContentItem::InputText {
|
||
text: image_close_tag_text(),
|
||
},
|
||
]
|
||
}
|
||
UserInput::LocalImage { path } => {
|
||
image_index += 1;
|
||
local_image_content_items_with_label_number(&path, Some(image_index))
|
||
}
|
||
UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core
|
||
})
|
||
.collect::<Vec<ContentItem>>(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
|
||
/// or `shell`, the `arguments` field should deserialize to this struct.
|
||
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||
pub struct ShellToolCallParams {
|
||
pub command: Vec<String>,
|
||
pub workdir: Option<String>,
|
||
|
||
/// This is the maximum time in milliseconds that the command is allowed to run.
|
||
#[serde(alias = "timeout")]
|
||
pub timeout_ms: Option<u64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
#[ts(optional)]
|
||
pub sandbox_permissions: Option<SandboxPermissions>,
|
||
/// Suggests a command prefix to persist for future sessions
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
#[ts(optional)]
|
||
pub prefix_rule: Option<Vec<String>>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
#[ts(optional)]
|
||
pub additional_permissions: Option<PermissionProfile>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub justification: Option<String>,
|
||
}
|
||
|
||
/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
|
||
/// `arguments` field should deserialize to this struct.
|
||
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||
pub struct ShellCommandToolCallParams {
|
||
pub command: String,
|
||
pub workdir: Option<String>,
|
||
|
||
/// Whether to run the shell with login shell semantics
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub login: Option<bool>,
|
||
/// This is the maximum time in milliseconds that the command is allowed to run.
|
||
#[serde(alias = "timeout")]
|
||
pub timeout_ms: Option<u64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
#[ts(optional)]
|
||
pub sandbox_permissions: Option<SandboxPermissions>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
#[ts(optional)]
|
||
pub prefix_rule: Option<Vec<String>>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
#[ts(optional)]
|
||
pub additional_permissions: Option<PermissionProfile>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub justification: Option<String>,
|
||
}
|
||
|
||
/// 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 },
|
||
}
|
||
|
||
/// 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 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 { .. } => 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 }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 text_content_mut(&mut self) -> Option<&mut String> {
|
||
match &mut 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 From<&CallToolResult> for FunctionCallOutputPayload {
|
||
fn from(call_tool_result: &CallToolResult) -> Self {
|
||
let CallToolResult {
|
||
content,
|
||
structured_content,
|
||
is_error,
|
||
meta: _,
|
||
} = call_tool_result;
|
||
|
||
let is_success = is_error != &Some(true);
|
||
|
||
if let Some(structured_content) = 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(is_success),
|
||
};
|
||
}
|
||
Err(err) => {
|
||
return FunctionCallOutputPayload {
|
||
body: FunctionCallOutputBody::Text(err.to_string()),
|
||
success: Some(false),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
let serialized_content = match serde_json::to_string(content) {
|
||
Ok(serialized_content) => serialized_content,
|
||
Err(err) => {
|
||
return FunctionCallOutputPayload {
|
||
body: FunctionCallOutputBody::Text(err.to_string()),
|
||
success: Some(false),
|
||
};
|
||
}
|
||
};
|
||
|
||
let content_items = convert_mcp_content_to_items(content);
|
||
|
||
let body = match content_items {
|
||
Some(content_items) => FunctionCallOutputBody::ContentItems(content_items),
|
||
None => FunctionCallOutputBody::Text(serialized_content),
|
||
};
|
||
|
||
FunctionCallOutputPayload {
|
||
body,
|
||
success: Some(is_success),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn convert_mcp_content_to_items(
|
||
contents: &[serde_json::Value],
|
||
) -> Option<Vec<FunctionCallOutputContentItem>> {
|
||
#[derive(serde::Deserialize)]
|
||
#[serde(tag = "type")]
|
||
enum McpContent {
|
||
#[serde(rename = "text")]
|
||
Text { text: String },
|
||
#[serde(rename = "image")]
|
||
Image {
|
||
data: String,
|
||
#[serde(rename = "mimeType", alias = "mime_type")]
|
||
mime_type: Option<String>,
|
||
},
|
||
#[serde(other)]
|
||
Unknown,
|
||
}
|
||
|
||
let mut saw_image = false;
|
||
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 }) => FunctionCallOutputContentItem::InputText { text },
|
||
Ok(McpContent::Image { data, mime_type }) => {
|
||
saw_image = true;
|
||
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 }
|
||
}
|
||
Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText {
|
||
text: serde_json::to_string(content).unwrap_or_else(|_| "<content>".to_string()),
|
||
},
|
||
};
|
||
items.push(item);
|
||
}
|
||
|
||
if saw_image { Some(items) } else { None }
|
||
}
|
||
|
||
// 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 crate::config_types::SandboxMode;
|
||
use crate::protocol::AskForApproval;
|
||
use anyhow::Result;
|
||
use codex_execpolicy::Policy;
|
||
use pretty_assertions::assert_eq;
|
||
use std::path::PathBuf;
|
||
use tempfile::tempdir;
|
||
|
||
#[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).expect("expected image items");
|
||
assert_eq!(
|
||
items,
|
||
vec![FunctionCallOutputContentItem::InputImage {
|
||
image_url: "data:image/png;base64,Zm9v".to_string(),
|
||
}]
|
||
);
|
||
}
|
||
|
||
#[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).expect("expected image items");
|
||
assert_eq!(
|
||
items,
|
||
vec![FunctionCallOutputContentItem::InputImage {
|
||
image_url: "data:image/png;base64,Zm9v".to_string(),
|
||
}]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn convert_mcp_content_to_items_returns_none_without_images() {
|
||
let contents = vec![serde_json::json!({
|
||
"type": "text",
|
||
"text": "hello",
|
||
})];
|
||
|
||
assert_eq!(convert_mcp_content_to_items(&contents), None);
|
||
}
|
||
|
||
#[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(),
|
||
},
|
||
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_images() {
|
||
let content_items = vec![
|
||
FunctionCallOutputContentItem::InputText {
|
||
text: " ".to_string(),
|
||
},
|
||
FunctionCallOutputContentItem::InputImage {
|
||
image_url: "data:image/png;base64,AAA".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(),
|
||
},
|
||
]);
|
||
|
||
let text = body.to_text();
|
||
assert_eq!(text, Some("line 1".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn converts_sandbox_mode_into_developer_instructions() {
|
||
let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into();
|
||
assert_eq!(
|
||
workspace_write,
|
||
DeveloperInstructions::new(
|
||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted."
|
||
)
|
||
);
|
||
|
||
let read_only: DeveloperInstructions = SandboxMode::ReadOnly.into();
|
||
assert_eq!(
|
||
read_only,
|
||
DeveloperInstructions::new(
|
||
"Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted."
|
||
)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn builds_permissions_with_network_access_override() {
|
||
let instructions = DeveloperInstructions::from_permissions_with_network(
|
||
SandboxMode::WorkspaceWrite,
|
||
NetworkAccess::Enabled,
|
||
AskForApproval::OnRequest,
|
||
&Policy::empty(),
|
||
None,
|
||
false,
|
||
);
|
||
|
||
let text = instructions.into_text();
|
||
assert!(
|
||
text.contains("Network access is enabled."),
|
||
"expected network access to be enabled in message"
|
||
);
|
||
assert!(
|
||
text.contains("How to request escalation"),
|
||
"expected approval guidance to be included"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn builds_permissions_from_policy() {
|
||
let policy = SandboxPolicy::WorkspaceWrite {
|
||
writable_roots: vec![],
|
||
read_only_access: Default::default(),
|
||
network_access: true,
|
||
exclude_tmpdir_env_var: false,
|
||
exclude_slash_tmp: false,
|
||
};
|
||
|
||
let instructions = DeveloperInstructions::from_policy(
|
||
&policy,
|
||
AskForApproval::UnlessTrusted,
|
||
&Policy::empty(),
|
||
&PathBuf::from("/tmp"),
|
||
false,
|
||
);
|
||
let text = instructions.into_text();
|
||
assert!(text.contains("Network access is enabled."));
|
||
assert!(text.contains("`approval_policy` is `unless-trusted`"));
|
||
}
|
||
|
||
#[test]
|
||
fn includes_request_rule_instructions_for_on_request() {
|
||
let mut exec_policy = Policy::empty();
|
||
exec_policy
|
||
.add_prefix_rule(
|
||
&["git".to_string(), "pull".to_string()],
|
||
codex_execpolicy::Decision::Allow,
|
||
)
|
||
.expect("add rule");
|
||
let instructions = DeveloperInstructions::from_permissions_with_network(
|
||
SandboxMode::WorkspaceWrite,
|
||
NetworkAccess::Enabled,
|
||
AskForApproval::OnRequest,
|
||
&exec_policy,
|
||
None,
|
||
false,
|
||
);
|
||
|
||
let text = instructions.into_text();
|
||
assert!(text.contains("prefix_rule"));
|
||
assert!(text.contains("Approved command prefixes"));
|
||
assert!(text.contains(r#"["git", "pull"]"#));
|
||
}
|
||
|
||
#[test]
|
||
fn includes_request_permission_rule_instructions_for_on_request_when_enabled() {
|
||
let instructions = DeveloperInstructions::from_permissions_with_network(
|
||
SandboxMode::WorkspaceWrite,
|
||
NetworkAccess::Enabled,
|
||
AskForApproval::OnRequest,
|
||
&Policy::empty(),
|
||
None,
|
||
true,
|
||
);
|
||
|
||
let text = instructions.into_text();
|
||
assert!(text.contains("with_additional_permissions"));
|
||
assert!(text.contains("additional_permissions"));
|
||
}
|
||
|
||
#[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 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 = FunctionCallOutputPayload::from(&call_tool_result);
|
||
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(),
|
||
},
|
||
]
|
||
);
|
||
|
||
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 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 = FunctionCallOutputPayload::from(&call_tool_result);
|
||
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(),
|
||
}]
|
||
);
|
||
|
||
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(),
|
||
},
|
||
];
|
||
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 {
|
||
encrypted_content: "abc".into(),
|
||
}
|
||
);
|
||
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()),
|
||
true,
|
||
),
|
||
(
|
||
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()),
|
||
true,
|
||
),
|
||
(
|
||
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()),
|
||
true,
|
||
),
|
||
(
|
||
r#"{
|
||
"type": "web_search_call",
|
||
"status": "in_progress",
|
||
"id": "ws_partial"
|
||
}"#,
|
||
Some("ws_partial".into()),
|
||
None,
|
||
Some("in_progress".into()),
|
||
false,
|
||
),
|
||
];
|
||
|
||
for (json_literal, expected_id, expected_action, expected_status, expect_roundtrip) 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(),
|
||
};
|
||
assert_eq!(parsed, expected);
|
||
|
||
let serialized = serde_json::to_value(&parsed)?;
|
||
let mut expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?;
|
||
if !expect_roundtrip && let Some(obj) = expected_serialized.as_object_mut() {
|
||
obj.remove("id");
|
||
}
|
||
assert_eq!(serialized, expected_serialized);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn deserialize_shell_tool_call_params() -> Result<()> {
|
||
let json = r#"{
|
||
"command": ["ls", "-l"],
|
||
"workdir": "/tmp",
|
||
"timeout": 1000
|
||
}"#;
|
||
|
||
let params: ShellToolCallParams = serde_json::from_str(json)?;
|
||
assert_eq!(
|
||
ShellToolCallParams {
|
||
command: vec!["ls".to_string(), "-l".to_string()],
|
||
workdir: Some("/tmp".to_string()),
|
||
timeout_ms: Some(1000),
|
||
sandbox_permissions: None,
|
||
prefix_rule: None,
|
||
additional_permissions: None,
|
||
justification: None,
|
||
},
|
||
params
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn wraps_image_user_input_with_tags() -> Result<()> {
|
||
let image_url = "data:image/png;base64,abc".to_string();
|
||
|
||
let item = ResponseInputItem::from(vec![UserInput::Image {
|
||
image_url: image_url.clone(),
|
||
}]);
|
||
|
||
match item {
|
||
ResponseInputItem::Message { content, .. } => {
|
||
let expected = vec![
|
||
ContentItem::InputText {
|
||
text: image_open_tag_text(),
|
||
},
|
||
ContentItem::InputImage { image_url },
|
||
ContentItem::InputText {
|
||
text: image_close_tag_text(),
|
||
},
|
||
];
|
||
assert_eq!(content, expected);
|
||
}
|
||
other => panic!("expected message response but got {other:?}"),
|
||
}
|
||
|
||
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");
|
||
// A tiny valid PNG (1x1) so this test doesn'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,
|
||
];
|
||
std::fs::write(&local_path, TINY_PNG_BYTES)?;
|
||
|
||
let item = ResponseInputItem::from(vec![
|
||
UserInput::Image {
|
||
image_url: image_url.clone(),
|
||
},
|
||
UserInput::LocalImage { path: local_path },
|
||
]);
|
||
|
||
match item {
|
||
ResponseInputItem::Message { content, .. } => {
|
||
assert_eq!(
|
||
content.first(),
|
||
Some(&ContentItem::InputText {
|
||
text: image_open_tag_text(),
|
||
})
|
||
);
|
||
assert_eq!(content.get(1), Some(&ContentItem::InputImage { image_url }));
|
||
assert_eq!(
|
||
content.get(2),
|
||
Some(&ContentItem::InputText {
|
||
text: image_close_tag_text(),
|
||
})
|
||
);
|
||
assert_eq!(
|
||
content.get(3),
|
||
Some(&ContentItem::InputText {
|
||
text: local_image_open_tag_text(2),
|
||
})
|
||
);
|
||
assert!(matches!(
|
||
content.get(4),
|
||
Some(ContentItem::InputImage { .. })
|
||
));
|
||
assert_eq!(
|
||
content.get(5),
|
||
Some(&ContentItem::InputText {
|
||
text: image_close_tag_text(),
|
||
})
|
||
);
|
||
}
|
||
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(),
|
||
}]);
|
||
|
||
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(),
|
||
}]);
|
||
|
||
match item {
|
||
ResponseInputItem::Message { content, .. } => {
|
||
assert_eq!(content.len(), 1);
|
||
match &content[0] {
|
||
ContentItem::InputText { text } => {
|
||
assert!(
|
||
text.contains("unsupported MIME type `application/json`"),
|
||
"placeholder should mention unsupported 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(),
|
||
}]);
|
||
|
||
match item {
|
||
ResponseInputItem::Message { content, .. } => {
|
||
assert_eq!(content.len(), 1);
|
||
let expected = format!(
|
||
"Codex cannot attach image at `{}`: unsupported image format `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(())
|
||
}
|
||
}
|