Compare commits

...

4 Commits

Author SHA1 Message Date
celia-oai
f9bf071758 comment 2026-05-18 17:50:20 -07:00
celia-oai
07276e5768 comments 2026-05-18 16:23:51 -07:00
celia-oai
4a6f28279e changes 2026-05-18 14:42:49 -07:00
celia-oai
fc08419c13 changes 2026-05-18 13:34:18 -07:00
6 changed files with 713 additions and 1 deletions

1
MODULE.bazel.lock generated
View File

@@ -1141,6 +1141,7 @@
"jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}",
"jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}",
"js-sys_0.3.85": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}",
"jsonptr_0.7.1": "{\"dependencies\":[{\"features\":[\"fancy\"],\"name\":\"miette\",\"optional\":true,\"req\":\"^7.4.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.203\"},{\"features\":[\"alloc\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.119\"},{\"name\":\"syn\",\"optional\":true,\"req\":\"^1.0.109\",\"target\":\"cfg(any())\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"assign\":[],\"default\":[\"std\",\"serde\",\"json\",\"resolve\",\"assign\",\"delete\"],\"delete\":[\"resolve\"],\"json\":[\"dep:serde_json\",\"serde\"],\"miette\":[\"dep:miette\",\"std\"],\"resolve\":[],\"std\":[\"serde/std\",\"serde_json?/std\"],\"toml\":[\"dep:toml\",\"serde\",\"std\"]}}",
"jsonwebtoken_9.3.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3\"},{\"features\":[\"std\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\",\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"simple_asn1\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.1\"}],\"features\":{\"default\":[\"use_pem\"],\"use_pem\":[\"pem\",\"simple_asn1\"]}}",
"keyring_3.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\",\"wrap_help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.1\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"features\":[\"std\"],\"name\":\"linux-keyutils\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"log\",\"req\":\"^0.4.22\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.66\"},{\"kind\":\"dev\",\"name\":\"rpassword\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"rprompt\",\"req\":\"^2\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"whoami\",\"req\":\"^1.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Credentials\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"apple-native\":[\"dep:security-framework\"],\"async-io\":[\"zbus?/async-io\"],\"async-secret-service\":[\"dep:secret-service\",\"dep:zbus\"],\"crypto-openssl\":[\"dbus-secret-service?/crypto-openssl\",\"secret-service?/crypto-openssl\"],\"crypto-rust\":[\"dbus-secret-service?/crypto-rust\",\"secret-service?/crypto-rust\"],\"linux-native\":[\"dep:linux-keyutils\"],\"linux-native-async-persistent\":[\"linux-native\",\"async-secret-service\"],\"linux-native-sync-persistent\":[\"linux-native\",\"sync-secret-service\"],\"sync-secret-service\":[\"dep:dbus-secret-service\"],\"tokio\":[\"zbus?/tokio\"],\"vendored\":[\"dbus-secret-service?/vendored\",\"openssl?/vendored\"],\"windows-native\":[\"dep:windows-sys\",\"dep:byteorder\"]}}",
"kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}",

8
codex-rs/Cargo.lock generated
View File

@@ -3757,12 +3757,14 @@ dependencies = [
"codex-utils-absolute-path",
"codex-utils-pty",
"codex-utils-string",
"jsonptr",
"pretty_assertions",
"rmcp",
"serde",
"serde_json",
"thiserror 2.0.18",
"tracing",
"urlencoding",
]
[[package]]
@@ -8088,6 +8090,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonptr"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe"
[[package]]
name = "jsonwebtoken"
version = "9.3.1"

View File

@@ -301,6 +301,7 @@ indexmap = "2.12.0"
insta = "1.46.3"
inventory = "0.3.19"
itertools = "0.14.0"
jsonptr = { version = "0.7.1", default-features = false }
jsonwebtoken = "9.3.1"
keyring = { version = "3.6", default-features = false }
landlock = "0.4.4"

View File

