From 92b83e226df59dc5ec43a49259d7716821e20c85 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 6 Aug 2026 00:16:10 +0000 Subject: [PATCH] Track multi-agent usage hints in world state (#37189) ## Why Resumed sessions need the current multi-agent usage instructions when configuration changes or the saved history predates usage-hint tracking. ## What changed - Store multi-agent usage hints in world state and refresh changed or previously untracked hints before re-emitting the active multi-agent mode. - Present `wait_agent` polling guidance in the overridable developer instructions only when the tool is enabled, instead of embedding it in the tool schema. ## Testing - Cover cold resumes, changes to `wait_agent` availability, custom usage hints, message ordering, and tool-schema behavior. GitOrigin-RevId: a9c6798d5dc2dedc8dadc49e0a80ff50a757ab83 --- codex-rs/core/src/config/config_tests.rs | 5 +- codex-rs/core/src/config/mod.rs | 33 +- codex-rs/core/src/context/mod.rs | 2 + .../src/context/multi_agent_usage_hint.rs | 37 ++ codex-rs/core/src/context/world_state/mod.rs | 9 +- .../context/world_state/multi_agent_mode.rs | 13 +- .../world_state/multi_agent_mode_tests.rs | 20 + .../world_state/multi_agent_usage_hint.rs | 45 ++ codex-rs/core/src/session/mod.rs | 16 +- codex-rs/core/src/session/world_state.rs | 13 +- .../src/tools/handlers/multi_agents_spec.rs | 2 +- .../tools/handlers/multi_agents_spec_tests.rs | 4 +- .../tests/suite/spawn_agent_description.rs | 387 ++++++++++++++++++ 13 files changed, 561 insertions(+), 25 deletions(-) create mode 100644 codex-rs/core/src/context/multi_agent_usage_hint.rs create mode 100644 codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dcdb1d1a90..e3e91a670a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -11029,8 +11029,9 @@ max_concurrent_threads_per_session = 17 let config = resolve_multi_agent_v2_config(&config_toml); let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you."; - let expected_suffix = - format!("{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}"); + let expected_suffix = format!( + "{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT}\n\n{concurrency_guidance}" + ); assert!( [ config.root_agent_usage_hint_text, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 868713d4d1..fc3b7f54de 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -255,6 +255,8 @@ You may also see them addressed as to=/root/..., which indicates your identity i "#; const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string."; const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; +const DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT: &str = + "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling."; const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. All agents share the same directory. In detail: @@ -262,9 +264,17 @@ All agents share the same directory. In detail: - All agents use the same current working directory. - As a result, edits made by one agent are immediately visible to all other agents. "#; -fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String { +fn default_multi_agent_v2_usage_hint_text( + usage_hint_text: &str, + max_concurrency: usize, + wait_agent_usage_hint_text: Option<&str>, +) -> String { + let wait_agent_usage_hint_text = match wait_agent_usage_hint_text { + Some(wait_agent_usage_hint_text) => format!("{wait_agent_usage_hint_text}\n\n"), + None => String::new(), + }; format!( - "{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you." + "{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{wait_agent_usage_hint_text}There are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you." ) } @@ -1286,10 +1296,12 @@ impl MultiAgentV2Config { root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text( DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, + Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT), )), subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text( DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, + Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT), )), subagent_developer_instructions: None, multi_agent_mode_hint_text: None, @@ -2726,8 +2738,21 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config let wait_agent_enabled = base .and_then(|config| config.wait_agent_enabled) .unwrap_or(default.wait_agent_enabled); - let mut default_root_agent_usage_hint_text = default.root_agent_usage_hint_text; - let mut default_subagent_usage_hint_text = default.subagent_usage_hint_text; + let default_wait_agent_usage_hint_text = if wait_agent_enabled { + Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT) + } else { + None + }; + let mut default_root_agent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text( + DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, + max_concurrent_threads_per_session, + default_wait_agent_usage_hint_text, + )); + let mut default_subagent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text( + DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, + max_concurrent_threads_per_session, + default_wait_agent_usage_hint_text, + )); if expose_spawn_agent_model_overrides { default_root_agent_usage_hint_text = Some(append_usage_hint_text( default_root_agent_usage_hint_text.as_deref(), diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 0398d21a1d..597a1268de 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -18,6 +18,7 @@ mod legacy_model_mismatch_warning; mod legacy_unified_exec_process_limit_warning; mod model_switch_instructions; mod multi_agent_mode_instructions; +mod multi_agent_usage_hint; mod network_rule_saved; mod permissions_instructions; mod personality_spec_instructions; @@ -62,6 +63,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning; pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning; pub(crate) use model_switch_instructions::ModelSwitchInstructions; +pub(crate) use multi_agent_usage_hint::MultiAgentUsageHint; pub(crate) use network_rule_saved::NetworkRuleSaved; pub use permissions_instructions::ApprovalPromptContext; pub use permissions_instructions::PermissionsInstructions; diff --git a/codex-rs/core/src/context/multi_agent_usage_hint.rs b/codex-rs/core/src/context/multi_agent_usage_hint.rs new file mode 100644 index 0000000000..e205d1a217 --- /dev/null +++ b/codex-rs/core/src/context/multi_agent_usage_hint.rs @@ -0,0 +1,37 @@ +use super::ContextualUserFragment; + +/// Configured multi-agent instructions emitted as a standalone developer message. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MultiAgentUsageHint { + text: String, +} + +impl MultiAgentUsageHint { + pub(crate) fn new(text: &str) -> Self { + Self { + text: text.to_string(), + } + } +} + +impl ContextualUserFragment for MultiAgentUsageHint { + fn role(&self) -> &'static str { + "developer" + } + + fn requires_separate_message(&self) -> bool { + true + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + self.text.clone() + } +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 28cb48c882..003e24069e 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -7,6 +7,7 @@ mod environment; mod environments_instructions; mod model; mod multi_agent_mode; +mod multi_agent_usage_hint; mod permissions; mod personality; mod plugins_instructions; @@ -40,6 +41,7 @@ pub(crate) use environment::EnvironmentsState; pub(crate) use environments_instructions::EnvironmentsInstructionsState; pub(crate) use model::ModelInstructionsState; pub(crate) use multi_agent_mode::MultiAgentModeState; +pub(crate) use multi_agent_usage_hint::MultiAgentUsageHintState; pub(crate) use permissions::PermissionsState; pub(crate) use personality::PersonalityState; pub(crate) use plugins_instructions::PluginsInstructionsState; @@ -89,7 +91,7 @@ impl ErasedWorldStateSection for S { } fn matches_legacy_fragment(&self, role: &str, text: &str) -> bool { - S::matches_legacy_fragment(role, text) + WorldStateSection::matches_current_legacy_fragment(self, role, text) } fn has_retained_fragment_matcher(&self) -> bool { @@ -219,6 +221,11 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static { false } + /// Recognizes legacy fragments whose identity depends on this section's current value. + fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool { + Self::matches_legacy_fragment(role, text) + } + /// Whether retained history must still contain this section's rendered fragment. fn has_retained_fragment_matcher() -> bool { false diff --git a/codex-rs/core/src/context/world_state/multi_agent_mode.rs b/codex-rs/core/src/context/world_state/multi_agent_mode.rs index ea94431a20..bb63e02526 100644 --- a/codex-rs/core/src/context/world_state/multi_agent_mode.rs +++ b/codex-rs/core/src/context/world_state/multi_agent_mode.rs @@ -1,5 +1,7 @@ use super::PreviousSectionState; +use super::WorldStateHash; use super::WorldStateSection; +use super::multi_agent_usage_hint::MultiAgentUsageHintState; use crate::context::ContextualUserFragment; use crate::context::multi_agent_mode_instructions::MultiAgentModeInstructions; use codex_protocol::config_types::MultiAgentMode; @@ -14,6 +16,8 @@ const MULTI_AGENT_MODE_MAX_TOKENS: usize = 400; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub(crate) struct MultiAgentModeState { mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + usage_hint_hash: Option, } impl MultiAgentModeState { @@ -26,8 +30,14 @@ impl MultiAgentModeState { )), mode @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive) => mode, }), + usage_hint_hash: None, } } + + pub(crate) fn with_usage_hint(mut self, usage_hint: &MultiAgentUsageHintState) -> Self { + self.usage_hint_hash = Some(usage_hint.snapshot()); + self + } } impl WorldStateSection for MultiAgentModeState { @@ -56,7 +66,8 @@ impl WorldStateSection for MultiAgentModeState { ) -> Option> { let mode = match (&self.mode, previous) { (Some(mode), PreviousSectionState::Known(previous)) - if previous.mode.as_ref() == Some(mode) => + if previous.mode.as_ref() == Some(mode) + && previous.usage_hint_hash == self.usage_hint_hash => { return None; } diff --git a/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs b/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs index ab8567232e..75577d120a 100644 --- a/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs +++ b/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs @@ -65,6 +65,26 @@ fn persisted_mode_is_restored_only_when_missing_from_history() { ); } +/// Active mode instructions must follow a newly migrated multi-agent usage hint. +#[test] +fn unchanged_mode_is_reemitted_after_usage_hint_migration() { + let previous = state(Some(MultiAgentMode::Proactive)); + let current = MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint( + &MultiAgentUsageHintState::new("Current usage instructions."), + ); + + let instructions = current + .render_diff(PreviousSectionState::Known(&previous)) + .expect("unchanged mode should follow migrated usage instructions"); + + assert_eq!( + instructions.render(), + MultiAgentModeInstructions::from_mode(MultiAgentMode::Proactive) + .expect("proactive mode should render") + .render() + ); +} + #[test] fn custom_mode_is_bounded_before_snapshot_and_rendering() { let state = state(Some(MultiAgentMode::Custom("custom mode ".repeat(1_000)))); diff --git a/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs b/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs new file mode 100644 index 0000000000..4142831916 --- /dev/null +++ b/codex-rs/core/src/context/world_state/multi_agent_usage_hint.rs @@ -0,0 +1,45 @@ +use super::PreviousSectionState; +use super::WorldStateHash; +use super::WorldStateSection; +use crate::context::ContextualUserFragment; +use crate::context::MultiAgentUsageHint; + +/// Configured multi-agent instructions currently visible to the model. +#[derive(Clone, Debug)] +pub(crate) struct MultiAgentUsageHintState { + instructions: MultiAgentUsageHint, +} + +impl MultiAgentUsageHintState { + pub(crate) fn new(text: &str) -> Self { + Self { + instructions: MultiAgentUsageHint::new(text), + } + } +} + +impl WorldStateSection for MultiAgentUsageHintState { + const ID: &'static str = "multi_agent_usage_hint"; + type Snapshot = WorldStateHash; + + fn snapshot(&self) -> Self::Snapshot { + WorldStateHash::from_fragment(&self.instructions) + } + + fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool { + role == self.instructions.role() && text == self.instructions.render() + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + match previous { + PreviousSectionState::Known(previous) if previous == &self.snapshot() => None, + PreviousSectionState::Unknown => None, + PreviousSectionState::Known(_) | PreviousSectionState::Absent => { + Some(Box::new(self.instructions.clone())) + } + } + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index a7e511f28f..0117b596ff 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3549,15 +3549,17 @@ impl Session { "developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => { initial_multi_agent_mode = Some(fragment); } + "developer" + if fragment.requires_separate_message() && fragment.markers().0.is_empty() => + { + separate_developer_sections.push(fragment.render()); + } "developer" => developer_sections.push(fragment.render()), "user" => contextual_user_sections.push(fragment.render()), _ => {} } } - let multi_agent_v2_usage_hint_text = - multi_agents::usage_hint_text(turn_context, &session_source); - let mut items = Vec::with_capacity(4); if let Some(developer_message) = crate::context_manager::updates::build_developer_update_item(developer_sections) @@ -3571,14 +3573,6 @@ impl Session { items.push(developer_message); } } - if let Some(usage_hint_text) = multi_agent_v2_usage_hint_text - && let Some(usage_hint_message) = - crate::context_manager::updates::build_developer_update_item(vec![ - usage_hint_text.to_string(), - ]) - { - items.push(usage_hint_message); - } if let Some(initial_multi_agent_mode) = initial_multi_agent_mode { items.push(initial_multi_agent_mode.into_boxed_response_item()); } diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 083f582c99..a61a545d0e 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -13,6 +13,7 @@ use crate::context::world_state::EnvironmentsInstructionsState; use crate::context::world_state::EnvironmentsState; use crate::context::world_state::ModelInstructionsState; use crate::context::world_state::MultiAgentModeState; +use crate::context::world_state::MultiAgentUsageHintState; use crate::context::world_state::PermissionsState; use crate::context::world_state::PersonalityState; use crate::context::world_state::PluginsInstructionsState; @@ -252,9 +253,17 @@ impl Session { world_state.add_extension_section(section); } } - world_state.add_section(MultiAgentModeState::new( + let mut multi_agent_mode = MultiAgentModeState::new( super::multi_agents::effective_multi_agent_mode(turn_context), - )); + ); + if let Some(usage_hint_text) = + super::multi_agents::usage_hint_text(turn_context, &turn_context.session_source) + { + let usage_hint = MultiAgentUsageHintState::new(usage_hint_text); + multi_agent_mode = multi_agent_mode.with_usage_hint(&usage_hint); + world_state.add_section(usage_hint); + } + world_state.add_section(multi_agent_mode); Ok(world_state) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 757e111cc2..2eb82e175b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -877,7 +877,7 @@ fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema let properties = BTreeMap::from([( "timeout_ms".to_string(), JsonSchema::number(Some(format!( - "Timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.", + "Timeout in milliseconds. Defaults to {}, min {}, max {}.", options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms, ))), )]); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 43cc92572d..063f637c3d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -421,9 +421,7 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() { properties .get("timeout_ms") .and_then(|schema| schema.description.as_deref()), - Some( - "Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000. Prefer longer waits (minutes) to avoid busy polling." - ) + Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.") ); assert_eq!(parameters.required.as_ref(), None); assert_eq!( diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 0143427f6e..bebab31184 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -17,10 +17,14 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -308,6 +312,378 @@ async fn configured_agent_roles_control_spawn_agent_type( Ok(()) } +/// Wait guidance belongs to overridable developer instructions, never the tool schema. +#[test_case(None, true; "default developer instructions include wait guidance")] +#[test_case(Some("Custom root instructions."), false; "custom developer instructions replace wait guidance")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_v2_wait_guidance_uses_overridable_developer_instructions( + root_agent_usage_hint_text: Option<&str>, + expected_wait_guidance: bool, +) -> Result<()> { + let root_agent_usage_hint_text = root_agent_usage_hint_text.map(str::to_string); + let server = start_mock_server().await; + let response = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let test = test_codex() + .with_config(move |config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + if let Some(root_agent_usage_hint_text) = root_agent_usage_hint_text { + config.multi_agent_v2.root_agent_usage_hint_text = Some(root_agent_usage_hint_text); + } + }) + .build_with_auto_env(&server) + .await?; + + test.submit_turn("hello").await?; + + let request = response.single_request(); + let developer_messages = request.message_input_texts("developer"); + let has_wait_guidance = developer_messages.iter().any(|message| { + message.contains( + "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.", + ) + }); + assert_eq!(has_wait_guidance, expected_wait_guidance); + + let body = request.body_json(); + let wait_agent_tool = namespace_child_tool(&body, MULTI_AGENT_V2_NAMESPACE, "wait_agent") + .expect("wait_agent should be exposed"); + assert_eq!( + wait_agent_tool + .pointer("/parameters/properties/timeout_ms/description") + .and_then(Value::as_str), + Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.") + ); + + Ok(()) +} + +/// Resumed legacy threads receive current usage hints once, before their active mode. +/// Configured hint overrides and disabled wait tools must retain their existing semantics. +#[test_case(true, None, true; "legacy resume restores default wait guidance")] +#[test_case(true, Some("Custom root instructions."), false; "legacy resume preserves custom usage hints")] +#[test_case(true, Some("Legacy root instructions."), false; "legacy resume preserves unchanged custom usage hints")] +#[test_case(false, None, false; "legacy resume omits disabled wait guidance")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_v2_cold_resume_refreshes_legacy_usage_hints_once( + wait_agent_enabled: bool, + resumed_root_agent_usage_hint_text: Option<&str>, + expected_wait_guidance: bool, +) -> Result<()> { + let resumed_root_agent_usage_hint_text = resumed_root_agent_usage_hint_text.map(str::to_string); + let legacy_root_agent_usage_hint_text = "Legacy root instructions."; + let wait_guidance = + "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling."; + let config_toml = format!( + "[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {wait_agent_enabled}\n" + ); + let server = start_mock_server().await; + let initial_response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-initial"), + ev_completed("resp-initial"), + ]), + ) + .await; + let initial = test_codex() + .with_pre_build_hook(move |home| { + std::fs::write(home.join("config.toml"), &config_toml) + .expect("write multi-agent configuration"); + }) + .with_config(move |config| { + config.multi_agent_v2.root_agent_usage_hint_text = + Some(legacy_root_agent_usage_hint_text.to_string()); + }) + .build_with_auto_env(&server) + .await?; + + initial.submit_turn("before upgrading").await?; + + let initial_request = initial_response.single_request(); + assert!( + initial_request + .message_input_text_groups("developer") + .iter() + .any(|group| { group.len() == 1 && group[0] == legacy_root_agent_usage_hint_text }), + "legacy usage hint should be its own developer message" + ); + assert!( + !initial_request + .message_input_texts("developer") + .iter() + .any(|message| message.contains(wait_guidance)), + "legacy rollout should not already contain wait guidance" + ); + + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("initial session should have a rollout path"); + initial.codex.shutdown_and_wait().await?; + + let mut removed_recorded_usage_hint = false; + let mut removed_usage_hint_presence = false; + let legacy_rollout = std::fs::read_to_string(&rollout_path)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()? + .into_iter() + .map(|mut line| { + if let RolloutItem::WorldState(world_state) = &mut line.item + && let Some(state) = world_state.state.as_object_mut() + { + removed_recorded_usage_hint |= state.remove("multi_agent_usage_hint").is_some(); + if let Some(mode) = state + .get_mut("multi_agent_mode") + .and_then(Value::as_object_mut) + { + removed_usage_hint_presence |= mode.remove("usage_hint_hash").is_some(); + } + } + serde_json::to_string(&line) + }) + .collect::, _>>()? + .join("\n"); + assert!( + removed_recorded_usage_hint, + "initial rollout should contain a usage-hint world-state section" + ); + assert!( + removed_usage_hint_presence, + "initial rollout should record usage-hint presence on its active mode" + ); + std::fs::write(&rollout_path, format!("{legacy_rollout}\n"))?; + + let resumed_responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-resumed-first"), + ev_completed("resp-resumed-first"), + ]), + sse(vec![ + ev_response_created("resp-resumed-second"), + ev_completed("resp-resumed-second"), + ]), + ], + ) + .await; + let mut resumed_builder = test_codex().with_config(move |config| { + if let Some(root_agent_usage_hint_text) = resumed_root_agent_usage_hint_text { + config.multi_agent_v2.root_agent_usage_hint_text = Some(root_agent_usage_hint_text); + } + }); + let resumed = resumed_builder.resume(&server, home, rollout_path).await?; + let current_usage_hint = resumed + .config + .multi_agent_v2 + .root_agent_usage_hint_text + .clone() + .expect("current root usage hint should be configured"); + + resumed.submit_turn("first turn after upgrading").await?; + resumed.submit_turn("second turn after upgrading").await?; + + let requests = resumed_responses.requests(); + assert_eq!(requests.len(), 2); + for request in &requests { + let developer_messages = request.message_input_text_groups("developer"); + let current_usage_hint_positions = developer_messages + .iter() + .enumerate() + .filter_map(|(index, group)| { + (group.len() == 1 && group[0] == current_usage_hint).then_some(index) + }) + .collect::>(); + assert_eq!( + current_usage_hint_positions.len(), + 1, + "current usage hint should appear exactly once as a standalone developer message: \ + {developer_messages:?}" + ); + let active_mode_position = developer_messages + .iter() + .rposition(|group| { + group + .iter() + .any(|message| message.contains(MULTI_AGENT_MODE_OPEN_TAG)) + }) + .expect("resumed context should include an active multi-agent mode"); + assert!( + current_usage_hint_positions[0] < active_mode_position, + "active mode must follow the current usage hint: {developer_messages:?}" + ); + let wait_guidance_count = developer_messages + .iter() + .flatten() + .filter(|message| message.contains(wait_guidance)) + .count(); + assert_eq!( + wait_guidance_count, + usize::from(expected_wait_guidance), + "wait guidance must follow configured tool availability and usage-hint overrides" + ); + + let body = request.body_json(); + let wait_agent_tool = namespace_child_tool(&body, MULTI_AGENT_V2_NAMESPACE, "wait_agent"); + assert_eq!(wait_agent_tool.is_some(), wait_agent_enabled); + if let Some(wait_agent_tool) = wait_agent_tool { + assert_eq!( + wait_agent_tool + .pointer("/parameters/properties/timeout_ms/description") + .and_then(Value::as_str), + Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.") + ); + } + } + + Ok(()) +} + +/// Resuming after wait-tool availability changes must refresh stale usage guidance once. +/// The active multi-agent mode must remain after the updated instructions. +#[test_case(true, false; "disabled wait agent invalidates prior guidance")] +#[test_case(false, true; "enabled wait agent adds current guidance")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_v2_resume_refreshes_changed_wait_guidance( + initial_wait_agent_enabled: bool, + resumed_wait_agent_enabled: bool, +) -> Result<()> { + let wait_guidance = + "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling."; + let initial_config_toml = format!( + "[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {initial_wait_agent_enabled}\n" + ); + let server = start_mock_server().await; + let initial_response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-initial"), + ev_completed("resp-initial"), + ]), + ) + .await; + let initial = test_codex() + .with_pre_build_hook(move |home| { + std::fs::write(home.join("config.toml"), &initial_config_toml) + .expect("write initial multi-agent configuration"); + }) + .build_with_auto_env(&server) + .await?; + + initial + .submit_turn("before changing wait-agent availability") + .await?; + + let initial_request = initial_response.single_request(); + assert_eq!( + namespace_child_tool( + &initial_request.body_json(), + MULTI_AGENT_V2_NAMESPACE, + "wait_agent" + ) + .is_some(), + initial_wait_agent_enabled + ); + + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("initial session should have a rollout path"); + initial.codex.shutdown_and_wait().await?; + std::fs::write( + home.path().join("config.toml"), + format!( + "[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {resumed_wait_agent_enabled}\n" + ), + )?; + + let resumed_responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-resumed-first"), + ev_completed("resp-resumed-first"), + ]), + sse(vec![ + ev_response_created("resp-resumed-second"), + ev_completed("resp-resumed-second"), + ]), + ], + ) + .await; + let resumed = test_codex().resume(&server, home, rollout_path).await?; + let current_usage_hint = resumed + .config + .multi_agent_v2 + .root_agent_usage_hint_text + .clone() + .expect("current root usage hint should be configured"); + + resumed + .submit_turn("first turn with updated wait-agent availability") + .await?; + resumed + .submit_turn("second turn with updated wait-agent availability") + .await?; + + let requests = resumed_responses.requests(); + assert_eq!(requests.len(), 2); + for request in &requests { + let developer_messages = request.message_input_text_groups("developer"); + let current_usage_hint_positions = developer_messages + .iter() + .enumerate() + .filter_map(|(index, group)| { + (group.len() == 1 && group[0] == current_usage_hint).then_some(index) + }) + .collect::>(); + assert_eq!( + current_usage_hint_positions.len(), + 1, + "current usage hint should refresh earlier guidance exactly once: \ + {developer_messages:?}" + ); + let current_usage_hint_position = current_usage_hint_positions[0]; + let current_usage_message = &developer_messages[current_usage_hint_position][0]; + assert_eq!( + current_usage_message.contains(wait_guidance), + resumed_wait_agent_enabled + ); + let active_mode_position = developer_messages + .iter() + .rposition(|group| { + group + .iter() + .any(|message| message.contains(MULTI_AGENT_MODE_OPEN_TAG)) + }) + .expect("resumed context should include an active multi-agent mode"); + assert!( + current_usage_hint_position < active_mode_position, + "active mode must follow updated usage instructions: {developer_messages:?}" + ); + + assert_eq!( + namespace_child_tool(&request.body_json(), MULTI_AGENT_V2_NAMESPACE, "wait_agent") + .is_some(), + resumed_wait_agent_enabled + ); + } + + Ok(()) +} + #[test_case(true, false; "wait agent remains available without clock sleep")] #[test_case(true, true; "wait agent remains available with clock sleep")] #[test_case(false, false; "wait agent can be disabled without clock sleep")] @@ -360,6 +736,17 @@ wait_agent_enabled = {wait_agent_enabled} namespace_child_tool(&body, "clock", "sleep").is_some(), sleep_tool_enabled ); + assert_eq!( + request + .message_input_texts("developer") + .iter() + .any(|message| { + message.contains( + "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.", + ) + }), + wait_agent_enabled + ); Ok(()) }