From 2e77fb6c93ae4e4ca15cf536cb941ae78e6bae86 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 21 Sep 2026 17:04:41 -0700 Subject: [PATCH] Add optional response contract to Rust SDK messages Expose response_optional and its builder on MessageOptions, forwarding explicit true and false while omitting None. Cover high-level and generated session.send serialization and idle-only completion. Regenerate Rust types from pinned CLI 1.0.87-0 schemas with only the authoritative responseOptional property additions from the rebased runtime producer (SendRequest, SendMessageItem, UserMessageData), avoiding unrelated unpublished schema drift. Preserve upstream structured response support. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/generated/api_types.rs | 9 ++- rust/src/generated/session_events.rs | 3 + rust/src/session.rs | 3 + rust/src/types.rs | 12 +++ rust/tests/session_test.rs | 111 ++++++++++++++++++++++++++- 5 files changed, 134 insertions(+), 4 deletions(-) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index bff7ace112..b72651d34d 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -3477,7 +3477,6 @@ impl CatalogAiSkillCandidate { serde::de::value::StringDeserializer::::new(value), ) } - fn deserialize_kind<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -3490,7 +3489,6 @@ impl CatalogAiSkillCandidate { serde::de::value::StringDeserializer::::new(value), ) } - fn deserialize_media_type<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -9962,7 +9960,6 @@ impl McpPlanRequiredValueEnum { serde::de::value::StringDeserializer::::new(value), ) } - fn deserialize_value_type<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -16880,6 +16877,9 @@ pub struct SendMessageItem { /// If set, the request will fail if the named tool is not available when this message is among the user messages at the start of the current exchange #[serde(skip_serializing_if = "Option::is_none")] pub required_tool: Option, + /// If true, this automatic notification may complete without visible assistant output. Defaults to false. Required messages in the same turn still require output; provider and execution errors are unaffected. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_optional: Option, /// Optional provenance tag copied to the resulting user.message event. Must be `user`, `system`, `command-` for command-originated messages, `schedule-` for scheduled prompts, or `agent-` for prompts sent by another agent. #[doc(hidden)] #[serde(skip_serializing_if = "Option::is_none")] @@ -16998,6 +16998,9 @@ pub struct SendRequest { /// Provider-native output format for this turn, including all tool-call iterations. Not inherited by later turns or subagents. Ordinary steering inherits the active format; specifying responseFormat with mode: immediate is an error, even while idle. Returned assistant content remains text; the runtime does not parse or validate it. Unsupported models or schemas produce provider errors. #[serde(skip_serializing_if = "Option::is_none")] pub response_format: Option, + /// If true, this automatic notification may complete without visible assistant output. Defaults to false. Required messages in the same turn still require output; provider and execution errors are unaffected. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_optional: Option, /// Optional provenance tag copied to the resulting user.message event. Must be `user`, `system`, `command-` for command-originated messages, `schedule-` for scheduled prompts, or `agent-` for prompts sent by another agent. #[doc(hidden)] #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/src/generated/session_events.rs b/rust/src/generated/session_events.rs index 6fba95dd7b..5619f8dd1e 100644 --- a/rust/src/generated/session_events.rs +++ b/rust/src/generated/session_events.rs @@ -2598,6 +2598,9 @@ pub struct UserMessageData { /// Parent agent task ID for background telemetry correlated to this user turn #[serde(skip_serializing_if = "Option::is_none")] pub parent_agent_task_id: Option, + /// True when the sender explicitly allows this automatic notification to complete without visible assistant output. Absent means a response is required. Does not relax other messages in the same turn or suppress execution errors. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_optional: Option, /// Responses reasoning settings anchored before this model-facing message, for cache-stable history replay #[serde(skip_serializing_if = "Option::is_none")] pub responses_reasoning: Option, diff --git a/rust/src/session.rs b/rust/src/session.rs index 4c39a084a2..2fedaa560a 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -609,6 +609,9 @@ impl Session { if let Some(source) = opts.source { params["source"] = serde_json::to_value(source)?; } + if let Some(response_optional) = opts.response_optional { + params["responseOptional"] = serde_json::json!(response_optional); + } if let Some(m) = opts.mode { params["mode"] = serde_json::to_value(m)?; } diff --git a/rust/src/types.rs b/rust/src/types.rs index b3dd113948..d59593967d 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -5538,6 +5538,10 @@ pub struct MessageOptions { /// Optional message provenance. When `None`, the field is omitted, /// preserving the runtime's default for user messages. pub source: Option, + /// Whether the runtime may complete without producing an assistant response. + /// Omitted or false preserves the empty-output error. Required messages in a + /// combined turn take precedence over optional messages. + pub response_optional: Option, /// Optional message delivery mode for this turn. /// /// Controls whether the prompt is queued behind in-flight work @@ -5579,6 +5583,7 @@ impl MessageOptions { prompt: prompt.into(), response_schema: None, source: None, + response_optional: None, mode: None, agent_mode: None, attachments: None, @@ -5602,6 +5607,13 @@ impl MessageOptions { self } + /// Allow an empty completion for an informational message when true. + /// False retains the runtime's normal response requirement. + pub fn with_response_optional(mut self, response_optional: bool) -> Self { + self.response_optional = Some(response_optional); + self + } + /// Set the message delivery mode for this turn. /// /// Pass [`DeliveryMode::Immediate`] to interrupt the session and run diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 920eed4561..907978cfd2 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -2080,6 +2080,112 @@ async fn send_injects_session_id() { timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); } +#[test] +fn message_options_response_optional_defaults_and_builder() { + let prompt = "notification".to_string(); + for options in [ + MessageOptions::new(&prompt), + MessageOptions::from(prompt.as_str()), + MessageOptions::from(prompt.clone()), + MessageOptions::from(&prompt), + ] { + assert_eq!(options.response_optional, None); + assert_eq!( + options + .with_response_optional(true) + .with_response_optional(false) + .response_optional, + Some(false) + ); + } +} + +#[tokio::test] +async fn send_response_optional_preserves_false_and_omits_none() { + let (session, mut server) = create_session_pair().await; + let session = Arc::new(session); + for value in [None, Some(false), Some(true)] { + let mut options = MessageOptions::new("notification") + .with_source(MessageSource::System) + .with_mode(DeliveryMode::Enqueue); + let mut expected = serde_json::json!({ + "sessionId": server.session_id, + "prompt": "notification", + "source": "system", + "mode": "enqueue", + }); + if let Some(value) = value { + options = options.with_response_optional(value); + expected["responseOptional"] = serde_json::json!(value); + } + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.send(options).await } + }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.send"); + assert_eq!(request["params"], expected); + server + .respond(&request, serde_json::json!({"messageId": "optional"})) + .await; + assert_eq!( + timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(), + "optional" + ); + } +} + +#[test] +fn rpc_response_optional_serialization() { + for value in [None, Some(false), Some(true)] { + let mut options = SendRequest::default(); + options.prompt = "notification".into(); + options.response_optional = value; + let mut expected = serde_json::json!({"prompt": "notification"}); + if let Some(value) = value { + expected["responseOptional"] = serde_json::json!(value); + } + assert_eq!(serde_json::to_value(options).unwrap(), expected); + } +} + +#[tokio::test] +async fn rpc_send_response_optional_preserves_false_and_omits_none() { + let (session, mut server) = create_session_pair().await; + let session = Arc::new(session); + for value in [None, Some(false), Some(true)] { + let mut options = SendRequest::default(); + options.prompt = "notification".into(); + options.response_optional = value; + let mut expected = serde_json::json!({ + "sessionId": server.session_id, + "prompt": "notification", + }); + if let Some(value) = value { + expected["responseOptional"] = serde_json::json!(value); + } + let handle = tokio::spawn({ + let session = session.clone(); + async move { session.rpc().send(options).await } + }); + let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); + assert_eq!(request["method"], "session.send"); + assert_eq!(request["params"], expected); + server + .respond(&request, serde_json::json!({"messageId": "optional"})) + .await; + assert_eq!( + timeout(TIMEOUT, handle) + .await + .unwrap() + .unwrap() + .unwrap() + .message_id, + "optional" + ); + } +} + #[test] fn message_options_source_is_opt_in() { let prompt = "hello".to_string(); @@ -4370,7 +4476,7 @@ async fn send_and_wait_agent_source_preserves_mode_and_optional_reply() { } #[tokio::test] -async fn send_and_wait_system_source_returns_none_on_idle_without_assistant() { +async fn send_and_wait_response_optional_returns_none_on_idle_without_assistant() { let (session, mut server) = create_session_pair().await; let session = Arc::new(session); @@ -4381,6 +4487,7 @@ async fn send_and_wait_system_source_returns_none_on_idle_without_assistant() { .send_and_wait( MessageOptions::new("Context updated") .with_source(MessageSource::System) + .with_response_optional(true) .with_wait_timeout(TIMEOUT), ) .await @@ -4389,6 +4496,7 @@ async fn send_and_wait_system_source_returns_none_on_idle_without_assistant() { let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); assert_eq!(request["method"], "session.send"); assert_eq!(request["params"]["source"], "system"); + assert_eq!(request["params"]["responseOptional"], true); server .respond(&request, serde_json::json!({"messageId": "system-message"})) .await; @@ -4407,6 +4515,7 @@ async fn send_and_wait_system_source_returns_none_on_idle_without_assistant() { let handle = tokio::spawn(async move { session.send("human follow-up").await }); let request = timeout(TIMEOUT, server.read_request()).await.unwrap(); assert!(request["params"].get("source").is_none()); + assert!(request["params"].get("responseOptional").is_none()); server .respond(&request, serde_json::json!({"messageId": "human-message"})) .await;