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
This commit is contained in:
alishobeiri-oai
2026-09-12 22:28:41 +00:00
committed by copyberry
parent 70eb36203d
commit b979d4f1f0
4 changed files with 63 additions and 19 deletions
+6
View File
@@ -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"
},
+6 -5
View File
@@ -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);
}
@@ -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
+8
View File
@@ -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",