Merge branch 'main' into dev/aaronl/dynamic-tool-content-items

This commit is contained in:
Owen Lin
2026-02-03 13:29:24 -08:00
895 changed files with 109659 additions and 13786 deletions
-1
View File
@@ -19,7 +19,6 @@ codex-utils-image = { workspace = true }
icu_decimal = { workspace = true }
icu_locale_core = { workspace = true }
icu_provider = { workspace = true, features = ["sync"] }
mcp-types = { workspace = true }
mime_guess = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
+2 -1
View File
@@ -1,9 +1,9 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::mcp::RequestId;
use crate::parse_command::ParsedCommand;
use crate::protocol::FileChange;
use mcp_types::RequestId;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -62,6 +62,7 @@ pub struct ExecApprovalRequestEvent {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ElicitationRequestEvent {
pub server_name: String,
#[ts(type = "string | number")]
pub id: RequestId,
pub message: String,
// TODO: MCP servers can request we fill out a schema for the elicitation. We don't support
+26 -4
View File
@@ -173,10 +173,23 @@ pub enum AltScreenMode {
pub enum ModeKind {
Plan,
#[default]
Code,
#[serde(
alias = "code",
alias = "pair_programming",
alias = "execute",
alias = "custom"
)]
Default,
#[doc(hidden)]
#[serde(skip_serializing, skip_deserializing)]
#[schemars(skip)]
#[ts(skip)]
PairProgramming,
#[doc(hidden)]
#[serde(skip_serializing, skip_deserializing)]
#[schemars(skip)]
#[ts(skip)]
Execute,
Custom,
}
/// Collaboration mode for a Codex session.
@@ -276,7 +289,7 @@ mod tests {
#[test]
fn apply_mask_can_clear_optional_fields() {
let mode = CollaborationMode {
mode: ModeKind::Code,
mode: ModeKind::Default,
settings: Settings {
model: "gpt-5.2-codex".to_string(),
reasoning_effort: Some(ReasoningEffort::High),
@@ -292,7 +305,7 @@ mod tests {
};
let expected = CollaborationMode {
mode: ModeKind::Code,
mode: ModeKind::Default,
settings: Settings {
model: "gpt-5.2-codex".to_string(),
reasoning_effort: None,
@@ -301,4 +314,13 @@ mod tests {
};
assert_eq!(expected, mode.apply_mask(&mask));
}
#[test]
fn mode_kind_deserializes_alias_values_to_default() {
for alias in ["code", "pair_programming", "execute", "custom"] {
let json = format!("\"{alias}\"");
let mode: ModeKind = serde_json::from_str(&json).expect("deserialize mode");
assert_eq!(ModeKind::Default, mode);
}
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod config_types;
pub mod custom_prompts;
pub mod dynamic_tools;
pub mod items;
pub mod mcp;
pub mod message_history;
pub mod models;
pub mod num_format;
+328
View File
@@ -0,0 +1,328 @@
//! Types used when representing Model Context Protocol (MCP) values inside the
//! Codex protocol.
//!
//! We intentionally keep these types TS/JSON-schema friendly (via `ts-rs` and
//! `schemars`) so they can be embedded in Codex's own protocol structures.
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// ID of a request, which can be either a string or an integer.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(untagged)]
pub enum RequestId {
String(String),
#[ts(type = "number")]
Integer(i64),
}
impl std::fmt::Display for RequestId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RequestId::String(s) => f.write_str(s),
RequestId::Integer(i) => i.fmt(f),
}
}
}
/// Definition for a tool the client can call.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
pub input_schema: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub output_schema: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub annotations: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub icons: Option<Vec<serde_json::Value>>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub meta: Option<serde_json::Value>,
}
/// A known resource that the server is capable of reading.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct Resource {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub annotations: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mime_type: Option<String>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
#[ts(type = "number")]
pub size: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub icons: Option<Vec<serde_json::Value>>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub meta: Option<serde_json::Value>,
}
/// A template description for resources available on the server.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct ResourceTemplate {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub annotations: Option<serde_json::Value>,
pub uri_template: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mime_type: Option<String>,
}
/// The server's response to a tool call.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct CallToolResult {
pub content: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub structured_content: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub is_error: Option<bool>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub meta: Option<serde_json::Value>,
}
// === Adapter helpers ===
//
// These types and conversions intentionally live in `codex-protocol` so other crates can convert
// “wire-shaped” MCP JSON (typically coming from rmcp model structs serialized with serde) into our
// TS/JsonSchema-friendly protocol types without depending on `mcp-types`.
fn deserialize_lossy_opt_i64<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: serde::Deserializer<'de>,
{
match Option::<serde_json::Number>::deserialize(deserializer)? {
Some(number) => {
if let Some(v) = number.as_i64() {
Ok(Some(v))
} else if let Some(v) = number.as_u64() {
Ok(i64::try_from(v).ok())
} else {
Ok(None)
}
}
None => Ok(None),
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ToolSerde {
name: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default, rename = "inputSchema", alias = "input_schema")]
input_schema: serde_json::Value,
#[serde(default, rename = "outputSchema", alias = "output_schema")]
output_schema: Option<serde_json::Value>,
#[serde(default)]
annotations: Option<serde_json::Value>,
#[serde(default)]
icons: Option<Vec<serde_json::Value>>,
#[serde(rename = "_meta", default)]
meta: Option<serde_json::Value>,
}
impl From<ToolSerde> for Tool {
fn from(value: ToolSerde) -> Self {
let ToolSerde {
name,
title,
description,
input_schema,
output_schema,
annotations,
icons,
meta,
} = value;
Self {
name,
title,
description,
input_schema,
output_schema,
annotations,
icons,
meta,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResourceSerde {
#[serde(default)]
annotations: Option<serde_json::Value>,
#[serde(default)]
description: Option<String>,
#[serde(rename = "mimeType", alias = "mime_type", default)]
mime_type: Option<String>,
name: String,
#[serde(default, deserialize_with = "deserialize_lossy_opt_i64")]
size: Option<i64>,
#[serde(default)]
title: Option<String>,
uri: String,
#[serde(default)]
icons: Option<Vec<serde_json::Value>>,
#[serde(rename = "_meta", default)]
meta: Option<serde_json::Value>,
}
impl From<ResourceSerde> for Resource {
fn from(value: ResourceSerde) -> Self {
let ResourceSerde {
annotations,
description,
mime_type,
name,
size,
title,
uri,
icons,
meta,
} = value;
Self {
annotations,
description,
mime_type,
name,
size,
title,
uri,
icons,
meta,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResourceTemplateSerde {
#[serde(default)]
annotations: Option<serde_json::Value>,
#[serde(rename = "uriTemplate", alias = "uri_template")]
uri_template: String,
name: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(rename = "mimeType", alias = "mime_type", default)]
mime_type: Option<String>,
}
impl From<ResourceTemplateSerde> for ResourceTemplate {
fn from(value: ResourceTemplateSerde) -> Self {
let ResourceTemplateSerde {
annotations,
uri_template,
name,
title,
description,
mime_type,
} = value;
Self {
annotations,
uri_template,
name,
title,
description,
mime_type,
}
}
}
impl Tool {
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
Ok(serde_json::from_value::<ToolSerde>(value)?.into())
}
}
impl Resource {
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
Ok(serde_json::from_value::<ResourceSerde>(value)?.into())
}
}
impl ResourceTemplate {
pub fn from_mcp_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
Ok(serde_json::from_value::<ResourceTemplateSerde>(value)?.into())
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn resource_size_deserializes_without_narrowing() {
let resource = serde_json::json!({
"name": "big",
"uri": "file:///tmp/big",
"size": 5_000_000_000u64,
});
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
assert_eq!(parsed.size, Some(5_000_000_000));
let resource = serde_json::json!({
"name": "negative",
"uri": "file:///tmp/negative",
"size": -1,
});
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
assert_eq!(parsed.size, Some(-1));
let resource = serde_json::json!({
"name": "too_big_for_i64",
"uri": "file:///tmp/too_big_for_i64",
"size": 18446744073709551615u64,
});
let parsed = Resource::from_mcp_value(resource).expect("should deserialize");
assert_eq!(parsed.size, None);
}
}
+232 -60
View File
@@ -2,8 +2,6 @@ use std::collections::HashMap;
use std::path::Path;
use codex_utils_image::load_and_resize_to_fit;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
@@ -24,6 +22,8 @@ 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,
@@ -72,6 +72,13 @@ pub enum ContentItem {
OutputText { text: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum MessagePhase {
Commentary,
FinalAnswer,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem {
@@ -85,6 +92,11 @@ pub enum ResponseItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
end_turn: Option<bool>,
// Optional output-message phase (for example: "commentary", "final_answer").
// Do not use directly; availability can vary by provider and model.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
phase: Option<MessagePhase>,
},
Reasoning {
#[serde(default, skip_serializing)]
@@ -97,7 +109,7 @@ pub enum ResponseItem {
encrypted_content: Option<String>,
},
LocalShellCall {
/// Set when using the chat completions API.
/// Legacy id field retained for compatibility with older payloads.
#[serde(default, skip_serializing)]
#[ts(skip)]
id: Option<String>,
@@ -113,8 +125,7 @@ pub enum ResponseItem {
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. This exactly matches the
// Chat Completions + Responses API behavior.
// Session::handle_function_call parse it into a Value.
arguments: String,
call_id: String,
},
@@ -233,10 +244,13 @@ impl DeveloperInstructions {
if !request_rule_enabled {
APPROVAL_POLICY_ON_REQUEST.to_string()
} else {
let command_prefixes = format_allow_prefixes(exec_policy);
let command_prefixes =
format_allow_prefixes(exec_policy.get_allowed_prefixes());
match command_prefixes {
Some(prefixes) => {
format!("{APPROVAL_POLICY_ON_REQUEST_RULE}\n{prefixes}")
format!(
"{APPROVAL_POLICY_ON_REQUEST_RULE}\nApproved command prefixes:\n{prefixes}"
)
}
None => APPROVAL_POLICY_ON_REQUEST_RULE.to_string(),
}
@@ -371,20 +385,51 @@ impl DeveloperInstructions {
}
}
pub fn render_command_prefix_list<I, P>(prefixes: I) -> Option<String>
where
I: IntoIterator<Item = P>,
P: AsRef<[String]>,
{
let lines = prefixes
.into_iter()
.map(|prefix| format!("- {}", render_command_prefix(prefix.as_ref())))
.collect::<Vec<_>>();
if lines.is_empty() {
return 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;
}
Some(lines.join("\n"))
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 {
@@ -396,12 +441,6 @@ fn render_command_prefix(prefix: &[String]) -> String {
format!("[{tokens}]")
}
fn format_allow_prefixes(exec_policy: &Policy) -> Option<String> {
let prefixes = exec_policy.get_allowed_prefixes();
let lines = render_command_prefix_list(prefixes)?;
Some(format!("Approved command prefixes:\n{lines}"))
}
impl From<DeveloperInstructions> for ResponseItem {
fn from(di: DeveloperInstructions) -> Self {
ResponseItem::Message {
@@ -411,6 +450,7 @@ impl From<DeveloperInstructions> for ResponseItem {
text: di.into_text(),
}],
end_turn: None,
phase: None,
}
}
}
@@ -568,6 +608,7 @@ impl From<ResponseInputItem> for ResponseItem {
content,
id: None,
end_turn: None,
phase: None,
},
ResponseInputItem::FunctionCallOutput { call_id, output } => {
Self::FunctionCallOutput { call_id, output }
@@ -808,6 +849,7 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
content,
structured_content,
is_error,
meta: _,
} = call_tool_result;
let is_success = is_error != &Some(true);
@@ -844,7 +886,7 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
}
};
let content_items = convert_content_blocks_to_items(content);
let content_items = convert_mcp_content_to_items(content);
FunctionCallOutputPayload {
content: serialized_content,
@@ -854,32 +896,45 @@ impl From<&CallToolResult> for FunctionCallOutputPayload {
}
}
fn convert_content_blocks_to_items(
blocks: &[ContentBlock],
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(blocks.len());
tracing::warn!("Blocks: {:?}", blocks);
for block in blocks {
match block {
ContentBlock::TextContent(text) => {
items.push(FunctionCallOutputContentItem::InputText {
text: text.text.clone(),
});
}
ContentBlock::ImageContent(image) => {
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;
// Just in case the content doesn't include a data URL, add it.
let image_url = if image.data.starts_with("data:") {
image.data.clone()
let image_url = if data.starts_with("data:") {
data
} else {
format!("data:{};base64,{}", image.mime_type, image.data)
let mime_type = mime_type.unwrap_or_else(|| "application/octet-stream".into());
format!("data:{mime_type};base64,{data}")
};
items.push(FunctionCallOutputContentItem::InputImage { image_url });
FunctionCallOutputContentItem::InputImage { image_url }
}
// TODO: render audio, resource, and embedded resource content to the model.
_ => return None,
}
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 }
@@ -911,12 +966,54 @@ mod tests {
use crate::protocol::AskForApproval;
use anyhow::Result;
use codex_execpolicy::Policy;
use mcp_types::ImageContent;
use mcp_types::TextContent;
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 converts_sandbox_mode_into_developer_instructions() {
let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into();
@@ -1003,6 +1100,62 @@ mod tests {
assert!(text.contains(r#"["git", "pull"]"#));
}
#[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 {
@@ -1043,20 +1196,12 @@ mod tests {
fn serializes_image_outputs_as_array() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![
ContentBlock::TextContent(TextContent {
annotations: None,
text: "caption".into(),
r#type: "text".into(),
}),
ContentBlock::ImageContent(ImageContent {
annotations: None,
data: "BASE64".into(),
mime_type: "image/png".into(),
r#type: "image".into(),
}),
serde_json::json!({"type":"text","text":"caption"}),
serde_json::json!({"type":"image","data":"BASE64","mimeType":"image/png"}),
],
is_error: None,
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = FunctionCallOutputPayload::from(&call_tool_result);
@@ -1088,6 +1233,33 @@ mod tests {
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");
};
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#"[
+45
View File
@@ -1,3 +1,8 @@
//! Shared model metadata types exchanged between Codex services and clients.
//!
//! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults
//! are used to preserve compatibility when older payloads omit newly introduced attributes.
use std::collections::HashMap;
use std::collections::HashSet;
@@ -43,6 +48,38 @@ pub enum ReasoningEffort {
XHigh,
}
/// Canonical user-input modality tags advertised by a model.
#[derive(
Debug,
Serialize,
Deserialize,
Clone,
Copy,
PartialEq,
Eq,
Display,
JsonSchema,
TS,
EnumIter,
Hash,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum InputModality {
/// Plain text turns and tool payloads.
Text,
/// Image attachments included in user turns.
Image,
}
/// Backward-compatible default when `input_modalities` is omitted on the wire.
///
/// Legacy payloads predate modality metadata, so we conservatively assume both text and images are
/// accepted unless a preset explicitly narrows support.
pub fn default_input_modalities() -> Vec<InputModality> {
vec![InputModality::Text, InputModality::Image]
}
/// A reasoning effort option that can be surfaced for a model.
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
pub struct ReasoningEffortPreset {
@@ -88,6 +125,9 @@ pub struct ModelPreset {
pub show_in_picker: bool,
/// whether this model is supported in the api
pub supported_in_api: bool,
/// Input modalities accepted when composing user turns for this preset.
#[serde(default = "default_input_modalities")]
pub input_modalities: Vec<InputModality>,
}
/// Visibility of a model in the picker or APIs.
@@ -206,6 +246,9 @@ pub struct ModelInfo {
#[serde(default = "default_effective_context_window_percent")]
pub effective_context_window_percent: i64,
pub experimental_supported_tools: Vec<String>,
/// Input modalities accepted by the backend for this model.
#[serde(default = "default_input_modalities")]
pub input_modalities: Vec<InputModality>,
}
impl ModelInfo {
@@ -350,6 +393,7 @@ impl From<ModelInfo> for ModelPreset {
}),
show_in_picker: info.visibility == ModelVisibility::List,
supported_in_api: info.supported_in_api,
input_modalities: info.input_modalities,
}
}
}
@@ -460,6 +504,7 @@ mod tests {
auto_compact_token_limit: None,
effective_context_window_percent: 95,
experimental_supported_tools: vec![],
input_modalities: default_input_modalities(),
}
}
+17 -11
View File
@@ -23,6 +23,11 @@ use crate::dynamic_tools::DynamicToolCallRequest;
use crate::dynamic_tools::DynamicToolResponse;
use crate::dynamic_tools::DynamicToolSpec;
use crate::items::TurnItem;
use crate::mcp::CallToolResult;
use crate::mcp::RequestId;
use crate::mcp::Resource as McpResource;
use crate::mcp::ResourceTemplate as McpResourceTemplate;
use crate::mcp::Tool as McpTool;
use crate::message_history::HistoryEntry;
use crate::models::BaseInstructions;
use crate::models::ContentItem;
@@ -35,11 +40,6 @@ use crate::plan_tool::UpdatePlanArgs;
use crate::request_user_input::RequestUserInputResponse;
use crate::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use mcp_types::CallToolResult;
use mcp_types::RequestId;
use mcp_types::Resource as McpResource;
use mcp_types::ResourceTemplate as McpResourceTemplate;
use mcp_types::Tool as McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -589,13 +589,18 @@ impl SandboxPolicy {
}
subpaths.push(top_level_git);
}
#[allow(clippy::expect_used)]
let top_level_codex = writable_root
.join(".codex")
.expect(".codex is a valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
// Make .agents/skills and .codex/config.toml and
// related files read-only to the agent, by default.
for subdir in &[".agents", ".codex"] {
#[allow(clippy::expect_used)]
let top_level_codex =
writable_root.join(subdir).expect("valid relative path");
if top_level_codex.as_path().is_dir() {
subpaths.push(top_level_codex);
}
}
WritableRoot {
root: writable_root,
read_only_subpaths: subpaths,
@@ -1691,6 +1696,7 @@ impl From<CompactedItem> for ResponseItem {
text: value.message,
}],
end_turn: None,
phase: None,
}
}
}