diff --git a/codex-rs/code-mode-protocol/src/description.rs b/codex-rs/code-mode-protocol/src/description.rs index dde8ac2980..860074e769 100644 --- a/codex-rs/code-mode-protocol/src/description.rs +++ b/codex-rs/code-mode-protocol/src/description.rs @@ -5,6 +5,7 @@ use serde_json::Value as JsonValue; use std::collections::BTreeMap; use crate::PUBLIC_TOOL_NAME; +use crate::json_schema_types::render_json_schema_to_typescript; const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some deferred nested tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`. @@ -332,8 +333,7 @@ pub fn build_exec_tool_description( } } - let nested_tool_reference = nested_tool_sections.join("\n\n"); - sections.push(nested_tool_reference); + sections.push(nested_tool_sections.join("\n\n")); } sections.join("\n\n") @@ -400,7 +400,7 @@ pub fn render_code_mode_sample( ) -> String { let declaration = format!( "declare const tools: {{ {} }};", - render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type) + render_code_mode_tool_declaration(tool_name, input_name, &input_type, &output_type) ); format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```") } @@ -446,8 +446,8 @@ fn render_code_mode_sample_for_definition(definition: &ToolDefinition) -> String fn render_code_mode_tool_declaration( tool_name: &str, input_name: &str, - input_type: String, - output_type: String, + input_type: &str, + output_type: &str, ) -> String { let tool_name = normalize_code_mode_identifier(tool_name); format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;") @@ -461,10 +461,6 @@ fn render_tool_heading(global_name: &str, raw_name: &str) -> String { } } -pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String { - render_json_schema_to_typescript_inner(schema) -} - fn mcp_structured_content_schema(output_schema: Option<&JsonValue>) -> Option<&JsonValue> { let output_schema = output_schema?; let properties = output_schema @@ -506,226 +502,6 @@ fn mcp_structured_content_schema(output_schema: Option<&JsonValue>) -> Option<&J ) } -fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String { - match schema { - JsonValue::Bool(true) => "unknown".to_string(), - JsonValue::Bool(false) => "never".to_string(), - JsonValue::Object(map) => { - if let Some(value) = map.get("const") { - return render_json_schema_literal(value); - } - - if let Some(values) = map.get("enum").and_then(JsonValue::as_array) { - let rendered = values - .iter() - .map(render_json_schema_literal) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - - for key in ["anyOf", "oneOf"] { - if let Some(variants) = map.get(key).and_then(JsonValue::as_array) { - let rendered = variants - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - } - - if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) { - let rendered = variants - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" & "); - } - } - - if let Some(schema_type) = map.get("type") { - if let Some(types) = schema_type.as_array() { - let rendered = types - .iter() - .filter_map(JsonValue::as_str) - .map(|schema_type| render_json_schema_type_keyword(map, schema_type)) - .collect::>(); - if !rendered.is_empty() { - return rendered.join(" | "); - } - } - - if let Some(schema_type) = schema_type.as_str() { - return render_json_schema_type_keyword(map, schema_type); - } - } - - if map.contains_key("properties") - || map.contains_key("additionalProperties") - || map.contains_key("required") - { - return render_json_schema_object(map); - } - - if map.contains_key("items") || map.contains_key("prefixItems") { - return render_json_schema_array(map); - } - - "unknown".to_string() - } - _ => "unknown".to_string(), - } -} - -fn render_json_schema_type_keyword( - map: &serde_json::Map, - schema_type: &str, -) -> String { - match schema_type { - "string" => "string".to_string(), - "number" | "integer" => "number".to_string(), - "boolean" => "boolean".to_string(), - "null" => "null".to_string(), - "array" => render_json_schema_array(map), - "object" => render_json_schema_object(map), - _ => "unknown".to_string(), - } -} - -fn render_json_schema_array(map: &serde_json::Map) -> String { - if let Some(items) = map.get("items") { - let item_type = render_json_schema_to_typescript_inner(items); - return format!("Array<{item_type}>"); - } - - if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) { - let item_types = items - .iter() - .map(render_json_schema_to_typescript_inner) - .collect::>(); - if !item_types.is_empty() { - return format!("[{}]", item_types.join(", ")); - } - } - - "unknown[]".to_string() -} - -fn append_additional_properties_line( - lines: &mut Vec, - map: &serde_json::Map, - properties: &serde_json::Map, - line_prefix: &str, -) { - 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!("{line_prefix}[key: string]: {property_type};")); - } - } else if properties.is_empty() { - lines.push(format!("{line_prefix}[key: string]: unknown;")); - } -} - -fn has_property_description(value: &JsonValue) -> bool { - value - .get("description") - .and_then(JsonValue::as_str) - .is_some_and(|description| !description.is_empty()) -} - -fn render_json_schema_object_property(name: &str, value: &JsonValue, required: &[&str]) -> String { - 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); - format!("{property_name}{optional}: {property_type};") -} - -fn render_json_schema_object(map: &serde_json::Map) -> String { - let required = map - .get("required") - .and_then(JsonValue::as_array) - .map(|items| { - items - .iter() - .filter_map(JsonValue::as_str) - .collect::>() - }) - .unwrap_or_default(); - let properties = map - .get("properties") - .and_then(JsonValue::as_object) - .cloned() - .unwrap_or_default(); - - let mut sorted_properties = properties.iter().collect::>(); - sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a); - if sorted_properties - .iter() - .any(|(_, value)| has_property_description(value)) - { - 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}")); - } - } - - lines.push(format!( - " {}", - render_json_schema_object_property(name, value, &required) - )); - } - - append_additional_properties_line(&mut lines, map, &properties, " "); - lines.push("}".to_string()); - return lines.join("\n"); - } - - let mut lines = sorted_properties - .into_iter() - .map(|(name, value)| render_json_schema_object_property(name, value, &required)) - .collect::>(); - - append_additional_properties_line(&mut lines, map, &properties, ""); - - if lines.is_empty() { - return "{}".to_string(); - } - - format!("{{ {} }}", lines.join(" ")) -} - -fn render_json_schema_property_name(name: &str) -> String { - if normalize_code_mode_identifier(name) == name { - name.to_string() - } else { - serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) - } -} - -fn render_json_schema_literal(value: &JsonValue) -> String { - serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) -} - #[cfg(test)] mod tests { use super::CodeModeToolKind; @@ -875,6 +651,48 @@ mod tests { )); } + #[test] + fn code_mode_types_structured_content_result_refs() { + let definition = ToolDefinition { + name: "mcp__sample__search".to_string(), + tool_name: ToolName::namespaced("mcp__sample__", "search"), + description: "Search".to_string(), + kind: CodeModeToolKind::Function, + input_schema: Some(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })), + output_schema: Some(mcp_call_tool_result_schema(json!({ + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { "$ref": "#/definitions/Result~1item~0v1" } + } + }, + "required": ["results"], + "additionalProperties": false, + "definitions": { + "Result/item~v1": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "score": { "type": "number" } + }, + "required": ["id", "score"], + "additionalProperties": false + } + } + }))), + }; + + let description = augment_tool_definition(definition).description; + assert!(description.contains( + "mcp__sample__search(args: {}): Promise; }>>;" + )); + } + #[test] fn code_mode_only_description_includes_nested_tools() { let description = build_exec_tool_description( diff --git a/codex-rs/code-mode-protocol/src/json_schema_types.rs b/codex-rs/code-mode-protocol/src/json_schema_types.rs new file mode 100644 index 0000000000..0c519528a4 --- /dev/null +++ b/codex-rs/code-mode-protocol/src/json_schema_types.rs @@ -0,0 +1,538 @@ +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; + +use crate::description::normalize_code_mode_identifier; + +// Expose one nested recursive shape, then fall back to `unknown` on the next +// occurrence so generated tool declarations remain finite. +const MAX_LOCAL_REF_EXPANSIONS_PER_PATH: usize = 2; +// Bound repeated refs and DAG fan-out separately from cycle depth so one +// compact schema cannot expand into an arbitrarily large model-visible item. +const MAX_TOTAL_LOCAL_REF_EXPANSIONS: usize = 32; +// Bound individual schema rendering before assembling the final declaration. +const MAX_RENDERED_SCHEMA_BYTES: usize = 16_000; +// Charge intermediate render strings as they are built so repeated local refs +// cannot allocate unbounded expanded copies before the final schema cap runs. +const MAX_RENDER_WORK_BYTES: usize = MAX_RENDERED_SCHEMA_BYTES * 4; + +pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String { + let rendered = JsonSchemaTypeRenderer::new(schema).render(schema); + if rendered.len() > MAX_RENDERED_SCHEMA_BYTES { + "unknown".to_string() + } else { + rendered + } +} + +struct JsonSchemaTypeRenderer<'a> { + root: &'a JsonValue, + nested_schema_resource_depth: usize, + active_local_ref_expansions: BTreeMap, + remaining_local_ref_expansions: usize, + remaining_render_work_bytes: usize, + render_work_budget_exhausted: bool, +} + +impl<'a> JsonSchemaTypeRenderer<'a> { + fn new(root: &'a JsonValue) -> Self { + Self { + root, + nested_schema_resource_depth: 0, + active_local_ref_expansions: BTreeMap::new(), + remaining_local_ref_expansions: MAX_TOTAL_LOCAL_REF_EXPANSIONS, + remaining_render_work_bytes: MAX_RENDER_WORK_BYTES, + render_work_budget_exhausted: false, + } + } + + fn render(&mut self, schema: &JsonValue) -> String { + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + + // A nested `$id` starts a new schema resource. Fragment-only refs below + // it are scoped to that resource, not to the outer document root. + let enters_nested_schema_resource = !std::ptr::eq(schema, self.root) + && schema + .as_object() + .is_some_and(|map| map.contains_key("$id")); + if enters_nested_schema_resource { + self.nested_schema_resource_depth += 1; + } + + let rendered = match schema { + JsonValue::Bool(true) => "unknown".to_string(), + JsonValue::Bool(false) => "never".to_string(), + JsonValue::Object(map) => self.render_map(map), + _ => "unknown".to_string(), + }; + if enters_nested_schema_resource { + self.nested_schema_resource_depth -= 1; + } + self.finish_render(rendered) + } + + fn render_map(&mut self, map: &serde_json::Map) -> String { + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + + if map.contains_key("$ref") { + return self.render_ref(map); + } + + if let Some(value) = map.get("const") { + return self.render_literal(value); + } + + if let Some(values) = map.get("enum").and_then(JsonValue::as_array) { + let mut rendered = Vec::new(); + for value in values { + let literal = self.render_literal(value); + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + if !self.consume_render_work(literal.len()) { + return "unknown".to_string(); + } + rendered.push(literal); + } + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + for key in ["anyOf", "oneOf"] { + if let Some(variants) = map.get(key).and_then(JsonValue::as_array) { + let mut rendered = Vec::new(); + for variant in variants { + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + rendered.push(self.render(variant)); + } + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + } + + if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) { + let mut rendered = Vec::new(); + for variant in variants { + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + rendered.push(parenthesize_union_for_intersection(self.render(variant))); + } + if !rendered.is_empty() { + return rendered.join(" & "); + } + } + + if let Some(schema_type) = map.get("type") { + if let Some(types) = schema_type.as_array() { + let mut rendered = Vec::new(); + for schema_type in types.iter().filter_map(JsonValue::as_str) { + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + rendered.push(self.render_type_keyword(map, schema_type)); + } + if !rendered.is_empty() { + return rendered.join(" | "); + } + } + + if let Some(schema_type) = schema_type.as_str() { + return self.render_type_keyword(map, schema_type); + } + } + + if map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("required") + { + return self.render_object(map); + } + + if map.contains_key("items") || map.contains_key("prefixItems") { + return self.render_array(map); + } + + "unknown".to_string() + } + + fn render_ref(&mut self, map: &serde_json::Map) -> String { + let referenced_type = if self.nested_schema_resource_depth > 0 { + None + } else { + map.get("$ref") + .and_then(JsonValue::as_str) + .and_then(local_json_pointer) + .and_then(|pointer| { + let active_expansions = self + .active_local_ref_expansions + .get(&pointer) + .copied() + .unwrap_or_default(); + if active_expansions >= MAX_LOCAL_REF_EXPANSIONS_PER_PATH { + return Some("unknown".to_string()); + } + if self.remaining_local_ref_expansions == 0 { + return Some("unknown".to_string()); + } + + let root = self.root; + let target = if pointer.is_empty() { + Some(root) + } else { + root.pointer(&pointer) + }?; + self.remaining_local_ref_expansions -= 1; + self.active_local_ref_expansions + .insert(pointer.clone(), active_expansions + 1); + + let rendered = self.render(target); + if active_expansions == 0 { + self.active_local_ref_expansions.remove(&pointer); + } else { + self.active_local_ref_expansions + .insert(pointer, active_expansions); + } + Some(rendered) + }) + } + .unwrap_or_else(|| "unknown".to_string()); + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + + let siblings = map + .iter() + .filter(|(key, _)| !matches!(key.as_str(), "$ref" | "$defs" | "definitions")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + if !has_renderable_schema_keywords(&siblings) { + return referenced_type; + } + + let sibling_type = self.render_map(&siblings); + match (referenced_type.as_str(), sibling_type.as_str()) { + ("unknown", _) => sibling_type, + (_, "unknown") => referenced_type, + _ => format!("({referenced_type}) & ({sibling_type})"), + } + } + + fn render_type_keyword( + &mut self, + map: &serde_json::Map, + schema_type: &str, + ) -> String { + match schema_type { + "string" => "string".to_string(), + "number" | "integer" => "number".to_string(), + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => self.render_array(map), + "object" => self.render_object(map), + _ => "unknown".to_string(), + } + } + + fn render_array(&mut self, map: &serde_json::Map) -> String { + if let Some(items) = map.get("items") { + let item_type = self.render(items); + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + return format!("Array<{item_type}>"); + } + + if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) { + let mut item_types = Vec::new(); + for item in items { + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + item_types.push(self.render(item)); + } + if !item_types.is_empty() { + return format!("[{}]", item_types.join(", ")); + } + } + + "unknown[]".to_string() + } + + fn append_additional_properties_line( + &mut self, + lines: &mut Vec, + map: &serde_json::Map, + properties: &serde_json::Map, + line_prefix: &str, + ) -> bool { + 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(self.render(value)), + }; + + if let Some(property_type) = property_type { + return self.push_render_line( + lines, + format!("{line_prefix}[key: string]: {property_type};"), + ); + } + } else if properties.is_empty() { + return self.push_render_line(lines, format!("{line_prefix}[key: string]: unknown;")); + } + true + } + + fn render_object_property( + &mut self, + name: &str, + value: &JsonValue, + required: &[&str], + ) -> String { + if name.len() > self.remaining_render_work_bytes { + self.render_work_budget_exhausted = true; + return "unknown".to_string(); + } + let optional = if required.iter().any(|required_name| required_name == &name) { + "" + } else { + "?" + }; + let property_name = render_json_schema_property_name(name); + let property_type = self.render(value); + if self.render_work_budget_exhausted { + return "unknown".to_string(); + } + format!("{property_name}{optional}: {property_type};") + } + + fn render_object(&mut self, map: &serde_json::Map) -> String { + let required = map + .get("required") + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .filter_map(JsonValue::as_str) + .collect::>() + }) + .unwrap_or_default(); + let empty_properties = serde_json::Map::new(); + let properties = map + .get("properties") + .and_then(JsonValue::as_object) + .unwrap_or(&empty_properties); + + let mut sorted_properties = properties.iter().collect::>(); + sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a); + if sorted_properties + .iter() + .any(|(_, value)| has_property_description(value)) + { + let mut lines = Vec::new(); + if !self.push_render_line(&mut lines, "{".to_string()) { + return "unknown".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()) + { + if description_line.len().saturating_add(5) + > self.remaining_render_work_bytes + || !self + .push_render_line(&mut lines, format!(" // {description_line}")) + { + return "unknown".to_string(); + } + } + } + + let property = self.render_object_property(name, value, &required); + if self.render_work_budget_exhausted + || !self.push_render_line(&mut lines, format!(" {property}")) + { + return "unknown".to_string(); + } + } + + if !self.append_additional_properties_line(&mut lines, map, properties, " ") + || !self.push_render_line(&mut lines, "}".to_string()) + { + return "unknown".to_string(); + } + return lines.join("\n"); + } + + let mut lines = Vec::new(); + for (name, value) in sorted_properties { + let property = self.render_object_property(name, value, &required); + if self.render_work_budget_exhausted || !self.push_render_line(&mut lines, property) { + return "unknown".to_string(); + } + } + + if !self.append_additional_properties_line(&mut lines, map, properties, "") { + return "unknown".to_string(); + } + + if lines.is_empty() { + return "{}".to_string(); + } + + format!("{{ {} }}", lines.join(" ")) + } + + fn finish_render(&mut self, rendered: String) -> String { + if self.consume_render_work(rendered.len()) { + rendered + } else { + "unknown".to_string() + } + } + + fn render_literal(&mut self, value: &JsonValue) -> String { + if json_literal_serialization_upper_bound(value) > self.remaining_render_work_bytes { + self.render_work_budget_exhausted = true; + "unknown".to_string() + } else { + render_json_schema_literal(value) + } + } + + fn consume_render_work(&mut self, rendered_bytes: usize) -> bool { + if rendered_bytes > self.remaining_render_work_bytes { + self.render_work_budget_exhausted = true; + false + } else { + self.remaining_render_work_bytes -= rendered_bytes; + true + } + } + + fn push_render_line(&mut self, lines: &mut Vec, line: String) -> bool { + if !self.consume_render_work(line.len()) { + return false; + } + lines.push(line); + true + } +} + +fn parenthesize_union_for_intersection(rendered: String) -> String { + if rendered.contains(" | ") { + format!("({rendered})") + } else { + rendered + } +} + +fn local_json_pointer(reference: &str) -> Option { + let fragment = reference.strip_prefix('#')?; + let pointer = percent_decode_uri_fragment(fragment)?; + if pointer.is_empty() || pointer.starts_with('/') { + Some(pointer) + } else { + None + } +} + +fn percent_decode_uri_fragment(fragment: &str) -> Option { + let bytes = fragment.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let high = decode_hex_digit(*bytes.get(index + 1)?)?; + let low = decode_hex_digit(*bytes.get(index + 2)?)?; + decoded.push((high << 4) | low); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +fn decode_hex_digit(digit: u8) -> Option { + match digit { + b'0'..=b'9' => Some(digit - b'0'), + b'a'..=b'f' => Some(digit - b'a' + 10), + b'A'..=b'F' => Some(digit - b'A' + 10), + _ => None, + } +} + +fn has_renderable_schema_keywords(map: &serde_json::Map) -> bool { + [ + "const", + "enum", + "anyOf", + "oneOf", + "allOf", + "type", + "properties", + "additionalProperties", + "required", + "items", + "prefixItems", + ] + .iter() + .any(|key| map.contains_key(*key)) +} + +fn has_property_description(value: &JsonValue) -> bool { + value + .get("description") + .and_then(JsonValue::as_str) + .is_some_and(|description| !description.is_empty()) +} + +fn render_json_schema_property_name(name: &str) -> String { + if normalize_code_mode_identifier(name) == name { + name.to_string() + } else { + serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\""))) + } +} + +fn render_json_schema_literal(value: &JsonValue) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string()) +} + +fn json_literal_serialization_upper_bound(value: &JsonValue) -> usize { + match value { + JsonValue::Null => 4, + JsonValue::Bool(false) => 5, + JsonValue::Bool(true) => 4, + JsonValue::Number(number) => number.to_string().len(), + // JSON escaping can expand one UTF-8 byte to at most one six-byte + // Unicode escape, so this bounds allocation before serialization. + JsonValue::String(string) => string.len().saturating_mul(6).saturating_add(2), + JsonValue::Array(values) => values.iter().fold(2, |size, value| { + size.saturating_add(1) + .saturating_add(json_literal_serialization_upper_bound(value)) + }), + JsonValue::Object(map) => map.iter().fold(2, |size, (key, value)| { + size.saturating_add(4) + .saturating_add(key.len().saturating_mul(6)) + .saturating_add(json_literal_serialization_upper_bound(value)) + }), + } +} + +#[cfg(test)] +#[path = "json_schema_types_tests.rs"] +mod tests; diff --git a/codex-rs/code-mode-protocol/src/json_schema_types_tests.rs b/codex-rs/code-mode-protocol/src/json_schema_types_tests.rs new file mode 100644 index 0000000000..449865f60e --- /dev/null +++ b/codex-rs/code-mode-protocol/src/json_schema_types_tests.rs @@ -0,0 +1,200 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn renders_recursive_local_refs_with_escaped_pointer_segments() { + let schema = json!({ + "type": "object", + "properties": { + "clauses": { + "type": "array", + "items": { "$ref": "#/$defs/Boolean~1Clause~0v1" } + } + }, + "$defs": { + "Boolean/Clause~v1": { + "type": "object", + "properties": { + "query": { "$ref": "#/$defs/Query" } + } + }, + "Query": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "clauses": { + "type": "array", + "items": { "$ref": "#/$defs/Boolean~1Clause~0v1" } + } + } + } + ] + } + } + }); + + let rendered = render_json_schema_to_typescript(&schema); + assert!(rendered.contains("clauses?: Array<{ query?: string | { clauses?: Array<{")); + assert!(rendered.contains("query?: string | { clauses?: Array; };")); +} + +#[test] +fn renders_ref_siblings_uri_fragments_and_all_of_precedence() { + assert_eq!( + render_json_schema_to_typescript(&json!({ + "$ref": "#/$defs/Label", + "enum": ["A"], + "$defs": { "Label": { "type": "string" } } + })), + r#"(string) & ("A")"# + ); + assert_eq!( + render_json_schema_to_typescript(&json!({ + "$ref": "#/$defs/Foo%20Bar", + "$defs": { "Foo Bar": { "type": "string" } } + })), + "string" + ); + assert_eq!( + render_json_schema_to_typescript(&json!({ + "allOf": [ + { "$ref": "#/$defs/Choice" }, + { "type": "object", "properties": { "value": { "type": "string" } } } + ], + "$defs": { + "Choice": { "oneOf": [{ "type": "string" }, { "type": "number" }] } + } + })), + "(string | number) & { value?: string; }" + ); +} + +#[test] +fn leaves_local_refs_under_nested_schema_resources_unresolved() { + let schema = json!({ + "$defs": { + "Choice": { "type": "string" } + }, + "type": "object", + "properties": { + "nested": { + "$id": "urn:nested", + "$defs": { + "Choice": { "type": "number" } + }, + "type": "object", + "properties": { + "value": { "$ref": "#/$defs/Choice" } + } + } + } + }); + + assert_eq!( + render_json_schema_to_typescript(&schema), + "{ nested?: { value?: unknown; }; }" + ); +} + +#[test] +fn bounds_expansions_without_charging_dangling_refs() { + let mut properties = (0..MAX_TOTAL_LOCAL_REF_EXPANSIONS) + .map(|index| { + ( + format!("a_missing_{index}"), + json!({ "$ref": format!("#/$defs/Missing{index}") }), + ) + }) + .collect::>(); + properties.insert("z_valid".to_string(), json!({ "$ref": "#/$defs/Valid" })); + let schema = json!({ + "type": "object", + "properties": properties, + "$defs": { "Valid": { "type": "string" } } + }); + + let rendered = render_json_schema_to_typescript(&schema); + assert!(rendered.contains("z_valid?: string;")); + + let properties = (0..MAX_TOTAL_LOCAL_REF_EXPANSIONS + 2) + .map(|index| { + ( + format!("property_{index}"), + json!({ "$ref": "#/$defs/Item" }), + ) + }) + .collect::>(); + let rendered = render_json_schema_to_typescript(&json!({ + "type": "object", + "properties": properties, + "$defs": { "Item": { "type": "string" } } + })); + assert_eq!( + rendered.matches("string").count(), + MAX_TOTAL_LOCAL_REF_EXPANSIONS + ); + assert_eq!(rendered.matches("unknown").count(), 2); +} + +#[test] +fn repeated_large_ref_expansions_exhaust_render_work_budget() { + let properties = (0..MAX_TOTAL_LOCAL_REF_EXPANSIONS) + .map(|index| { + ( + format!("property_{index}"), + json!({ "$ref": "#/$defs/Item" }), + ) + }) + .collect::>(); + let schema = json!({ + "type": "object", + "properties": properties, + "$defs": { + "Item": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "x".repeat(MAX_RENDERED_SCHEMA_BYTES / 2) + } + } + } + } + }); + + let mut renderer = JsonSchemaTypeRenderer::new(&schema); + assert_eq!(renderer.render(&schema), "unknown"); + assert!(renderer.render_work_budget_exhausted); +} + +#[test] +fn oversized_ref_literal_exhausts_render_work_budget() { + let schema = json!({ + "$ref": "#/$defs/Value", + "$defs": { + "Value": { + "const": "x".repeat(MAX_RENDER_WORK_BYTES) + } + } + }); + + let mut renderer = JsonSchemaTypeRenderer::new(&schema); + assert_eq!(renderer.render(&schema), "unknown"); + assert!(renderer.render_work_budget_exhausted); +} + +#[test] +fn rendered_schema_has_a_hard_size_cap() { + let description = "x".repeat(MAX_RENDERED_SCHEMA_BYTES); + let schema = json!({ + "type": "object", + "properties": { + "value": { "type": "string", "description": description } + } + }); + + assert_eq!(render_json_schema_to_typescript(&schema), "unknown"); +} diff --git a/codex-rs/code-mode-protocol/src/lib.rs b/codex-rs/code-mode-protocol/src/lib.rs index 6af942a4e8..93eed078a4 100644 --- a/codex-rs/code-mode-protocol/src/lib.rs +++ b/codex-rs/code-mode-protocol/src/lib.rs @@ -1,6 +1,7 @@ mod description; pub mod grpc; pub mod host; +mod json_schema_types; mod response; mod runtime; mod session; @@ -19,7 +20,7 @@ pub use description::is_code_mode_nested_tool; pub use description::normalize_code_mode_identifier; pub use description::parse_exec_source; pub use description::render_code_mode_sample; -pub use description::render_json_schema_to_typescript; +pub use json_schema_types::render_json_schema_to_typescript; pub use response::DEFAULT_IMAGE_DETAIL; pub use response::FunctionCallOutputContentItem; pub use response::ImageDetail; diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index e23331d5b0..f9429a1464 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -5417,6 +5417,89 @@ text(JSON.stringify({ Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_renders_local_refs_in_outbound_exec_description() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("code mode only should be enabled"); + }); + let base_test = builder.build_with_auto_env(&server).await?; + let new_thread = base_test + .thread_manager + .start_thread(StartThreadOptions { + dynamic_tools: vec![DynamicToolSpec::Function(DynamicToolFunctionSpec { + name: "boolean_search".to_string(), + description: "Search with nested Boolean clauses.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "clauses": { + "type": "array", + "items": { "$ref": "#/$defs/Clause" } + } + }, + "$defs": { + "Clause": { + "type": "object", + "properties": { + "query": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "clauses": { + "type": "array", + "items": { "$ref": "#/$defs/Clause" } + } + } + } + ] + } + } + } + } + }), + defer_loading: false, + })], + ..StartThreadOptions::new(base_test.config.clone()) + }) + .await?; + let mut test = base_test; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; + + test.submit_turn("inspect the tool schema").await?; + + let body = response.single_request().body_json(); + let exec_description = body["tools"] + .as_array() + .expect("request should contain tools") + .iter() + .find_map(|tool| { + (tool["name"].as_str() == Some("exec")) + .then(|| tool["description"].as_str()) + .flatten() + }) + .expect("Code Mode exec should remain available"); + assert!(exec_description.contains("query?: string | { clauses?: Array<")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { skip_if_no_network!(Ok(()));