diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 22d56df390..8658c5ae92 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -165,7 +165,10 @@ pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option MultiAgentMode::Custom(hint_text.to_string()), None => match turn_context.effective_reasoning_effort() { - Some(ReasoningEffort::Ultra) => MultiAgentMode::Proactive, + Some(ReasoningEffort::Ultra) => catalog_mode + .and_then(|messages| messages.proactive.clone()) + .map(MultiAgentMode::Custom) + .unwrap_or(MultiAgentMode::Proactive), _ => catalog_mode .and_then(|messages| messages.explicit.clone()) .map(MultiAgentMode::Custom) diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs index 4e6f722f32..36356527a8 100644 --- a/codex-rs/core/tests/suite/multi_agent_mode.rs +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -31,7 +31,9 @@ const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active."; const CUSTOM_MODE_HINT_TEXT: &str = "Use the configured delegation policy."; const CATALOG_MODE_HINT_TEXT: &str = "Use the model catalog delegation policy."; const CATALOG_EXPLICIT_TEXT: &str = "Use explicit delegation from the model catalog."; +const CATALOG_PROACTIVE_TEXT: &str = "Use proactive delegation from the model catalog."; const SECOND_MODEL_EXPLICIT_TEXT: &str = "Second model explicit mode."; +const SECOND_MODEL_PROACTIVE_TEXT: &str = "Second model proactive mode."; const FIRST_MODEL_ROOT_ROLE_TEXT: &str = "First model root role."; const SECOND_MODEL_ROOT_ROLE_TEXT: &str = "Second model root role."; const ROOT_USAGE_HINT_TEXT: &str = "Root usage hint."; @@ -45,6 +47,7 @@ enum ModeHintSource { fn set_multi_agent_mode( model_info: &mut ModelInfo, explicit: &str, + proactive: Option<&str>, hint_text: Option<&str>, root_role: Option<&str>, ) { @@ -56,6 +59,7 @@ fn set_multi_agent_mode( }), mode: Some(MultiAgentModeMessages { explicit: Some(explicit.to_string()), + proactive: proactive.map(str::to_string), hint_text: hint_text.map(str::to_string), }), }); @@ -185,6 +189,7 @@ async fn mode_hints_override_reasoning_effort(source: ModeHintSource) -> Result< set_multi_agent_mode( model_info, CATALOG_EXPLICIT_TEXT, + Some(CATALOG_PROACTIVE_TEXT), Some(CATALOG_MODE_HINT_TEXT), /*root_role*/ None, ); @@ -213,8 +218,9 @@ async fn mode_hints_override_reasoning_effort(source: ModeHintSource) -> Result< count_containing(texts, expected_hint), count_containing(texts, NO_SPAWN_TEXT), count_containing(texts, PROACTIVE_TEXT), + count_containing(texts, CATALOG_PROACTIVE_TEXT), ), - (1, 0, 0) + (1, 0, 0, 0) ); assert_eq!(count_containing(texts, suppressed_hint), 0); } @@ -222,8 +228,66 @@ async fn mode_hints_override_reasoning_effort(source: ModeHintSource) -> Result< Ok(()) } +#[test_case(ReasoningEffort::Ultra, Some(CATALOG_PROACTIVE_TEXT), Some(CATALOG_PROACTIVE_TEXT); "ultra uses proactive override")] +#[test_case(ReasoningEffort::High, Some(CATALOG_PROACTIVE_TEXT), Some(CATALOG_EXPLICIT_TEXT); "non ultra ignores proactive override")] +#[test_case(ReasoningEffort::Ultra, None, Some(PROACTIVE_TEXT); "ultra falls back to built in")] +#[test_case(ReasoningEffort::Ultra, Some(""), None; "empty proactive suppresses ultra mode")] +#[test_case(ReasoningEffort::High, Some(""), Some(CATALOG_EXPLICIT_TEXT); "empty proactive leaves non ultra unchanged")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn model_switch_refreshes_catalog_role_and_explicit_mode() -> Result<()> { +async fn catalog_proactive_mode_is_ultra_only( + effort: ReasoningEffort, + proactive: Option<&'static str>, + expected_hint: Option<&str>, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_model_info_override("gpt-5.4", move |model_info| { + add_ultra_reasoning(model_info); + set_multi_agent_mode( + model_info, + CATALOG_EXPLICIT_TEXT, + proactive, + /*hint_text*/ None, + /*root_role*/ None, + ); + }) + .with_config(configure_multi_agent_v2) + .build_with_auto_env(&server) + .await?; + + submit_turn(&test.codex, "hello", Some(effort)).await?; + + let input = response.single_request().input(); + let texts = developer_texts(&input); + assert_eq!( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + usize::from(expected_hint.is_some()) + ); + if let Some(expected_hint) = expected_hint { + assert_eq!(count_containing(&texts, expected_hint), 1); + } + assert_eq!( + count_containing(&texts, CATALOG_PROACTIVE_TEXT), + usize::from(expected_hint == Some(CATALOG_PROACTIVE_TEXT)) + ); + + Ok(()) +} + +#[test_case(ReasoningEffort::High, [CATALOG_EXPLICIT_TEXT, SECOND_MODEL_EXPLICIT_TEXT]; "explicit mode")] +#[test_case(ReasoningEffort::Ultra, [CATALOG_PROACTIVE_TEXT, SECOND_MODEL_PROACTIVE_TEXT]; "proactive mode")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_switch_refreshes_catalog_role_and_mode( + effort: ReasoningEffort, + expected_hints: [&str; 2], +) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -241,17 +305,21 @@ async fn model_switch_refreshes_catalog_role_and_explicit_mode() -> Result<()> { .await; let test = test_codex() .with_model_info_override("gpt-5.2", |model_info| { + add_ultra_reasoning(model_info); set_multi_agent_mode( model_info, SECOND_MODEL_EXPLICIT_TEXT, + Some(SECOND_MODEL_PROACTIVE_TEXT), /*hint_text*/ None, Some(SECOND_MODEL_ROOT_ROLE_TEXT), ); }) .with_model_info_override("gpt-5.4", |model_info| { + add_ultra_reasoning(model_info); set_multi_agent_mode( model_info, CATALOG_EXPLICIT_TEXT, + Some(CATALOG_PROACTIVE_TEXT), /*hint_text*/ None, Some(FIRST_MODEL_ROOT_ROLE_TEXT), ); @@ -259,7 +327,7 @@ async fn model_switch_refreshes_catalog_role_and_explicit_mode() -> Result<()> { .with_config(configure_multi_agent_v2) .build_with_auto_env(&server) .await?; - submit_turn(&test.codex, "first model", Some(ReasoningEffort::High)).await?; + submit_turn(&test.codex, "first model", Some(effort.clone())).await?; core_test_support::submit_thread_settings( &test.codex, ThreadSettingsOverrides { @@ -268,7 +336,7 @@ async fn model_switch_refreshes_catalog_role_and_explicit_mode() -> Result<()> { }, ) .await?; - submit_turn(&test.codex, "second model", Some(ReasoningEffort::High)).await?; + submit_turn(&test.codex, "second model", Some(effort)).await?; let requests = responses.requests(); for (index, request) in requests.iter().enumerate() { @@ -276,8 +344,8 @@ async fn model_switch_refreshes_catalog_role_and_explicit_mode() -> Result<()> { let texts = developer_texts(&input); assert_eq!( ( - count_containing(&texts, CATALOG_EXPLICIT_TEXT), - count_containing(&texts, SECOND_MODEL_EXPLICIT_TEXT), + count_containing(&texts, expected_hints[0]), + count_containing(&texts, expected_hints[1]), count_containing(&texts, NO_SPAWN_TEXT), count_containing(&texts, PROACTIVE_TEXT), ), @@ -316,8 +384,10 @@ async fn model_switch_refreshes_catalog_role_and_explicit_mode() -> Result<()> { Ok(()) } +#[test_case(ReasoningEffort::High; "explicit mode")] +#[test_case(ReasoningEffort::Ultra; "proactive mode")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn empty_configured_mode_hint_emits_no_mode_message() -> Result<()> { +async fn empty_configured_mode_hint_emits_no_mode_message(effort: ReasoningEffort) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -327,14 +397,24 @@ async fn empty_configured_mode_hint_emits_no_mode_message() -> Result<()> { ) .await; let test = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + add_ultra_reasoning(model_info); + set_multi_agent_mode( + model_info, + CATALOG_EXPLICIT_TEXT, + Some(CATALOG_PROACTIVE_TEXT), + Some(CATALOG_MODE_HINT_TEXT), + /*root_role*/ None, + ); + }) .with_config(|config| { configure_multi_agent_v2(config); config.multi_agent_v2.multi_agent_mode_hint_text = Some(String::new()); }) - .build(&server) + .build_with_auto_env(&server) .await?; - submit_turn(&test.codex, "hello", Some(ReasoningEffort::High)).await?; + submit_turn(&test.codex, "hello", Some(effort)).await?; let input = response.single_request().input(); let texts = developer_texts(&input); diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 6660884585..38e090e696 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -54,6 +54,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { }), mode: Some(MultiAgentModeMessages { explicit: Some("explicit mode".to_string()), + proactive: Some("proactive mode".to_string()), hint_text: Some("mode hint".to_string()), }), }; diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 25d34b2501..5f7f78deb2 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -627,6 +627,9 @@ pub struct MultiAgentRoleMessages { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct MultiAgentModeMessages { pub explicit: Option, + /// Ultra-only mode instructions. Missing or null uses the built-in proactive hint; + /// an empty string suppresses the mode message. `hint_text` takes precedence. + pub proactive: Option, pub hint_text: Option, } @@ -1110,7 +1113,7 @@ mod tests { #[test] fn multi_agent_messages_preserve_missing_and_empty_values() { let messages: ModelMessages = from_str( - r#"{"instructions_template":null,"instructions_variables":null,"multi_agent":{"role":{"root":"","subagent":"subagent base"},"mode":{"explicit":"explicit mode","hint_text":""}}}"#, + r#"{"instructions_template":null,"instructions_variables":null,"multi_agent":{"role":{"root":"","subagent":"subagent base"},"mode":{"explicit":"explicit mode","proactive":"","hint_text":""}}}"#, ) .expect("multi-agent messages should deserialize"); @@ -1123,6 +1126,7 @@ mod tests { }), mode: Some(MultiAgentModeMessages { explicit: Some("explicit mode".to_string()), + proactive: Some(String::new()), hint_text: Some(String::new()), }), })