@@ -16,6 +16,7 @@ codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-string = { workspace = true }
jsonptr = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
"base64",
"macros",
@@ -26,6 +27,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
urlencoding = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -3,6 +3,7 @@ use serde::Serialize;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
/// Primitive JSON Schema type names we support in tool definitions.
///
@@ -33,6 +34,8 @@ pub enum JsonSchemaType {
/// Generic JSON-Schema subset needed for our tool definitions.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct JsonSchema {
#[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
pub schema_ref: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub schema_type: Option<JsonSchemaType>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -52,6 +55,10 @@ pub struct JsonSchema {
pub additional_properties: Option<AdditionalProperties>,
#[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")]
pub any_of: Option<Vec<JsonSchema>>,
#[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
pub defs: Option<BTreeMap<String, JsonSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub definitions: Option<BTreeMap<String, JsonSchema>>,
}
impl JsonSchema {
@@ -149,6 +156,7 @@ impl From<JsonSchema> for AdditionalProperties {
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);
prune_unreachable_definitions(&mut input_schema);
let schema: JsonSchema = serde_json::from_value(input_schema)?;
if matches!(
schema.schema_type,
@@ -163,6 +171,7 @@ pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, s
/// schema representation. This function:
/// - Ensures every typed schema object has a `"type"` when required.
/// - Preserves explicit `anyOf`.
/// - Preserves `$ref` and reachable local `$defs` / `definitions`.
/// - Collapses `const` into single-value `enum`.
/// - Fills required child fields for object/array schema types, including
/// nullable unions, with permissive defaults when absent.
@@ -200,6 +209,8 @@ fn sanitize_json_schema(value: &mut JsonValue) {
if let Some(value) = map.get_mut("anyOf") {
sanitize_json_schema(value);
}
sanitize_schema_table(map, "$defs");
sanitize_schema_table(map, "definitions");
if let Some(const_value) = map.remove("const") {
map.insert("enum".to_string(), JsonValue::Array(vec![const_value]));
@@ -207,7 +218,7 @@ fn sanitize_json_schema(value: &mut JsonValue) {
let mut schema_types = normalized_schema_types(map);
if schema_types.is_empty() && map.contains_key("anyOf") {
if schema_types.is_empty() && (map.contains_key("$ref") || map.contains_key("anyOf")) {
return;
}
@@ -241,6 +252,29 @@ fn sanitize_json_schema(value: &mut JsonValue) {
}
}
/// Sanitize a schema definition table before deserializing into `JsonSchema`.
///
/// Definition tables must be objects. Codex keeps valid definition tables and
/// recursively applies the same compatibility lowering used for inline schemas,
/// but drops malformed tables so `strict: false` tool registration degrades
/// gracefully instead of failing on an unreachable or invalid definition table.
fn sanitize_schema_table(map: &mut serde_json::Map<String, JsonValue>, key: &str) {
let should_remove = match map.get_mut(key) {
Some(JsonValue::Object(definitions)) => {
for definition in definitions.values_mut() {
sanitize_json_schema(definition);
}
false
}
Some(_) => true,
None => false,
};
if should_remove {
map.remove(key);
}
}
fn ensure_default_children_for_schema_types(
map: &mut serde_json::Map<String, JsonValue>,
schema_types: &[JsonSchemaPrimitiveType],
@@ -257,6 +291,196 @@ fn ensure_default_children_for_schema_types(
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum DefinitionTable {
Defs,
Definitions,
}
impl DefinitionTable {
fn key(&self) -> &'static str {
match self {
Self::Defs => "$defs",
Self::Definitions => "definitions",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct DefinitionPointer {
table: DefinitionTable,
name: String,
}
/// Prune unused root definition entries to avoid sending tokens for definitions
/// the tool schema never references.
fn prune_unreachable_definitions(value: &mut JsonValue) {
let reachable = collect_reachable_definitions(value);
let JsonValue::Object(map) = value else {
return;
};
prune_schema_table(map, DefinitionTable::Defs, &reachable);
prune_schema_table(map, DefinitionTable::Definitions, &reachable);
}
fn prune_schema_table(
map: &mut serde_json::Map<String, JsonValue>,
table: DefinitionTable,
reachable: &BTreeSet<DefinitionPointer>,
) {
let Some(JsonValue::Object(definitions)) = map.get_mut(table.key()) else {
return;
};
definitions.retain(|name, _| {
reachable.contains(&DefinitionPointer {
table: table.clone(),
name: name.clone(),
})
});
if definitions.is_empty() {
map.remove(table.key());
}
}
fn collect_reachable_definitions(value: &JsonValue) -> BTreeSet<DefinitionPointer> {
let mut reachable = BTreeSet::new();
let mut pending = Vec::new();
collect_refs_outside_definitions(value, &mut pending);
while let Some(pointer) = pending.pop() {
if !reachable.insert(pointer.clone()) {
continue;
}
if let Some(definition) = definition_for_pointer(value, &pointer) {
collect_refs(definition, &mut pending);
}
}
reachable
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RefCollectionContext {
SchemaObject,
PropertiesMap,
}
fn collect_refs_outside_definitions(value: &JsonValue, refs: &mut Vec<DefinitionPointer>) {
collect_refs_outside_definitions_in_context(value, refs, RefCollectionContext::SchemaObject);
}
fn collect_refs_outside_definitions_in_context(
value: &JsonValue,
refs: &mut Vec<DefinitionPointer>,
context: RefCollectionContext,
) {
match value {
JsonValue::Array(values) => {
for value in values {
collect_refs_outside_definitions_in_context(
value,
refs,
RefCollectionContext::SchemaObject,
);
}
}
JsonValue::Object(map) => match context {
RefCollectionContext::SchemaObject => {
collect_ref_from_map(map, refs);
for (key, value) in map {
if key == "$defs" || key == "definitions" {
continue;
}
let child_context = if key == "properties" {
RefCollectionContext::PropertiesMap
} else {
RefCollectionContext::SchemaObject
};
collect_refs_outside_definitions_in_context(value, refs, child_context);
}
}
RefCollectionContext::PropertiesMap => {
for value in map.values() {
collect_refs_outside_definitions_in_context(
value,
refs,
RefCollectionContext::SchemaObject,
);
}
}
},
_ => {}
}
}
fn collect_refs(value: &JsonValue, refs: &mut Vec<DefinitionPointer>) {
match value {
JsonValue::Array(values) => {
for value in values {
collect_refs(value, refs);
}
}
JsonValue::Object(map) => {
collect_ref_from_map(map, refs);
for value in map.values() {
collect_refs(value, refs);
}
}
_ => {}
}
}
fn collect_ref_from_map(
map: &serde_json::Map<String, JsonValue>,
refs: &mut Vec<DefinitionPointer>,
) {
if let Some(JsonValue::String(schema_ref)) = map.get("$ref")
&& let Some(pointer) = parse_local_definition_ref(schema_ref)
{
refs.push(pointer);
}
}
fn definition_for_pointer<'a>(
value: &'a JsonValue,
pointer: &DefinitionPointer,
) -> Option<&'a JsonValue> {
let JsonValue::Object(map) = value else {
return None;
};
map.get(pointer.table.key())
.and_then(JsonValue::as_object)
.and_then(|definitions| definitions.get(&pointer.name))
}
fn parse_local_definition_ref(schema_ref: &str) -> Option<DefinitionPointer> {
let fragment = schema_ref.strip_prefix('#')?;
let pointer = urlencoding::decode(fragment).ok()?;
let pointer = jsonptr::Pointer::parse(pointer.as_ref()).ok()?;
let (table_token, pointer) = pointer.split_front()?;
let table = match table_token.decoded().as_ref() {
"$defs" => DefinitionTable::Defs,
"definitions" => DefinitionTable::Definitions,
_ => return None,
};
// Responses API non-strict mode accepts nested local refs such as
// `#/$defs/User/properties/name`, so keep the parent definition reachable.
let (name, _) = pointer.split_front()?;
Some(DefinitionPointer {
table,
name: name.decoded().into_owned(),
})
}
fn normalized_schema_types(
map: &serde_json::Map<String, JsonValue>,
) -> Vec<JsonSchemaPrimitiveType> {

View File

@@ -848,3 +848,479 @@ fn parse_tool_input_schema_preserves_string_enum_constraints() {
)
);
}
#[test]
fn parse_tool_input_schema_preserves_refs_and_prunes_unreachable_defs() {
// Example schema shape:
// {
// "type": "object",
// "properties": { "user": { "$ref": "#/$defs/User" } },
// "$defs": {
// "User": { "type": "object", "properties": { "name": { "type": "string" } } },
// "Unused": { "type": "string" }
// }
// }
//
// Expected normalization behavior:
// - Local `$ref` is preserved as a schema hint.
// - Reachable `$defs` entries stay attached to the root schema.
// - Unreachable `$defs` entries are pruned.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"user": {"$ref": "#/$defs/User"}
},
"$defs": {
"User": {
"type": "object",
"properties": {
"name": {"type": "string"}
}
},
"Unused": {"type": "string"}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(BTreeMap::from([(
"user".to_string(),
JsonSchema {
schema_ref: Some("#/$defs/User".to_string()),
..Default::default()
},
)])),
defs: Some(BTreeMap::from([(
"User".to_string(),
JsonSchema::object(
BTreeMap::from([(
"name".to_string(),
JsonSchema::string(/*description*/ None),
)]),
/*required*/ None,
/*additional_properties*/ None,
),
)])),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_preserves_refs_from_properties_named_def_tables() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "$defs": { "$ref": "#/$defs/User" }
// },
// "$defs": { "User": { "type": "string" }, "Unused": { "type": "boolean" } }
// }
//
// Expected normalization behavior:
// - A property named like the `$defs` keyword is treated as a user field
// while traversing `properties`.
// - Refs from that property schema still mark root definitions reachable.
// - Unreferenced root definitions are still pruned.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"$defs": {"$ref": "#/$defs/User"}
},
"$defs": {
"User": {"type": "string"},
"Unused": {"type": "boolean"}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(BTreeMap::from([(
"$defs".to_string(),
JsonSchema {
schema_ref: Some("#/$defs/User".to_string()),
..Default::default()
},
)])),
defs: Some(BTreeMap::from([(
"User".to_string(),
JsonSchema::string(/*description*/ None),
)])),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_handles_cyclic_local_refs() {
// Example schema shape:
// {
// "type": "object",
// "properties": { "node": { "$ref": "#/$defs/Node" } },
// "$defs": {
// "Node": {
// "type": "object",
// "properties": { "next": { "$ref": "#/$defs/Node" } }
// }
// }
// }
//
// Expected normalization behavior:
// - Recursive refs are preserved.
// - Pruning traversal terminates after visiting each local target once.
// - Responses API handles this recursive local-ref shape correctly.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"node": {"$ref": "#/$defs/Node"}
},
"$defs": {
"Node": {
"type": "object",
"properties": {
"next": {"$ref": "#/$defs/Node"}
}
}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(BTreeMap::from([(
"node".to_string(),
JsonSchema {
schema_ref: Some("#/$defs/Node".to_string()),
..Default::default()
},
)])),
defs: Some(BTreeMap::from([(
"Node".to_string(),
JsonSchema::object(
BTreeMap::from([(
"next".to_string(),
JsonSchema {
schema_ref: Some("#/$defs/Node".to_string()),
..Default::default()
},
)]),
/*required*/ None,
/*additional_properties*/ None,
),
)])),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_preserves_legacy_definitions() {
// Example schema shape:
// {
// "type": "object",
// "properties": { "user": { "$ref": "#/definitions/User" } },
// "definitions": {
// "User": { "type": "object", "properties": { "profile": { "$ref": "#/definitions/Profile" } } },
// "Profile": { "type": "object", "properties": { "name": { "type": "string" } } }
// }
// }
//
// Expected normalization behavior:
// - Codex preserves legacy `definitions`.
// - Reachability follows refs through the legacy definition table.
// - Unreachable legacy definition entries are pruned.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"user": {"$ref": "#/definitions/User"}
},
"definitions": {
"User": {
"type": "object",
"properties": {
"profile": {"$ref": "#/definitions/Profile"}
}
},
"Profile": {
"type": "object",
"properties": {
"name": {"type": "string"}
}
},
"Unused": {"type": "string"}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(BTreeMap::from([(
"user".to_string(),
JsonSchema {
schema_ref: Some("#/definitions/User".to_string()),
..Default::default()
},
)])),
definitions: Some(BTreeMap::from([
(
"Profile".to_string(),
JsonSchema::object(
BTreeMap::from([(
"name".to_string(),
JsonSchema::string(/*description*/ None),
)]),
/*required*/ None,
/*additional_properties*/ None,
),
),
(
"User".to_string(),
JsonSchema::object(
BTreeMap::from([(
"profile".to_string(),
JsonSchema {
schema_ref: Some("#/definitions/Profile".to_string()),
..Default::default()
},
)]),
/*required*/ None,
/*additional_properties*/ None,
),
),
])),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_preserves_unresolved_and_external_refs() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "missing": { "$ref": "#/$defs/Missing" },
// "remote": { "$ref": "https://example.com/schema.json" }
// },
// "$defs": { "Unused": { "type": "string" } }
// }
//
// Expected normalization behavior:
// - Unresolved local refs and external refs are preserved.
// - Unreachable local definitions are still pruned.
// - Responses API handles these refs correctly during downstream validation.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"missing": {"$ref": "#/$defs/Missing"},
"remote": {"$ref": "https://example.com/schema.json"}
},
"$defs": {
"Unused": {"type": "string"}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(BTreeMap::from([
(
"missing".to_string(),
JsonSchema {
schema_ref: Some("#/$defs/Missing".to_string()),
..Default::default()
},
),
(
"remote".to_string(),
JsonSchema {
schema_ref: Some("https://example.com/schema.json".to_string()),
..Default::default()
},
),
])),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_preserves_nested_defs_ref_parent() {
// Example schema shape:
// {
// "type": "object",
// "properties": { "name": { "$ref": "#/$defs/User/properties/name" } },
// "$defs": {
// "User": { "type": "object", "properties": { "name": { "type": "string" } } },
// "name": { "type": "string" },
// "Unused": { "type": "boolean" }
// }
// }
//
// Expected normalization behavior:
// - The nested JSON Pointer ref remains unchanged.
// - The parent root definition is retained so the local ref does not dangle.
// - Unreferenced root definitions are still pruned.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"name": {"$ref": "#/$defs/User/properties/name"}
},
"$defs": {
"User": {
"type": "object",
"properties": {
"name": {"type": "string"}
}
},
"name": {"type": "string"},
"Unused": {"type": "boolean"}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(BTreeMap::from([(
"name".to_string(),
JsonSchema {
schema_ref: Some("#/$defs/User/properties/name".to_string()),
..Default::default()
},
)])),
defs: Some(BTreeMap::from([(
"User".to_string(),
JsonSchema::object(
BTreeMap::from([(
"name".to_string(),
JsonSchema::string(/*description*/ None),
)]),
/*required*/ None,
/*additional_properties*/ None,
),
)])),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_preserves_percent_encoded_definition_refs() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "user": { "$ref": "#/$defs/User%20Name" },
// "profile": { "$ref": "#/%24defs/Profile%7E0Name" }
// },
// "$defs": {
// "User Name": { "type": "string" },
// "Profile~Name": { "type": "string" },
// "Unused": { "type": "boolean" }
// }
// }
//
// Expected normalization behavior:
// - URI fragment percent encoding is decoded before JSON Pointer `~`
// escaping, per RFC 6901 section 6.
// - The original `$ref` strings are preserved, but their definition
// targets are recognized as reachable and retained.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"user": {"$ref": "#/$defs/User%20Name"},
"profile": {"$ref": "#/%24defs/Profile%7E0Name"}
},
"$defs": {
"User Name": {"type": "string"},
"Profile~Name": {"type": "string"},
"Unused": {"type": "boolean"}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(BTreeMap::from([
(
"profile".to_string(),
JsonSchema {
schema_ref: Some("#/%24defs/Profile%7E0Name".to_string()),
..Default::default()
},
),
(
"user".to_string(),
JsonSchema {
schema_ref: Some("#/$defs/User%20Name".to_string()),
..Default::default()
},
),
])),
defs: Some(BTreeMap::from([
(
"Profile~Name".to_string(),
JsonSchema::string(/*description*/ None),
),
(
"User Name".to_string(),
JsonSchema::string(/*description*/ None),
),
])),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_drops_malformed_definition_tables() {
// Example schema shape:
// {
// "type": "object",
// "properties": { "user": { "$ref": "#/$defs/User" } },
// "$defs": ["not", "an", "object"]
// }
//
// Expected normalization behavior:
// - Malformed `$defs` tables are dropped instead of rejecting the schema.
// - The unresolved local ref remains visible to the model.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"user": {"$ref": "#/$defs/User"}
},
"$defs": ["not", "an", "object"]
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(BTreeMap::from([(
"user".to_string(),
JsonSchema {
schema_ref: Some("#/$defs/User".to_string()),
..Default::default()
},
)])),
..Default::default()
}
);
}