From 4fe4b982df9c055756ae845895f5c08becdc3ad6 Mon Sep 17 00:00:00 2001 From: shijie-openai Date: Thu, 18 Jun 2026 11:52:02 -0700 Subject: [PATCH 1/2] Fix multi-agent mode lifecycle inheritance --- .../core/src/agent/control/residency_tests.rs | 1 + codex-rs/core/src/agent/control/spawn.rs | 19 +++ codex-rs/core/src/agent/control_tests.rs | 110 +++++++++++++++++- codex-rs/core/src/session/mod.rs | 3 +- codex-rs/core/src/thread_manager.rs | 81 ++++++------- codex-rs/protocol/src/protocol.rs | 60 +++++++--- 6 files changed, 207 insertions(+), 67 deletions(-) diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index cf043971e269..f91a77364ae0 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -142,6 +142,7 @@ async fn spawn_v2_subagent( /*forked_from_thread_id*/ None, Some(ThreadSource::Subagent), /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*environments*/ None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index bc6dba1a48cf..7278d6420f6e 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -1,11 +1,13 @@ use super::residency::is_v2_resident_session_source; use super::*; +use codex_protocol::config_types::MultiAgentMode; const AGENT_NAMES: &str = include_str!("../agent_names.txt"); struct SpawnAgentThreadInheritance { environments: Option, exec_policy: Option>, + inherited_multi_agent_mode: Option, } fn default_agent_nickname_list() -> Vec<&'static str> { @@ -230,6 +232,19 @@ impl AgentControl { agent_max_threads }; let mut reservation = self.state.reserve_spawn_slot(reservation_max_threads)?; + let inherited_multi_agent_mode = + if let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + .. + })) = session_source.as_ref() + { + match state.get_thread(*parent_thread_id).await { + Ok(parent_thread) => parent_thread.config_snapshot().await.multi_agent_mode, + Err(_) => None, + } + } else { + None + }; let inheritance = SpawnAgentThreadInheritance { environments: self .inherited_environments_for_source(&state, session_source.as_ref()) @@ -237,6 +252,7 @@ impl AgentControl { exec_policy: self .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) .await, + inherited_multi_agent_mode, }; let (session_source, mut agent_metadata) = match session_source { Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -283,6 +299,7 @@ impl AgentControl { /*forked_from_thread_id*/ None, /*thread_source*/ Some(ThreadSource::Subagent), /*metrics_service_name*/ None, + inheritance.inherited_multi_agent_mode, inheritance.environments, inheritance.exec_policy, options.environments.clone(), @@ -388,6 +405,7 @@ impl AgentControl { let SpawnAgentThreadInheritance { environments: inherited_environments, exec_policy: inherited_exec_policy, + inherited_multi_agent_mode, } = inheritance; if options.fork_parent_spawn_call_id.is_none() { return Err(CodexErr::Fatal( @@ -513,6 +531,7 @@ impl AgentControl { /*thread_source*/ Some(ThreadSource::Subagent), /*parent_thread_id*/ Some(parent_thread_id), /*forked_from_thread_id*/ Some(parent_thread_id), + inherited_multi_agent_mode, inherited_environments, inherited_exec_policy, options.environments.clone(), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 94f7b0132434..9d7f7a501df7 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -14,6 +14,7 @@ use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::AgentPath; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; @@ -816,6 +817,49 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn spawn_thread_subagent_inherits_parent_selected_multi_agent_mode_without_history() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await + .expect("update parent multi-agent mode"); + + let child_thread_id = harness + .control + .spawn_agent( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + ) + .await + .expect("spawn child without parent history"); + let child_snapshot = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered") + .config_snapshot() + .await; + + assert_eq!( + child_snapshot.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); +} + #[tokio::test] async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let harness = AgentControlHarness::new().await; @@ -838,6 +882,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("start parent thread"); let parent_thread_id = new_thread.thread_id; let parent_thread = new_thread.thread; + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await + .expect("update parent multi-agent mode"); parent_thread .inject_user_message_without_turn("parent seed context".to_string()) .await; @@ -935,6 +988,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .get_thread(child_thread_id) .await .expect("child thread should be registered"); + assert_eq!( + child_thread.config_snapshot().await.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; let expected_history = [ @@ -1241,6 +1298,15 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { let harness = AgentControlHarness::new().await; let (parent_thread_id, parent_thread) = harness.start_thread().await; + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }) + .await + .expect("update parent multi-agent mode"); parent_thread .inject_user_message_without_turn("old parent context".to_string()) @@ -1337,6 +1403,10 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .get_thread(child_thread_id) .await .expect("child thread should be registered"); + assert_eq!( + child_thread.config_snapshot().await.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); let history = child_thread.codex.session.clone_history().await; assert!( @@ -2184,7 +2254,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { } #[tokio::test] -async fn resume_thread_subagent_restores_stored_nickname_and_role() { +async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_agent_mode() { let (home, mut config) = test_config().await; config .features @@ -2206,7 +2276,7 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { manager, control, }; - let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; let agent_path = AgentPath::from_string("/root/explorer".to_string()) .expect("test agent path should be valid"); @@ -2231,6 +2301,38 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { .get_thread(child_thread_id) .await .expect("child thread should exist"); + let mut child_turn_context = child_thread + .codex + .session + .new_default_turn() + .await + .to_turn_context_item(); + child_turn_context.multi_agent_mode = Some(MultiAgentMode::Proactive); + child_thread + .codex + .session + .persist_rollout_items(&[RolloutItem::TurnContext(child_turn_context)]) + .await; + child_thread + .codex + .session + .ensure_rollout_materialized() + .await; + child_thread + .codex + .session + .flush_rollout() + .await + .expect("flush child effective multi-agent mode"); + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::ExplicitRequestOnly), + ..Default::default() + }) + .await + .expect("change parent multi-agent mode before child resume"); let mut status_rx = harness .control .subscribe_status(child_thread_id) @@ -2319,6 +2421,10 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { assert_eq!(resumed_agent_path, Some(agent_path)); assert_eq!(resumed_nickname, Some(original_nickname)); assert_eq!(resumed_role, Some("explorer".to_string())); + assert_eq!( + resumed_snapshot.multi_agent_mode, + Some(MultiAgentMode::Proactive) + ); let _ = harness .control diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 251c67cedc42..b9d1f3af0745 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -578,8 +578,7 @@ impl Codex { .await; let multi_agent_version = resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); - let multi_agent_mode = - initial_multi_agent_mode.or_else(|| conversation_history.get_multi_agent_mode()); + let multi_agent_mode = initial_multi_agent_mode; config .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 47d358204dd7..62fc13000a55 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -721,6 +721,7 @@ impl ThreadManager { let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.state.spawn_thread_with_source( config, initial_history, @@ -732,7 +733,7 @@ impl ThreadManager { thread_source, Vec::new(), /*metrics_service_name*/ None, - /*initial_multi_agent_mode*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, parent_trace, @@ -762,6 +763,7 @@ impl ThreadManager { /*thread_source*/ None, Vec::new(), /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*parent_trace*/ None, environments, /*thread_extension_init*/ ExtensionDataInit::default(), @@ -785,6 +787,7 @@ impl ThreadManager { let (session_source, thread_source) = initial_history .get_resumed_session_sources() .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.state.spawn_thread_with_source( config, initial_history, @@ -796,7 +799,7 @@ impl ThreadManager { thread_source, Vec::new(), /*metrics_service_name*/ None, - /*initial_multi_agent_mode*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*parent_trace*/ None, @@ -936,18 +939,25 @@ impl ThreadManager { ) -> CodexResult { // `forked_from_id()` describes this history's existing lineage. When // forking a resumed thread, the child copies the resumed thread itself. - let forked_from_thread_id = match &history { + let source_thread_id = match &history { InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), InitialHistory::Forked(_) => history.forked_from_id(), InitialHistory::New | InitialHistory::Cleared => None, }; + let initial_multi_agent_mode = match source_thread_id { + Some(thread_id) => match self.get_thread(thread_id).await { + Ok(thread) => thread.config_snapshot().await.multi_agent_mode, + Err(_) => history.get_latest_effective_multi_agent_mode(), + }, + None => history.get_latest_effective_multi_agent_mode(), + }; let multi_agent_version = self .state .effective_multi_agent_version_for_spawn( &history, /*session_source*/ None, /*parent_thread_id*/ None, - forked_from_thread_id, + source_thread_id, &config, ) .await; @@ -964,10 +974,11 @@ impl ThreadManager { Arc::clone(&self.state.auth_manager), self.agent_control(), /*parent_thread_id*/ None, - forked_from_thread_id, + source_thread_id, thread_source, Vec::new(), /*metrics_service_name*/ None, + initial_multi_agent_mode, parent_trace, environments, /*thread_extension_init*/ ExtensionDataInit::default(), @@ -1102,12 +1113,16 @@ impl ThreadManagerState { parent_thread_id: Option, forked_from_thread_id: Option, ) -> Option { - let inherited_thread_id = Self::inherited_thread_id_for_spawn( - initial_history, - session_source, - parent_thread_id, - forked_from_thread_id, - ); + let inherited_thread_id = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + })) => Some(*parent_thread_id), + _ => match initial_history { + InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), + InitialHistory::Forked(_) => forked_from_thread_id.or(parent_thread_id), + InitialHistory::New | InitialHistory::Cleared => parent_thread_id, + }, + }; let inherited_multi_agent_version = match inherited_thread_id { Some(thread_id) => self .get_thread(thread_id) @@ -1119,24 +1134,6 @@ impl ThreadManagerState { resolve_multi_agent_version(initial_history, inherited_multi_agent_version) } - fn inherited_thread_id_for_spawn( - initial_history: &InitialHistory, - session_source: Option<&SessionSource>, - parent_thread_id: Option, - forked_from_thread_id: Option, - ) -> Option { - match session_source { - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - })) => Some(*parent_thread_id), - _ => match initial_history { - InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), - InitialHistory::Forked(_) => forked_from_thread_id.or(parent_thread_id), - InitialHistory::New | InitialHistory::Cleared => parent_thread_id, - }, - } - } - /// Resolves the provider snapshot for a newly spawned runtime. /// /// Loads a fresh provider snapshot for: @@ -1201,6 +1198,7 @@ impl ThreadManagerState { /*forked_from_thread_id*/ None, /*thread_source*/ None, /*metrics_service_name*/ None, + /*initial_multi_agent_mode*/ None, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, /*environments*/ None, @@ -1218,6 +1216,7 @@ impl ThreadManagerState { forked_from_thread_id: Option, thread_source: Option, metrics_service_name: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, environments: Option>, @@ -1236,7 +1235,7 @@ impl ThreadManagerState { thread_source, Vec::new(), metrics_service_name, - /*initial_multi_agent_mode*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1263,6 +1262,7 @@ impl ThreadManagerState { let environments = default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); let thread_source = initial_history.get_resumed_thread_source(); + let initial_multi_agent_mode = initial_history.get_latest_effective_multi_agent_mode(); Box::pin(self.spawn_thread_with_source( config, initial_history, @@ -1274,7 +1274,7 @@ impl ThreadManagerState { thread_source, Vec::new(), /*metrics_service_name*/ None, - /*initial_multi_agent_mode*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1295,6 +1295,7 @@ impl ThreadManagerState { thread_source: Option, parent_thread_id: Option, forked_from_thread_id: Option, + initial_multi_agent_mode: Option, inherited_environments: Option, inherited_exec_policy: Option>, environments: Option>, @@ -1313,7 +1314,7 @@ impl ThreadManagerState { thread_source, Vec::new(), /*metrics_service_name*/ None, - /*initial_multi_agent_mode*/ None, + initial_multi_agent_mode, inherited_environments, inherited_exec_policy, /*parent_trace*/ None, @@ -1337,6 +1338,7 @@ impl ThreadManagerState { thread_source: Option, dynamic_tools: Vec, metrics_service_name: Option, + initial_multi_agent_mode: Option, parent_trace: Option, environments: Vec, thread_extension_init: ExtensionDataInit, @@ -1353,7 +1355,7 @@ impl ThreadManagerState { thread_source, dynamic_tools, metrics_service_name, - /*initial_multi_agent_mode*/ None, + initial_multi_agent_mode, /*inherited_environments*/ None, /*inherited_exec_policy*/ None, parent_trace, @@ -1422,19 +1424,6 @@ impl ThreadManagerState { forked_from_thread_id, ) .await; - let inherited_multi_agent_mode = match Self::inherited_thread_id_for_spawn( - &initial_history, - Some(&session_source), - parent_thread_id, - forked_from_thread_id, - ) { - Some(thread_id) => match self.get_thread(thread_id).await { - Ok(thread) => thread.config_snapshot().await.multi_agent_mode, - Err(_) => None, - }, - None => None, - }; - let initial_multi_agent_mode = initial_multi_agent_mode.or(inherited_multi_agent_mode); let CodexSpawnOk { codex, thread_id, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 047a765faa6e..c91898f11b0e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2547,12 +2547,24 @@ impl InitialHistory { } } - pub fn get_multi_agent_mode(&self) -> Option { - match self { - InitialHistory::New | InitialHistory::Cleared => None, - InitialHistory::Resumed(resumed) => multi_agent_mode_from_items(&resumed.history), - InitialHistory::Forked(items) => multi_agent_mode_from_items(items), - } + pub fn get_latest_effective_multi_agent_mode(&self) -> Option { + let items = match self { + InitialHistory::New | InitialHistory::Cleared => return None, + InitialHistory::Resumed(resumed) => &resumed.history, + InitialHistory::Forked(items) => items, + }; + items + .iter() + .rev() + .find_map(|item| match item { + RolloutItem::TurnContext(turn_context) => Some(turn_context), + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::InterAgentCommunication(_) + | RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) => None, + }) + .and_then(|turn_context| turn_context.multi_agent_mode) } pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { @@ -2867,17 +2879,6 @@ fn multi_agent_version_from_items( }) } -fn multi_agent_mode_from_items(items: &[RolloutItem]) -> Option { - items.iter().rev().find_map(|item| match item { - RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_mode, - RolloutItem::SessionMeta(_) - | RolloutItem::ResponseItem(_) - | RolloutItem::InterAgentCommunication(_) - | RolloutItem::Compacted(_) - | RolloutItem::EventMsg(_) => None, - }) -} - #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -5379,6 +5380,31 @@ mod tests { Ok(()) } + #[test] + fn latest_effective_multi_agent_mode_uses_latest_turn_context_even_when_unset() -> Result<()> { + let turn_context_item = |multi_agent_mode| -> Result { + let mut value = json!({ + "cwd": test_path_buf("/tmp"), + "approval_policy": "never", + "sandbox_policy": { "type": "danger-full-access" }, + "model": "gpt-5", + "summary": "auto", + }); + value["multi_agent_mode"] = serde_json::to_value(multi_agent_mode)?; + Ok(RolloutItem::TurnContext(serde_json::from_value(value)?)) + }; + + assert_eq!( + InitialHistory::Forked(vec![ + turn_context_item(Some(MultiAgentMode::Proactive))?, + turn_context_item(/*multi_agent_mode*/ None)?, + ]) + .get_latest_effective_multi_agent_mode(), + None + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { From e5ff18c443aed202614ae510ed1fb1670f73ae1a Mon Sep 17 00:00:00 2001 From: shijie-openai Date: Thu, 18 Jun 2026 12:53:31 -0700 Subject: [PATCH 2/2] Sync subagent multi-agent mode with root --- codex-rs/core/src/agent/control.rs | 7 + codex-rs/core/src/agent/control_tests.rs | 53 ++++++-- codex-rs/core/src/session/turn_context.rs | 12 +- codex-rs/core/tests/suite/multi_agent_mode.rs | 122 ++++++++++++++++++ 4 files changed, 180 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 01f0e1415d3f..1028e2487fc8 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -17,6 +17,7 @@ use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ContentItem; @@ -121,6 +122,12 @@ impl AgentControl { self.session_id } + pub(crate) async fn root_multi_agent_mode(&self) -> CodexResult> { + let state = self.upgrade()?; + let root_thread = state.get_thread(ThreadId::from(self.session_id)).await?; + Ok(root_thread.config_snapshot().await.multi_agent_mode) + } + /// Send rich user input items to an existing agent thread. pub(crate) async fn send_input( &self, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 9d7f7a501df7..bb6df7081c2c 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -830,9 +830,9 @@ async fn spawn_thread_subagent_inherits_parent_selected_multi_agent_mode_without }) .await .expect("update parent multi-agent mode"); + let control = parent_thread.codex.session.services.agent_control.clone(); - let child_thread_id = harness - .control + let child_thread_id = control .spawn_agent( harness.config.clone(), text_input("child task"), @@ -858,6 +858,27 @@ async fn spawn_thread_subagent_inherits_parent_selected_multi_agent_mode_without child_snapshot.multi_agent_mode, Some(MultiAgentMode::Proactive) ); + + parent_thread + .codex + .session + .update_settings(crate::session::SessionSettingsUpdate { + multi_agent_mode: Some(MultiAgentMode::ExplicitRequestOnly), + ..Default::default() + }) + .await + .expect("update parent multi-agent mode after child spawn"); + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should remain registered"); + let child_turn = child_thread.codex.session.new_default_turn().await; + + assert_eq!( + child_turn.multi_agent_mode, + Some(MultiAgentMode::ExplicitRequestOnly) + ); } #[tokio::test] @@ -2254,7 +2275,7 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { } #[tokio::test] -async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_agent_mode() { +async fn resumed_thread_subagent_uses_root_multi_agent_mode_for_its_next_turn() { let (home, mut config) = test_config().await; config .features @@ -2277,11 +2298,11 @@ async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_age control, }; let (parent_thread_id, parent_thread) = harness.start_thread().await; + let control = parent_thread.codex.session.services.agent_control.clone(); let agent_path = AgentPath::from_string("/root/explorer".to_string()) .expect("test agent path should be valid"); - let child_thread_id = harness - .control + let child_thread_id = control .spawn_agent( harness.config.clone(), text_input("hello child"), @@ -2333,8 +2354,7 @@ async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_age }) .await .expect("change parent multi-agent mode before child resume"); - let mut status_rx = harness - .control + let mut status_rx = control .subscribe_status(child_thread_id) .await .expect("status subscription should succeed"); @@ -2375,14 +2395,12 @@ async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_age .await .expect("child thread metadata should be persisted to sqlite before shutdown"); - let _ = harness - .control + let _ = control .shutdown_live_agent(child_thread_id) .await .expect("child shutdown should submit"); - let resumed_thread_id = harness - .control + let resumed_thread_id = control .resume_agent_from_rollout( harness.config.clone(), child_thread_id, @@ -2425,9 +2443,18 @@ async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_age resumed_snapshot.multi_agent_mode, Some(MultiAgentMode::Proactive) ); + let resumed_thread = harness + .manager + .get_thread(resumed_thread_id) + .await + .expect("resumed child thread should remain registered"); + let resumed_turn = resumed_thread.codex.session.new_default_turn().await; + assert_eq!( + resumed_turn.multi_agent_mode, + Some(MultiAgentMode::ExplicitRequestOnly) + ); - let _ = harness - .control + let _ = control .shutdown_live_agent(resumed_thread_id) .await .expect("resumed child shutdown should submit"); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8599d9cff4e8..3fecef59730f 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -746,10 +746,20 @@ impl Session { async fn new_turn_context_from_configuration( &self, sub_id: String, - session_configuration: SessionConfiguration, + mut session_configuration: SessionConfiguration, final_output_json_schema: Option>, multi_agent_runtime: TurnMultiAgentRuntime, ) -> Arc { + // Thread-spawn subagents follow the root's current selection. If the root is unavailable, + // retain the subagent's persisted selection so standalone resume still has a fallback. + if matches!( + session_configuration.session_source, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) + ) && let Ok(root_multi_agent_mode) = + self.services.agent_control.root_multi_agent_mode().await + { + session_configuration.multi_agent_mode = root_multi_agent_mode; + } let turn_environments = self.services.turn_environments.snapshot().await; let primary_turn_environment = turn_environments.primary().cloned(); // TODO(anp): Migrate per-turn config and legacy TurnContext cwd consumers to PathUri so diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs index ba91f2fd315e..f9b488b8eb09 100644 --- a/codex-rs/core/tests/suite/multi_agent_mode.rs +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -7,8 +7,10 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -17,6 +19,10 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; +use std::time::Duration; + +const SPAWN_CALL_ID: &str = "spawn-call-1"; const NO_SPAWN_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active."; @@ -59,6 +65,10 @@ async fn submit_turn( Ok(()) } +fn body_contains(req: &wiremock::Request, text: &str) -> bool { + String::from_utf8_lossy(&req.body).contains(text) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { skip_if_no_network!(Ok(())); @@ -132,6 +142,118 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn existing_subagent_uses_root_multi_agent_mode_on_its_next_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let root_prompt = "spawn a child"; + let child_prompt = "child initial task"; + let child_followup = "child followup task"; + let spawn_args = serde_json::to_string(&json!({ + "message": child_prompt, + "task_name": "worker", + }))?; + mount_sse_once_match( + &server, + move |req: &wiremock::Request| body_contains(req, root_prompt), + sse(vec![ + ev_response_created("root-spawn-response"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("root-spawn-response"), + ]), + ) + .await; + mount_sse_once_match( + &server, + move |req: &wiremock::Request| { + body_contains(req, child_prompt) && !body_contains(req, SPAWN_CALL_ID) + }, + sse(vec![ + ev_response_created("child-initial-response"), + ev_completed("child-initial-response"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + sse(vec![ + ev_response_created("root-followup-response"), + ev_completed("root-followup-response"), + ]), + ) + .await; + let child_followup_response = mount_sse_once_match( + &server, + move |req: &wiremock::Request| body_contains(req, child_followup), + sse(vec![ + ev_response_created("child-followup-response"), + ev_completed("child-followup-response"), + ]), + ) + .await; + + let test = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentMode) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::EnableRequestCompression) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let mut created_threads = test.thread_manager.subscribe_thread_created(); + + submit_turn(&test.codex, root_prompt, /*mode*/ None).await?; + let child_thread_id = + tokio::time::timeout(Duration::from_secs(10), created_threads.recv()).await??; + let child_thread = test.thread_manager.get_thread(child_thread_id).await?; + wait_for_event(&child_thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.codex + .submit(Op::ThreadSettings { + thread_settings: ThreadSettingsOverrides { + multi_agent_mode: Some(MultiAgentMode::Proactive), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ThreadSettingsApplied(_)) + }) + .await; + + submit_turn(&child_thread, child_followup, /*mode*/ None).await?; + + let input = child_followup_response.single_request().input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + ), + (2, 1, 1) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<()> { skip_if_no_network!(Ok(()));