From b979d4f1f04538ba5a5fcc434d499c007bfe1b8c Mon Sep 17 00:00:00 2001 From: alishobeiri-oai Date: Sat, 12 Sep 2026 22:16:31 +0000 Subject: [PATCH] Add a feature flag for asynchronous user messages (#45124) ## What changed Add the disabled-by-default `send_message_to_user_async` feature flag so root agents can use the tool without model catalog support. Preserve catalog-based opt-in and keep the tool unavailable to subagents. Register the flag as under development and expose it in the configuration schema. ## Testing Extend integration tests to cover feature and catalog opt-ins, deduplication when both are enabled, subagent exclusion, and the retired `send_async_message` flag. Check that either opt-in allows messages to be emitted without ending the turn. GitOrigin-RevId: 3f53181ab074b0432c795550de73a9b1da7caf15 --- codex-rs/core/config.schema.json | 6 ++ codex-rs/core/src/tools/spec_plan.rs | 11 ++-- .../tests/suite/request_user_input_async.rs | 57 ++++++++++++++----- codex-rs/features/src/lib.rs | 8 +++ 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index fa65106c6f..84f6ce9a55 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -971,6 +971,9 @@ "send_async_message": { "type": "boolean" }, + "send_message_to_user_async": { + "type": "boolean" + }, "shell_snapshot": { "type": "boolean" }, @@ -6586,6 +6589,9 @@ "send_async_message": { "type": "boolean" }, + "send_message_to_user_async": { + "type": "boolean" + }, "shell_snapshot": { "type": "boolean" }, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 01054b1b50..1f308ab5f3 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1190,11 +1190,12 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool } if !turn_context.session_source.is_non_root_agent() - && context - .model_info - .experimental_supported_tools - .iter() - .any(|tool| tool == "send_message_to_user_async") + && (features.enabled(Feature::SendMessageToUserAsync) + || context + .model_info + .experimental_supported_tools + .iter() + .any(|tool| tool == "send_message_to_user_async")) { registry.add_with_exposure(SendMessageToUserAsyncHandler, ToolExposure::DirectModelOnly); } diff --git a/codex-rs/core/tests/suite/request_user_input_async.rs b/codex-rs/core/tests/suite/request_user_input_async.rs index 7e20abf110..dba6dfba1e 100644 --- a/codex-rs/core/tests/suite/request_user_input_async.rs +++ b/codex-rs/core/tests/suite/request_user_input_async.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::StartThreadOptions; use codex_core::TurnInputRequest; +use codex_features::Feature; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageDelivery; use codex_protocol::items::AgentMessageItem; @@ -120,15 +121,21 @@ async fn persistent_async_message_guidance_follows_tool_availability( Ok(()) } -#[test_case(SessionSource::Exec, None, false; "root_without_opt_in")] -#[test_case(SessionSource::Exec, Some("send_user_message_async"), false; "root_with_legacy_question_tool")] -#[test_case(SessionSource::Exec, Some("request_user_input_async"), false; "root_with_question_tool")] -#[test_case(SessionSource::Exec, Some("send_message_to_user_async"), true; "root_with_freeform_tool")] -#[test_case(SessionSource::SubAgent(SubAgentSource::Other("test".to_string())), Some("send_message_to_user_async"), false; "subagent_with_freeform_tool")] +#[test_case(SessionSource::Exec, None, &[], false; "root_without_opt_in")] +#[test_case(SessionSource::Exec, None, &[Feature::SendAsyncMessage], false; "root_with_retired_feature")] +#[test_case(SessionSource::Exec, Some("send_user_message_async"), &[], false; "root_with_legacy_question_tool")] +#[test_case(SessionSource::Exec, Some("request_user_input_async"), &[], false; "root_with_question_tool")] +#[test_case(SessionSource::Exec, Some("send_message_to_user_async"), &[], true; "root_with_catalog_opt_in")] +#[test_case(SessionSource::Exec, None, &[Feature::SendMessageToUserAsync], true; "root_with_feature_opt_in")] +#[test_case(SessionSource::Exec, Some("send_message_to_user_async"), &[Feature::SendMessageToUserAsync], true; "root_with_both_opt_ins")] +#[test_case(SessionSource::SubAgent(SubAgentSource::Other("test".to_string())), Some("send_message_to_user_async"), &[], false; "subagent_with_catalog_opt_in")] +#[test_case(SessionSource::SubAgent(SubAgentSource::Other("test".to_string())), None, &[Feature::SendMessageToUserAsync], false; "subagent_with_feature_opt_in")] +#[test_case(SessionSource::SubAgent(SubAgentSource::Other("test".to_string())), Some("send_message_to_user_async"), &[Feature::SendMessageToUserAsync], false; "subagent_with_both_opt_ins")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn freeform_async_message_requires_root_and_exact_catalog_opt_in( +async fn freeform_async_message_requires_root_and_catalog_or_feature_opt_in( session_source: SessionSource, catalog_tool: Option<&'static str>, + enabled_features: &'static [Feature], expected: bool, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -145,6 +152,14 @@ async fn freeform_async_message_requires_root_and_exact_catalog_opt_in( model.experimental_supported_tools = catalog_tool.map(str::to_string).into_iter().collect(); }) + .with_config(move |config| { + for &feature in enabled_features { + config + .features + .enable(feature) + .expect("enable async messaging test feature"); + } + }) .build_with_auto_env(&server) .await?; let thread = test @@ -168,14 +183,20 @@ async fn freeform_async_message_requires_root_and_exact_catalog_opt_in( assert_eq!( tools .iter() - .any(|tool| tool["name"] == "send_message_to_user_async"), - expected, + .filter(|tool| tool["name"] == "send_message_to_user_async") + .count(), + usize::from(expected), ); Ok(()) } +#[test_case(Some("send_message_to_user_async"), &[]; "catalog_opt_in")] +#[test_case(None, &[Feature::SendMessageToUserAsync]; "feature_opt_in")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn freeform_async_message_emits_an_item_without_ending_the_turn() -> Result<()> { +async fn freeform_async_message_emits_an_item_without_ending_the_turn( + catalog_tool: Option<&'static str>, + enabled_features: &'static [Feature], +) -> Result<()> { skip_if_no_network!(Ok(())); const CALL_ID: &str = "freeform-message"; @@ -213,12 +234,12 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn() -> Resul ) .await; let test = test_codex() - .with_model_info_override("gpt-5.2", |model| { + .with_model_info_override("gpt-5.2", move |model| { model.tool_mode = Some(ToolMode::CodeModeOnly); - model.experimental_supported_tools.extend([ - "send_message_to_user_async".to_string(), - "request_user_input_async".to_string(), - ]); + model.experimental_supported_tools = vec!["request_user_input_async".to_string()]; + model + .experimental_supported_tools + .extend(catalog_tool.map(str::to_string)); model .model_messages .as_mut() @@ -229,6 +250,14 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn() -> Resul }), }); }) + .with_config(move |config| { + for &feature in enabled_features { + config + .features + .enable(feature) + .expect("enable async messaging test feature"); + } + }) .build_with_auto_env(&server) .await?; test.codex diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 8d228421fd..7e11d0b1ea 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -307,6 +307,8 @@ pub enum Feature { DefaultModeRequestUserInput, /// Removed compatibility flag for model-enabled async user messaging. SendAsyncMessage, + /// Allow root agents to send async user messages without model catalog support. + SendMessageToUserAsync, /// Enable automatic review for approval prompts. GuardianApproval, /// Select thread-owned context for both Guardian reviewers. @@ -1557,6 +1559,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::SendMessageToUserAsync, + key: "send_message_to_user_async", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::TerminalVisualizationInstructions, key: "terminal_visualization_instructions",