From cb1e9651e4e7b26f8979785fece2d5875266be4d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 27 Oct 2026 23:48:10 +0100 Subject: [PATCH] fix(schema): mark SessionInfo.additionalDirectories as default-on-null #2567 made v2 SessionInfo.additionalDirectories parse null as empty but landed after #2569 added the x-deserialize-default-on-null hint, so the field missed it. Generated parsers that read the hint (the TypeScript SDK) then reject null there, and because session/list skips invalid entries, the whole session drops out of the list instead of listing with no roots. Add the hint and a schema-generator test, like the existing DefaultOnError one, that fails when a DefaultOnNull field has no hint. Co-Authored-By: Claude --- agent-client-protocol-schema/src/v2/agent.rs | 1 + schema-generator/src/main.rs | 30 ++++++++++++++++++++ schema/v2/schema.json | 3 +- schema/v2/schema.unstable.json | 3 +- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/agent-client-protocol-schema/src/v2/agent.rs b/agent-client-protocol-schema/src/v2/agent.rs index c261e411c..b840b5879 100644 --- a/agent-client-protocol-schema/src/v2/agent.rs +++ b/agent-client-protocol-schema/src/v2/agent.rs @@ -1966,6 +1966,7 @@ pub struct SessionInfo { /// by the Agent. Omitted, `null`, and empty values are equivalent: the response /// reports no additional roots. #[serde_as(deserialize_as = "DefaultOnNull")] + #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-null" = true)))] #[serde(default, skip_serializing_if = "Vec::is_empty")] pub additional_directories: Vec, diff --git a/schema-generator/src/main.rs b/schema-generator/src/main.rs index 362a1fc1f..7f13c1478 100644 --- a/schema-generator/src/main.rs +++ b/schema-generator/src/main.rs @@ -283,6 +283,7 @@ mod schema_annotation_tests { const DEFAULT_ON_ERROR_EXTENSION: &str = "x-deserialize-default-on-error"; const SKIP_INVALID_ITEMS_EXTENSION: &str = "x-deserialize-skip-invalid-items"; + const DEFAULT_ON_NULL_EXTENSION: &str = "x-deserialize-default-on-null"; #[test] fn generated_notice_schema_is_available_without_unstable_features() { @@ -970,6 +971,35 @@ mod schema_annotation_tests { } } + #[test] + fn source_default_on_null_fields_are_schema_annotated() { + let root = schema_crate_dir(); + for module_dir in ["src/v1", "src/v2"] { + for entry in fs::read_dir(root.join(module_dir)).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + + let source = fs::read_to_string(&path).unwrap(); + let lines: Vec<_> = source.lines().collect(); + for (line_index, line) in lines.iter().enumerate() { + if !line.contains(r#"#[serde_as(deserialize_as = "DefaultOnNull"#) { + continue; + } + + let annotation = lines.get(line_index + 1).copied().unwrap_or_default(); + assert!( + annotation.contains(r#""x-deserialize-default-on-null" = true"#), + "{}:{} missing {DEFAULT_ON_NULL_EXTENSION}", + path.display(), + line_index + 1 + ); + } + } + } + } + #[test] fn source_meta_fields_are_default_on_error_annotated() { let root = schema_crate_dir(); diff --git a/schema/v2/schema.json b/schema/v2/schema.json index e22ddcc33..2cc6514b3 100644 --- a/schema/v2/schema.json +++ b/schema/v2/schema.json @@ -4177,7 +4177,8 @@ "type": "array", "items": { "$ref": "#/$defs/AbsolutePath" - } + }, + "x-deserialize-default-on-null": true }, "title": { "description": "Human-readable title for the session\n\nOmitted and `null` are equivalent and mean no title is provided.", diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index fc82d8491..5ecbf539d 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -4619,7 +4619,8 @@ "type": "array", "items": { "$ref": "#/$defs/AbsolutePath" - } + }, + "x-deserialize-default-on-null": true }, "title": { "description": "Human-readable title for the session\n\nOmitted and `null` are equivalent and mean no title is provided.",