From 1b5b820b738bd26f9e2eb1c66036b7f4dd74d7a8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 30 Jun 2026 10:22:14 +0200 Subject: [PATCH 1/2] fix(unstable-v2): make v2 config options non-null arrays --- agent-client-protocol-schema/src/v2/agent.rs | 148 +++++++++++++----- .../src/v2/conversion.rs | 49 +++++- docs/protocol/v2/draft/schema.mdx | 16 +- docs/protocol/v2/schema.mdx | 12 +- schema/v2/schema.json | 12 +- schema/v2/schema.unstable.json | 16 +- 6 files changed, 175 insertions(+), 78 deletions(-) diff --git a/agent-client-protocol-schema/src/v2/agent.rs b/agent-client-protocol-schema/src/v2/agent.rs index a3f3684ce..b9d27e79a 100644 --- a/agent-client-protocol-schema/src/v2/agent.rs +++ b/agent-client-protocol-schema/src/v2/agent.rs @@ -1167,11 +1167,11 @@ pub struct NewSessionResponse { /// /// Used in all subsequent requests for this conversation. pub session_id: SessionId, - /// Initial session configuration options if supported by the Agent. - #[serde_as(deserialize_as = "DefaultOnError>>")] + /// Initial session configuration options. + #[serde_as(deserialize_as = "DefaultOnError>")] #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))] - #[serde(default)] - pub config_options: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config_options: Vec, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -1190,18 +1190,15 @@ impl NewSessionResponse { pub fn new(session_id: impl Into) -> Self { Self { session_id: session_id.into(), - config_options: None, + config_options: Vec::new(), meta: None, } } - /// Initial session configuration options if supported by the Agent. + /// Initial session configuration options. #[must_use] - pub fn config_options( - mut self, - config_options: impl IntoOption>, - ) -> Self { - self.config_options = config_options.into_option(); + pub fn config_options(mut self, config_options: Vec) -> Self { + self.config_options = config_options; self } @@ -1308,11 +1305,11 @@ impl LoadSessionRequest { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct LoadSessionResponse { - /// Initial session configuration options if supported by the Agent. - #[serde_as(deserialize_as = "DefaultOnError>>")] + /// Initial session configuration options. + #[serde_as(deserialize_as = "DefaultOnError>")] #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))] - #[serde(default)] - pub config_options: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config_options: Vec, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -1332,13 +1329,10 @@ impl LoadSessionResponse { Self::default() } - /// Initial session configuration options if supported by the Agent. + /// Initial session configuration options. #[must_use] - pub fn config_options( - mut self, - config_options: impl IntoOption>, - ) -> Self { - self.config_options = config_options.into_option(); + pub fn config_options(mut self, config_options: Vec) -> Self { + self.config_options = config_options; self } @@ -1459,11 +1453,11 @@ impl ForkSessionRequest { pub struct ForkSessionResponse { /// Unique identifier for the newly created forked session. pub session_id: SessionId, - /// Initial session configuration options if supported by the Agent. - #[serde_as(deserialize_as = "DefaultOnError>>")] + /// Initial session configuration options. + #[serde_as(deserialize_as = "DefaultOnError>")] #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))] - #[serde(default)] - pub config_options: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config_options: Vec, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -1483,18 +1477,15 @@ impl ForkSessionResponse { pub fn new(session_id: impl Into) -> Self { Self { session_id: session_id.into(), - config_options: None, + config_options: Vec::new(), meta: None, } } - /// Initial session configuration options if supported by the Agent. + /// Initial session configuration options. #[must_use] - pub fn config_options( - mut self, - config_options: impl IntoOption>, - ) -> Self { - self.config_options = config_options.into_option(); + pub fn config_options(mut self, config_options: Vec) -> Self { + self.config_options = config_options; self } @@ -1603,11 +1594,11 @@ impl ResumeSessionRequest { #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct ResumeSessionResponse { - /// Initial session configuration options if supported by the Agent. - #[serde_as(deserialize_as = "DefaultOnError>>")] + /// Initial session configuration options. + #[serde_as(deserialize_as = "DefaultOnError>")] #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))] - #[serde(default)] - pub config_options: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config_options: Vec, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at /// these keys. @@ -1627,13 +1618,10 @@ impl ResumeSessionResponse { Self::default() } - /// Initial session configuration options if supported by the Agent. + /// Initial session configuration options. #[must_use] - pub fn config_options( - mut self, - config_options: impl IntoOption>, - ) -> Self { - self.config_options = config_options.into_option(); + pub fn config_options(mut self, config_options: Vec) -> Self { + self.config_options = config_options; self } @@ -5886,6 +5874,82 @@ mod test_serialization { ); } + fn test_config_option() -> SessionConfigOption { + SessionConfigOption::select( + "mode", + "Mode", + "ask", + vec![SessionConfigSelectOption::new("ask", "Ask")], + ) + } + + #[test] + fn test_session_response_config_options_default_empty_and_skip_serializing() { + assert_eq!( + serde_json::to_value(NewSessionResponse::new("sess")).unwrap(), + json!({ "sessionId": "sess" }) + ); + assert_eq!( + serde_json::to_value(LoadSessionResponse::new()).unwrap(), + json!({}) + ); + assert_eq!( + serde_json::to_value(ResumeSessionResponse::new()).unwrap(), + json!({}) + ); + #[cfg(feature = "unstable_session_fork")] + assert_eq!( + serde_json::to_value(ForkSessionResponse::new("fork")).unwrap(), + json!({ "sessionId": "fork" }) + ); + + let json = serde_json::to_value( + NewSessionResponse::new("sess").config_options(vec![test_config_option()]), + ) + .unwrap(); + assert_eq!(json["configOptions"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_session_response_config_options_deserialize_missing_null_and_invalid() { + let missing: NewSessionResponse = + serde_json::from_value(json!({ "sessionId": "sess" })).unwrap(); + assert!(missing.config_options.is_empty()); + + let null: NewSessionResponse = serde_json::from_value(json!({ + "sessionId": "sess", + "configOptions": null + })) + .unwrap(); + assert!(null.config_options.is_empty()); + + let wrong_shape: NewSessionResponse = serde_json::from_value(json!({ + "sessionId": "sess", + "configOptions": "oops" + })) + .unwrap(); + assert!(wrong_shape.config_options.is_empty()); + + let valid_option = serde_json::to_value(test_config_option()).unwrap(); + let mixed: NewSessionResponse = serde_json::from_value(json!({ + "sessionId": "sess", + "configOptions": ["oops", valid_option] + })) + .unwrap(); + assert_eq!(mixed.config_options.len(), 1); + + let load: LoadSessionResponse = serde_json::from_value(json!({})).unwrap(); + assert!(load.config_options.is_empty()); + let resume: ResumeSessionResponse = serde_json::from_value(json!({})).unwrap(); + assert!(resume.config_options.is_empty()); + #[cfg(feature = "unstable_session_fork")] + { + let fork: ForkSessionResponse = + serde_json::from_value(json!({ "sessionId": "fork" })).unwrap(); + assert!(fork.config_options.is_empty()); + } + } + #[test] fn test_auth_method_agent_serialization() { let method = AuthMethod::Agent(AuthMethodAgent::new("default-auth", "Default Auth")); diff --git a/agent-client-protocol-schema/src/v2/conversion.rs b/agent-client-protocol-schema/src/v2/conversion.rs index 8123c5c61..6e7a2979a 100644 --- a/agent-client-protocol-schema/src/v2/conversion.rs +++ b/agent-client-protocol-schema/src/v2/conversion.rs @@ -304,6 +304,13 @@ where value.map(into_v2_vec_skip_errors) } +fn option_vec_into_v2_default_skip_errors(value: Option>) -> Vec +where + T: IntoV2, +{ + value.map(into_v2_vec_skip_errors).unwrap_or_default() +} + impl IntoV2 for Vec where T: IntoV2, @@ -3233,7 +3240,7 @@ impl IntoV1 for super::NewSessionResponse { Ok(crate::v1::NewSessionResponse { session_id: session_id.into_v1()?, modes: None, - config_options: option_vec_into_v1_skip_errors(config_options), + config_options: Some(into_v1_vec_skip_errors(config_options)), meta: meta.into_v1()?, }) } @@ -3251,7 +3258,7 @@ impl IntoV2 for crate::v1::NewSessionResponse { } = self; Ok(super::NewSessionResponse { session_id: session_id.into_v2()?, - config_options: option_vec_into_v2_skip_errors(config_options), + config_options: option_vec_into_v2_default_skip_errors(config_options), meta: meta.into_v2()?, }) } @@ -3309,7 +3316,7 @@ impl IntoV1 for super::LoadSessionResponse { } = self; Ok(crate::v1::LoadSessionResponse { modes: None, - config_options: option_vec_into_v1_skip_errors(config_options), + config_options: Some(into_v1_vec_skip_errors(config_options)), meta: meta.into_v1()?, }) } @@ -3325,7 +3332,7 @@ impl IntoV2 for crate::v1::LoadSessionResponse { meta, } = self; Ok(super::LoadSessionResponse { - config_options: option_vec_into_v2_skip_errors(config_options), + config_options: option_vec_into_v2_default_skip_errors(config_options), meta: meta.into_v2()?, }) } @@ -3388,7 +3395,7 @@ impl IntoV1 for super::ForkSessionResponse { Ok(crate::v1::ForkSessionResponse { session_id: session_id.into_v1()?, modes: None, - config_options: option_vec_into_v1_skip_errors(config_options), + config_options: Some(into_v1_vec_skip_errors(config_options)), meta: meta.into_v1()?, }) } @@ -3407,7 +3414,7 @@ impl IntoV2 for crate::v1::ForkSessionResponse { } = self; Ok(super::ForkSessionResponse { session_id: session_id.into_v2()?, - config_options: option_vec_into_v2_skip_errors(config_options), + config_options: option_vec_into_v2_default_skip_errors(config_options), meta: meta.into_v2()?, }) } @@ -3465,7 +3472,7 @@ impl IntoV1 for super::ResumeSessionResponse { } = self; Ok(crate::v1::ResumeSessionResponse { modes: None, - config_options: option_vec_into_v1_skip_errors(config_options), + config_options: Some(into_v1_vec_skip_errors(config_options)), meta: meta.into_v1()?, }) } @@ -3481,7 +3488,7 @@ impl IntoV2 for crate::v1::ResumeSessionResponse { meta, } = self; Ok(super::ResumeSessionResponse { - config_options: option_vec_into_v2_skip_errors(config_options), + config_options: option_vec_into_v2_default_skip_errors(config_options), meta: meta.into_v2()?, }) } @@ -9999,12 +10006,38 @@ mod tests { assert!(back_to_v1.modes.is_none()); } + #[test] + fn v1_session_response_missing_config_options_becomes_empty_v2_vec() { + let new_response: v2::NewSessionResponse = + v1_to_v2(v1::NewSessionResponse::new("sess")).unwrap(); + assert!(new_response.config_options.is_empty()); + + let load_response: v2::LoadSessionResponse = + v1_to_v2(v1::LoadSessionResponse::new()).unwrap(); + assert!(load_response.config_options.is_empty()); + + let resume_response: v2::ResumeSessionResponse = + v1_to_v2(v1::ResumeSessionResponse::new()).unwrap(); + assert!(resume_response.config_options.is_empty()); + + #[cfg(feature = "unstable_session_fork")] + { + let fork_response: v2::ForkSessionResponse = + v1_to_v2(v1::ForkSessionResponse::new("fork")).unwrap(); + assert!(fork_response.config_options.is_empty()); + } + } + #[test] fn v2_session_response_converts_to_v1_without_mode_state() { let response: v1::NewSessionResponse = v2_to_v1(v2::NewSessionResponse::new("sess")).unwrap(); assert!(response.modes.is_none()); + assert!(matches!( + response.config_options, + Some(config_options) if config_options.is_empty() + )); } #[test] diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 7437aabe7..073c7fd02 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -1093,8 +1093,8 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) -SessionConfigOption[] | null} > - Initial session configuration options if supported by the Agent. +SessionConfigOption[]} > + Initial session configuration options. SessionId} required> Unique identifier for the newly created forked session. @@ -1228,8 +1228,8 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) -SessionConfigOption[] | null} > - Initial session configuration options if supported by the Agent. +SessionConfigOption[]} > + Initial session configuration options. @@ -1300,8 +1300,8 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) -SessionConfigOption[] | null} > - Initial session configuration options if supported by the Agent. +SessionConfigOption[]} > + Initial session configuration options. SessionId} required> Unique identifier for the created session. @@ -1454,8 +1454,8 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/draft/extensibility) -SessionConfigOption[] | null} > - Initial session configuration options if supported by the Agent. +SessionConfigOption[]} > + Initial session configuration options. diff --git a/docs/protocol/v2/schema.mdx b/docs/protocol/v2/schema.mdx index e845fdc6a..36f8f2e16 100644 --- a/docs/protocol/v2/schema.mdx +++ b/docs/protocol/v2/schema.mdx @@ -470,8 +470,8 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility) -SessionConfigOption[] | null} > - Initial session configuration options if supported by the Agent. +SessionConfigOption[]} > + Initial session configuration options. @@ -542,8 +542,8 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility) -SessionConfigOption[] | null} > - Initial session configuration options if supported by the Agent. +SessionConfigOption[]} > + Initial session configuration options. SessionId} required> Unique identifier for the created session. @@ -696,8 +696,8 @@ these keys. See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/v2/extensibility) -SessionConfigOption[] | null} > - Initial session configuration options if supported by the Agent. +SessionConfigOption[]} > + Initial session configuration options. diff --git a/schema/v2/schema.json b/schema/v2/schema.json index 1d2cab661..e5273a4a1 100644 --- a/schema/v2/schema.json +++ b/schema/v2/schema.json @@ -2195,8 +2195,8 @@ ] }, "configOptions": { - "description": "Initial session configuration options if supported by the Agent.", - "type": ["array", "null"], + "description": "Initial session configuration options.", + "type": "array", "items": { "$ref": "#/$defs/SessionConfigOption" }, @@ -2459,8 +2459,8 @@ "type": "object", "properties": { "configOptions": { - "description": "Initial session configuration options if supported by the Agent.", - "type": ["array", "null"], + "description": "Initial session configuration options.", + "type": "array", "items": { "$ref": "#/$defs/SessionConfigOption" }, @@ -2569,8 +2569,8 @@ "type": "object", "properties": { "configOptions": { - "description": "Initial session configuration options if supported by the Agent.", - "type": ["array", "null"], + "description": "Initial session configuration options.", + "type": "array", "items": { "$ref": "#/$defs/SessionConfigOption" }, diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index 3a8f7aea1..b5df9f595 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -4058,8 +4058,8 @@ ] }, "configOptions": { - "description": "Initial session configuration options if supported by the Agent.", - "type": ["array", "null"], + "description": "Initial session configuration options.", + "type": "array", "items": { "$ref": "#/$defs/SessionConfigOption" }, @@ -4359,8 +4359,8 @@ "type": "object", "properties": { "configOptions": { - "description": "Initial session configuration options if supported by the Agent.", - "type": ["array", "null"], + "description": "Initial session configuration options.", + "type": "array", "items": { "$ref": "#/$defs/SessionConfigOption" }, @@ -4477,8 +4477,8 @@ ] }, "configOptions": { - "description": "Initial session configuration options if supported by the Agent.", - "type": ["array", "null"], + "description": "Initial session configuration options.", + "type": "array", "items": { "$ref": "#/$defs/SessionConfigOption" }, @@ -4501,8 +4501,8 @@ "type": "object", "properties": { "configOptions": { - "description": "Initial session configuration options if supported by the Agent.", - "type": ["array", "null"], + "description": "Initial session configuration options.", + "type": "array", "items": { "$ref": "#/$defs/SessionConfigOption" }, From 0b609f228af4cf822906f25073bd27e3f0091b44 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 30 Jun 2026 10:27:07 +0200 Subject: [PATCH 2/2] fix(unstable-v2): make load session MCP servers optional --- agent-client-protocol-schema/src/v2/agent.rs | 28 ++++++++++++++++++++ docs/protocol/v2/draft/schema.mdx | 2 +- docs/protocol/v2/draft/session-setup.mdx | 3 +-- docs/protocol/v2/schema.mdx | 2 +- docs/protocol/v2/session-setup.mdx | 3 +-- schema/v2/schema.json | 2 +- schema/v2/schema.unstable.json | 2 +- 7 files changed, 34 insertions(+), 8 deletions(-) diff --git a/agent-client-protocol-schema/src/v2/agent.rs b/agent-client-protocol-schema/src/v2/agent.rs index b9d27e79a..06b331cf4 100644 --- a/agent-client-protocol-schema/src/v2/agent.rs +++ b/agent-client-protocol-schema/src/v2/agent.rs @@ -1245,6 +1245,7 @@ pub struct LoadSessionRequest { /// List of MCP servers to connect to for this session. #[serde_as(deserialize_as = "DefaultOnError>")] #[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mcp_servers: Vec, /// The _meta property is reserved by ACP to allow clients and agents to attach additional /// metadata to their interactions. Implementations MUST NOT make assumptions about values at @@ -6118,6 +6119,33 @@ mod test_serialization { ], }) ); + assert_eq!( + serde_json::to_value(LoadSessionRequest::new("sess_abc123", "/home/user/project")) + .unwrap(), + json!({ + "sessionId": "sess_abc123", + "cwd": "/home/user/project", + }) + ); + assert_eq!( + serde_json::from_value::(json!({ + "sessionId": "sess_abc123", + "cwd": "/home/user/project" + })) + .unwrap() + .mcp_servers, + Vec::::new() + ); + assert_eq!( + serde_json::from_value::(json!({ + "sessionId": "sess_abc123", + "cwd": "/home/user/project", + "mcpServers": null + })) + .unwrap() + .mcp_servers, + Vec::::new() + ); assert_eq!( serde_json::to_value(SessionInfo::new("sess_abc123", "/home/user/project")).unwrap(), json!({ diff --git a/docs/protocol/v2/draft/schema.mdx b/docs/protocol/v2/draft/schema.mdx index 073c7fd02..af81f28e9 100644 --- a/docs/protocol/v2/draft/schema.mdx +++ b/docs/protocol/v2/draft/schema.mdx @@ -1205,7 +1205,7 @@ the request `cwd` matches the session's `cwd`. The working directory for this session. -McpServer[]} required> +McpServer[]} > List of MCP servers to connect to for this session. SessionId} required> diff --git a/docs/protocol/v2/draft/session-setup.mdx b/docs/protocol/v2/draft/session-setup.mdx index 01143e9cf..6aa428c81 100644 --- a/docs/protocol/v2/draft/session-setup.mdx +++ b/docs/protocol/v2/draft/session-setup.mdx @@ -364,8 +364,7 @@ requests include `session/new`, `session/load`, and `session/resume`. "additionalDirectories": [ "/home/user/shared-lib", "/home/user/product-docs" - ], - "mcpServers": [] + ] } } ``` diff --git a/docs/protocol/v2/schema.mdx b/docs/protocol/v2/schema.mdx index 36f8f2e16..3e2d2be7f 100644 --- a/docs/protocol/v2/schema.mdx +++ b/docs/protocol/v2/schema.mdx @@ -447,7 +447,7 @@ the request `cwd` matches the session's `cwd`. The working directory for this session. -McpServer[]} required> +McpServer[]} > List of MCP servers to connect to for this session. SessionId} required> diff --git a/docs/protocol/v2/session-setup.mdx b/docs/protocol/v2/session-setup.mdx index f3444aee8..e6f951069 100644 --- a/docs/protocol/v2/session-setup.mdx +++ b/docs/protocol/v2/session-setup.mdx @@ -340,8 +340,7 @@ requests include `session/new`, `session/load`, and `session/resume`. "additionalDirectories": [ "/home/user/shared-lib", "/home/user/product-docs" - ], - "mcpServers": [] + ] } } ``` diff --git a/schema/v2/schema.json b/schema/v2/schema.json index e5273a4a1..810cf1284 100644 --- a/schema/v2/schema.json +++ b/schema/v2/schema.json @@ -4456,7 +4456,7 @@ "additionalProperties": true } }, - "required": ["sessionId", "cwd", "mcpServers"], + "required": ["sessionId", "cwd"], "x-side": "agent", "x-method": "session/load" }, diff --git a/schema/v2/schema.unstable.json b/schema/v2/schema.unstable.json index b5df9f595..e4d308dcb 100644 --- a/schema/v2/schema.unstable.json +++ b/schema/v2/schema.unstable.json @@ -7473,7 +7473,7 @@ "additionalProperties": true } }, - "required": ["sessionId", "cwd", "mcpServers"], + "required": ["sessionId", "cwd"], "x-side": "agent", "x-method": "session/load" },