Compare commits

..

1 Commits

Author SHA1 Message Date
Vivian Fang
c7c4fa6ab3 Preserve nested nullable MCP tool schemas in code mode 2026-04-05 20:04:03 -07:00
6 changed files with 798 additions and 108 deletions

View File

@@ -265,7 +265,7 @@ fn append_code_mode_sample_for_definition(definition: &ToolDefinition) -> String
CodeModeToolKind::Function => definition
.input_schema
.as_ref()
.map(render_json_schema_to_typescript)
.map(render_json_schema_to_typescript_with_property_descriptions)
.unwrap_or_else(|| "unknown".to_string()),
CodeModeToolKind::Freeform => "string".to_string(),
};
@@ -297,6 +297,33 @@ pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
render_json_schema_to_typescript_inner(schema)
}
fn render_json_schema_to_typescript_with_property_descriptions(schema: &JsonValue) -> String {
let Some(map) = schema.as_object() else {
return render_json_schema_to_typescript(schema);
};
if !(map.contains_key("properties")
|| map.contains_key("additionalProperties")
|| map.contains_key("required"))
{
return render_json_schema_to_typescript(schema);
}
let Some(properties) = map.get("properties").and_then(JsonValue::as_object) else {
return render_json_schema_to_typescript(schema);
};
if properties.values().all(|value| {
value
.get("description")
.and_then(JsonValue::as_str)
.is_none_or(str::is_empty)
}) {
return render_json_schema_to_typescript(schema);
}
render_json_schema_object_with_property_descriptions(map)
}
fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String {
match schema {
JsonValue::Bool(true) => "unknown".to_string(),
@@ -460,6 +487,67 @@ fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String
format!("{{ {} }}", lines.join(" "))
}
fn render_json_schema_object_with_property_descriptions(
map: &serde_json::Map<String, JsonValue>,
) -> String {
let required = map
.get("required")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let properties = map
.get("properties")
.and_then(JsonValue::as_object)
.cloned()
.unwrap_or_default();
let mut sorted_properties = properties.iter().collect::<Vec<_>>();
sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
let mut lines = vec!["{".to_string()];
for (name, value) in sorted_properties {
if let Some(description) = value.get("description").and_then(JsonValue::as_str) {
for description_line in description
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
lines.push(format!(" // {description_line}"));
}
}
let optional = if required.iter().any(|required_name| required_name == name) {
""
} else {
"?"
};
let property_name = render_json_schema_property_name(name);
let property_type = render_json_schema_to_typescript_inner(value);
lines.push(format!(" {property_name}{optional}: {property_type};"));
}
if let Some(additional_properties) = map.get("additionalProperties") {
let property_type = match additional_properties {
JsonValue::Bool(true) => Some("unknown".to_string()),
JsonValue::Bool(false) => None,
value => Some(render_json_schema_to_typescript_inner(value)),
};
if let Some(property_type) = property_type {
lines.push(format!(" [key: string]: {property_type};"));
}
} else if properties.is_empty() {
lines.push(" [key: string]: unknown;".to_string());
}
lines.push("}".to_string());
lines.join("\n")
}
fn render_json_schema_property_name(name: &str) -> String {
if normalize_code_mode_identifier(name) == name {
name.to_string()
@@ -548,6 +636,36 @@ mod tests {
);
}
#[test]
fn augment_tool_definition_includes_input_property_descriptions_as_comments() {
let definition = ToolDefinition {
name: "weather_tool".to_string(),
description: "Weather tool".to_string(),
kind: CodeModeToolKind::Function,
input_schema: Some(json!({
"type": "object",
"properties": {
"weather": {
"type": "array",
"description": "look up weather for a given list of locations",
"items": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}
}
})),
output_schema: None,
};
let description = augment_tool_definition(definition).description;
assert!(description.contains("// look up weather for a given list of locations"));
assert!(description.contains("weather?: Array<{ location: string; }>;"));
}
#[test]
fn code_mode_only_description_includes_nested_tools() {
let description = build_exec_tool_description(

View File

@@ -6,10 +6,7 @@ use codex_utils_cli::CliConfigOverrides;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
version,
override_usage = "codex exec [OPTIONS] [PROMPT]\n codex exec [OPTIONS] <COMMAND> [ARGS]"
)]
#[command(version)]
pub struct Cli {
/// Action to perform. If omitted, runs a new non-interactive session.
#[command(subcommand)]

View File

@@ -514,66 +514,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
let default_sandbox_policy = config.permissions.sandbox_policy.get();
let default_effort = config.model_reasoning_effort;
let (initial_operation, prompt_summary) = match (command.as_ref(), prompt, images) {
(Some(ExecCommand::Review(review_cli)), _, _) => {
let review_request = build_review_request(review_cli)?;
let summary = codex_core::review_prompts::user_facing_hint(&review_request.target);
(InitialOperation::Review { review_request }, summary)
}
(Some(ExecCommand::Resume(args)), root_prompt, imgs) => {
let prompt_arg = args
.prompt
.clone()
.or_else(|| {
if args.last {
args.session_id.clone()
} else {
None
}
})
.or(root_prompt);
let prompt_text = resolve_prompt(prompt_arg);
let mut items: Vec<UserInput> = imgs
.into_iter()
.chain(args.images.iter().cloned())
.map(|path| UserInput::LocalImage { path })
.collect();
items.push(UserInput::Text {
text: prompt_text.clone(),
// CLI input doesn't track UI element ranges, so none are available here.
text_elements: Vec::new(),
});
let output_schema = load_output_schema(output_schema_path.clone());
(
InitialOperation::UserTurn {
items,
output_schema,
},
prompt_text,
)
}
(None, root_prompt, imgs) => {
let prompt_text = resolve_root_prompt(root_prompt);
let mut items: Vec<UserInput> = imgs
.into_iter()
.map(|path| UserInput::LocalImage { path })
.collect();
items.push(UserInput::Text {
text: prompt_text.clone(),
// CLI input doesn't track UI element ranges, so none are available here.
text_elements: Vec::new(),
});
let output_schema = load_output_schema(output_schema_path);
(
InitialOperation::UserTurn {
items,
output_schema,
},
prompt_text,
)
}
};
// When --yolo (dangerously_bypass_approvals_and_sandbox) is set, also skip the git repo check
// since the user is explicitly running in an externally sandboxed environment.
if !skip_git_repo_check
@@ -648,6 +588,66 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
exec_span.record("thread.id", primary_thread_id_for_span.as_str());
let (initial_operation, prompt_summary) = match (command.as_ref(), prompt, images) {
(Some(ExecCommand::Review(review_cli)), _, _) => {
let review_request = build_review_request(review_cli)?;
let summary = codex_core::review_prompts::user_facing_hint(&review_request.target);
(InitialOperation::Review { review_request }, summary)
}
(Some(ExecCommand::Resume(args)), root_prompt, imgs) => {
let prompt_arg = args
.prompt
.clone()
.or_else(|| {
if args.last {
args.session_id.clone()
} else {
None
}
})
.or(root_prompt);
let prompt_text = resolve_prompt(prompt_arg);
let mut items: Vec<UserInput> = imgs
.into_iter()
.chain(args.images.iter().cloned())
.map(|path| UserInput::LocalImage { path })
.collect();
items.push(UserInput::Text {
text: prompt_text.clone(),
// CLI input doesn't track UI element ranges, so none are available here.
text_elements: Vec::new(),
});
let output_schema = load_output_schema(output_schema_path.clone());
(
InitialOperation::UserTurn {
items,
output_schema,
},
prompt_text,
)
}
(None, root_prompt, imgs) => {
let prompt_text = resolve_root_prompt(root_prompt);
let mut items: Vec<UserInput> = imgs
.into_iter()
.map(|path| UserInput::LocalImage { path })
.collect();
items.push(UserInput::Text {
text: prompt_text.clone(),
// CLI input doesn't track UI element ranges, so none are available here.
text_elements: Vec::new(),
});
let output_schema = load_output_schema(output_schema_path);
(
InitialOperation::UserTurn {
items,
output_schema,
},
prompt_text,
)
}
};
// Print the effective configuration and initial request so users can see what Codex
// is using.
event_processor.print_config_summary(&config, &prompt_summary, &session_configured);

View File

@@ -1,48 +1,60 @@
use serde::Deserialize;
use serde::Serialize;
use serde::Serializer;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
/// Generic JSON-Schema subset needed for our tool definitions.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
#[derive(Debug, Clone, PartialEq)]
pub enum JsonSchema {
Boolean {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
String {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// MCP schema allows "number" | "integer" for Number.
#[serde(alias = "integer")]
Number {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
Null {
description: Option<String>,
},
Array {
items: Box<JsonSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
Object {
properties: BTreeMap<String, JsonSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
required: Option<Vec<String>>,
#[serde(
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
additional_properties: Option<AdditionalProperties>,
},
Const {
value: JsonValue,
schema_type: Option<String>,
description: Option<String>,
},
Enum {
values: Vec<JsonValue>,
schema_type: Option<String>,
description: Option<String>,
},
AnyOf {
variants: Vec<JsonSchema>,
description: Option<String>,
},
OneOf {
variants: Vec<JsonSchema>,
description: Option<String>,
},
AllOf {
variants: Vec<JsonSchema>,
description: Option<String>,
},
}
/// Whether additional properties are allowed, and if so, any required schema.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
#[derive(Debug, Clone, PartialEq)]
pub enum AdditionalProperties {
Boolean(bool),
Schema(Box<JsonSchema>),
@@ -60,18 +72,41 @@ impl From<JsonSchema> for AdditionalProperties {
}
}
impl Serialize for JsonSchema {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
json_schema_to_json(self).serialize(serializer)
}
}
impl Serialize for AdditionalProperties {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::Boolean(value) => value.serialize(serializer),
Self::Schema(schema) => json_schema_to_json(schema).serialize(serializer),
}
}
}
/// Parse the tool `input_schema` or return an error for invalid schema.
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
let mut input_schema = input_schema.clone();
sanitize_json_schema(&mut input_schema);
serde_json::from_value::<JsonSchema>(input_schema)
parse_json_schema(&input_schema)
}
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
/// JsonSchema enum. This function:
/// - Ensures every schema object has a "type". If missing, infers it from
/// common keywords (properties => object, items => array, enum/const/format => string)
/// and otherwise defaults to "string".
/// - Infers a concrete `"type"` when it is missing and the shape can be reduced
/// to our supported subset (properties => object, items => array,
/// enum/const/format => string).
/// - Preserves explicit combiners like `anyOf`/`oneOf`/`allOf` and nullable
/// unions instead of collapsing them to a single fallback type.
/// - Fills required child fields (e.g. array items, object properties) with
/// permissive defaults when absent.
fn sanitize_json_schema(value: &mut JsonValue) {
@@ -107,22 +142,6 @@ fn sanitize_json_schema(value: &mut JsonValue) {
.and_then(|value| value.as_str())
.map(str::to_string);
if schema_type.is_none()
&& let Some(JsonValue::Array(types)) = map.get("type")
{
for candidate in types {
if let Some(candidate_type) = candidate.as_str()
&& matches!(
candidate_type,
"object" | "array" | "string" | "number" | "integer" | "boolean"
)
{
schema_type = Some(candidate_type.to_string());
break;
}
}
}
if schema_type.is_none() {
if map.contains_key("properties")
|| map.contains_key("required")
@@ -146,10 +165,11 @@ fn sanitize_json_schema(value: &mut JsonValue) {
}
}
let schema_type = schema_type.unwrap_or_else(|| "string".to_string());
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
if let Some(schema_type) = &schema_type {
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
}
if schema_type == "object" {
if schema_type.as_deref() == Some("object") {
if !map.contains_key("properties") {
map.insert(
"properties".to_string(),
@@ -163,7 +183,7 @@ fn sanitize_json_schema(value: &mut JsonValue) {
}
}
if schema_type == "array" && !map.contains_key("items") {
if schema_type.as_deref() == Some("array") && !map.contains_key("items") {
map.insert("items".to_string(), json!({ "type": "string" }));
}
}
@@ -171,6 +191,284 @@ fn sanitize_json_schema(value: &mut JsonValue) {
}
}
fn parse_json_schema(value: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
match value {
JsonValue::Bool(_) => Ok(JsonSchema::String { description: None }),
JsonValue::Object(map) => {
let description = map
.get("description")
.and_then(JsonValue::as_str)
.map(str::to_string);
if let Some(value) = map.get("const") {
return Ok(JsonSchema::Const {
value: value.clone(),
schema_type: map
.get("type")
.and_then(JsonValue::as_str)
.map(str::to_string),
description,
});
}
if let Some(values) = map.get("enum").and_then(JsonValue::as_array) {
return Ok(JsonSchema::Enum {
values: values.clone(),
schema_type: map
.get("type")
.and_then(JsonValue::as_str)
.map(str::to_string),
description,
});
}
if let Some(variants) = map.get("anyOf").and_then(JsonValue::as_array) {
return Ok(JsonSchema::AnyOf {
variants: variants
.iter()
.map(parse_json_schema)
.collect::<Result<Vec<_>, _>>()?,
description,
});
}
if let Some(variants) = map.get("oneOf").and_then(JsonValue::as_array) {
return Ok(JsonSchema::OneOf {
variants: variants
.iter()
.map(parse_json_schema)
.collect::<Result<Vec<_>, _>>()?,
description,
});
}
if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) {
return Ok(JsonSchema::AllOf {
variants: variants
.iter()
.map(parse_json_schema)
.collect::<Result<Vec<_>, _>>()?,
description,
});
}
if let Some(types) = map.get("type").and_then(JsonValue::as_array) {
return Ok(JsonSchema::AnyOf {
variants: types
.iter()
.filter_map(JsonValue::as_str)
.map(|schema_type| {
parse_json_schema(&json!({
"type": schema_type,
}))
})
.collect::<Result<Vec<_>, _>>()?,
description,
});
}
match map
.get("type")
.and_then(JsonValue::as_str)
.unwrap_or("string")
{
"boolean" => Ok(JsonSchema::Boolean { description }),
"string" => Ok(JsonSchema::String { description }),
"number" | "integer" => Ok(JsonSchema::Number { description }),
"null" => Ok(JsonSchema::Null { description }),
"array" => Ok(JsonSchema::Array {
items: Box::new(parse_json_schema(
map.get("items").unwrap_or(&json!({ "type": "string" })),
)?),
description,
}),
"object" => {
let properties = map
.get("properties")
.and_then(JsonValue::as_object)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|(name, value)| Ok((name, parse_json_schema(&value)?)))
.collect::<Result<BTreeMap<_, _>, serde_json::Error>>()?;
let required = map
.get("required")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
});
let additional_properties = map
.get("additionalProperties")
.map(parse_additional_properties)
.transpose()?;
Ok(JsonSchema::Object {
properties,
required,
additional_properties,
})
}
_ => Ok(JsonSchema::String { description }),
}
}
_ => Ok(JsonSchema::String { description: None }),
}
}
fn parse_additional_properties(
value: &JsonValue,
) -> Result<AdditionalProperties, serde_json::Error> {
match value {
JsonValue::Bool(flag) => Ok(AdditionalProperties::Boolean(*flag)),
_ => Ok(AdditionalProperties::Schema(Box::new(parse_json_schema(
value,
)?))),
}
}
fn json_schema_to_json(schema: &JsonSchema) -> JsonValue {
match schema {
JsonSchema::Boolean { description } => {
let mut map = serde_json::Map::new();
map.insert("type".to_string(), JsonValue::String("boolean".to_string()));
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::String { description } => {
let mut map = serde_json::Map::new();
map.insert("type".to_string(), JsonValue::String("string".to_string()));
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::Number { description } => {
let mut map = serde_json::Map::new();
map.insert("type".to_string(), JsonValue::String("number".to_string()));
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::Null { description } => {
let mut map = serde_json::Map::new();
map.insert("type".to_string(), JsonValue::String("null".to_string()));
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::Array { items, description } => {
let mut map = serde_json::Map::new();
map.insert("type".to_string(), JsonValue::String("array".to_string()));
map.insert("items".to_string(), json_schema_to_json(items));
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::Object {
properties,
required,
additional_properties,
} => {
let mut map = serde_json::Map::new();
map.insert("type".to_string(), JsonValue::String("object".to_string()));
map.insert(
"properties".to_string(),
JsonValue::Object(
properties
.iter()
.map(|(name, value)| (name.clone(), json_schema_to_json(value)))
.collect(),
),
);
if let Some(required) = required {
map.insert(
"required".to_string(),
JsonValue::Array(required.iter().cloned().map(JsonValue::String).collect()),
);
}
if let Some(additional_properties) = additional_properties {
map.insert(
"additionalProperties".to_string(),
match additional_properties {
AdditionalProperties::Boolean(flag) => JsonValue::Bool(*flag),
AdditionalProperties::Schema(schema) => json_schema_to_json(schema),
},
);
}
JsonValue::Object(map)
}
JsonSchema::Const {
value,
schema_type,
description,
} => {
let mut map = serde_json::Map::new();
map.insert("const".to_string(), value.clone());
if let Some(schema_type) = schema_type {
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
}
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::Enum {
values,
schema_type,
description,
} => {
let mut map = serde_json::Map::new();
map.insert("enum".to_string(), JsonValue::Array(values.clone()));
if let Some(schema_type) = schema_type {
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
}
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::AnyOf {
variants,
description,
} => {
let mut map = serde_json::Map::new();
map.insert(
"anyOf".to_string(),
JsonValue::Array(variants.iter().map(json_schema_to_json).collect()),
);
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::OneOf {
variants,
description,
} => {
let mut map = serde_json::Map::new();
map.insert(
"oneOf".to_string(),
JsonValue::Array(variants.iter().map(json_schema_to_json).collect()),
);
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
JsonSchema::AllOf {
variants,
description,
} => {
let mut map = serde_json::Map::new();
map.insert(
"allOf".to_string(),
JsonValue::Array(variants.iter().map(json_schema_to_json).collect()),
);
insert_description(&mut map, description.as_deref());
JsonValue::Object(map)
}
}
}
fn insert_description(map: &mut serde_json::Map<String, JsonValue>, description: Option<&str>) {
if let Some(description) = description {
map.insert(
"description".to_string(),
JsonValue::String(description.to_string()),
);
}
}
#[cfg(test)]
#[path = "json_schema_tests.rs"]
mod tests;

View File

@@ -87,7 +87,13 @@ fn parse_tool_input_schema_sanitizes_additional_properties_schema() {
JsonSchema::Object {
properties: BTreeMap::from([(
"value".to_string(),
JsonSchema::String { description: None },
JsonSchema::AnyOf {
variants: vec![
JsonSchema::String { description: None },
JsonSchema::Number { description: None },
],
description: None,
},
)]),
required: Some(vec!["value".to_string()]),
additional_properties: None,
@@ -96,3 +102,157 @@ fn parse_tool_input_schema_sanitizes_additional_properties_schema() {
}
);
}
#[test]
fn parse_tool_input_schema_preserves_web_run_shape() {
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"open": {
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"ref_id": {"type": "string"},
"lineno": {"anyOf": [{"type": "integer"}, {"type": "null"}]}
},
"required": ["ref_id"],
"additionalProperties": false
}
},
{"type": "null"}
]
},
"tagged_list": {
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {"type": "const", "const": "tagged"},
"variant": {"type": "enum", "enum": ["alpha", "beta"]},
"scope": {"type": "enum", "enum": ["one", "two"]}
},
"required": ["kind", "variant", "scope"]
}
},
{"type": "null"}
]
},
"response_length": {
"type": "enum",
"enum": ["short", "medium", "long"]
}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::Object {
properties: BTreeMap::from([
(
"open".to_string(),
JsonSchema::AnyOf {
variants: vec![
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties: BTreeMap::from([
(
"lineno".to_string(),
JsonSchema::AnyOf {
variants: vec![
JsonSchema::Number { description: None },
JsonSchema::Null { description: None },
],
description: None,
},
),
(
"ref_id".to_string(),
JsonSchema::String { description: None },
),
]),
required: Some(vec!["ref_id".to_string()]),
additional_properties: Some(false.into()),
}),
description: None,
},
JsonSchema::Null { description: None },
],
description: None,
},
),
(
"response_length".to_string(),
JsonSchema::Enum {
values: vec![
serde_json::json!("short"),
serde_json::json!("medium"),
serde_json::json!("long"),
],
schema_type: Some("enum".to_string()),
description: None,
},
),
(
"tagged_list".to_string(),
JsonSchema::AnyOf {
variants: vec![
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties: BTreeMap::from([
(
"kind".to_string(),
JsonSchema::Const {
value: serde_json::json!("tagged"),
schema_type: Some("const".to_string()),
description: None,
},
),
(
"scope".to_string(),
JsonSchema::Enum {
values: vec![
serde_json::json!("one"),
serde_json::json!("two"),
],
schema_type: Some("enum".to_string()),
description: None,
},
),
(
"variant".to_string(),
JsonSchema::Enum {
values: vec![
serde_json::json!("alpha"),
serde_json::json!("beta"),
],
schema_type: Some("enum".to_string()),
description: None,
},
),
]),
required: Some(vec![
"kind".to_string(),
"variant".to_string(),
"scope".to_string(),
]),
additional_properties: None,
}),
description: None,
},
JsonSchema::Null { description: None },
],
description: None,
},
),
]),
required: None,
additional_properties: None,
}
);
}

View File

@@ -1543,6 +1543,93 @@ fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() {
);
}
#[test]
fn code_mode_preserves_nullable_and_literal_mcp_input_shapes() {
let model_info = model_info();
let mut features = Features::with_defaults();
features.enable(Feature::CodeMode);
features.enable(Feature::UnifiedExec);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs(
&tools_config,
Some(HashMap::from([(
"mcp__sample__fn".to_string(),
mcp_tool(
"fn",
"Sample fn",
serde_json::json!({
"type": "object",
"properties": {
"open": {
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"ref_id": {"type": "string"},
"lineno": {"anyOf": [{"type": "integer"}, {"type": "null"}]}
},
"required": ["ref_id"],
"additionalProperties": false
}
},
{"type": "null"}
]
},
"tagged_list": {
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {"type": "const", "const": "tagged"},
"variant": {"type": "enum", "enum": ["alpha", "beta"]},
"scope": {"type": "enum", "enum": ["one", "two"]}
},
"required": ["kind", "variant", "scope"]
}
},
{"type": "null"}
]
},
"response_length": {"type": "enum", "enum": ["short", "medium", "long"]}
},
"additionalProperties": false
}),
),
)])),
/*app_tools*/ None,
&[],
);
let ToolSpec::Function(ResponsesApiTool { description, .. }) =
&find_tool(&tools, "mcp__sample__fn").spec
else {
panic!("expected function tool");
};
assert!(description.contains("mcp__sample__fn(args: { open?: Array<{"));
assert!(description.contains("lineno?: number | null;"));
assert!(description.contains("ref_id: string;"));
assert!(description.contains("response_length?: \"short\" | \"medium\" | \"long\";"));
assert!(description.contains("tagged_list?: Array<{"));
assert!(description.contains("kind: \"tagged\";"));
assert!(description.contains("variant: \"alpha\" | \"beta\";"));
assert!(!description.contains("open?: string;"));
}
#[test]
fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
let model_info = model_info();
@@ -1574,7 +1661,7 @@ fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
assert_eq!(
description,
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```"
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{ detail: string | null; image_url: string; }>; };\n```"
);
}
@@ -1842,7 +1929,37 @@ fn strip_descriptions_schema(schema: &mut JsonSchema) {
match schema {
JsonSchema::Boolean { description }
| JsonSchema::String { description }
| JsonSchema::Number { description } => {
| JsonSchema::Number { description }
| JsonSchema::Null { description } => {
*description = None;
}
JsonSchema::Const {
description,
value: _,
schema_type: _,
}
| JsonSchema::Enum {
description,
values: _,
schema_type: _,
} => {
*description = None;
}
JsonSchema::AnyOf {
variants,
description,
}
| JsonSchema::OneOf {
variants,
description,
}
| JsonSchema::AllOf {
variants,
description,
} => {
for variant in variants {
strip_descriptions_schema(variant);
}
*description = None;
}
JsonSchema::Array { items, description } => {