diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index fd220ae86d..c0d799dde4 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2175,6 +2175,7 @@ mod tests { model_provider: "openai".to_string(), model: None, reasoning_effort: None, + multi_agent_version: None, created_at, updated_at: created_at, archived_at: None, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index b5d49cf7fb..8b23ee5183 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -400,6 +400,7 @@ mod thread_processor_behavior_tests { model_provider: "openai".to_string(), model: None, reasoning_effort: None, + multi_agent_version: None, created_at: created_at.with_timezone(&Utc), updated_at: updated_at.with_timezone(&Utc), archived_at: None, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6b2a9a0abe..99aa483d17 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -143,6 +143,7 @@ pub fn create_fake_rollout_with_source( agent_nickname: None, agent_role: None, model_provider: model_provider.map(str::to_string), + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, @@ -227,6 +228,7 @@ pub fn create_fake_rollout_with_text_elements( agent_nickname: None, agent_role: None, model_provider: model_provider.map(str::to_string), + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 5253850107..02cf9869a3 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -131,6 +131,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Disabled, }, event_persistence_mode: ThreadEventPersistenceMode::default(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index ee430f1593..1abc9235c1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1365,6 +1365,7 @@ async fn seed_pathless_store_thread( metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Disabled, }, event_persistence_mode: ThreadEventPersistenceMode::default(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 36dac0d45c..d186427b59 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1749,6 +1749,7 @@ stream_max_retries = 0 agent_nickname: None, agent_role: None, model_provider: Some("mock_provider".to_string()), + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index dea8433d44..4ad1393651 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -218,6 +218,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Disabled, }, event_persistence_mode: ThreadEventPersistenceMode::default(), diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 48781949fe..608594873d 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -511,14 +511,12 @@ impl AgentControl { config: crate::config::Config, thread_id: ThreadId, session_source: SessionSource, - multi_agent_version: Option, ) -> CodexResult { let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( config.clone(), thread_id, session_source, - multi_agent_version, )) .await?; let state = self.upgrade()?; @@ -571,7 +569,6 @@ impl AgentControl { config.clone(), child_thread_id, child_session_source, - multi_agent_version, )) .await { @@ -596,8 +593,16 @@ impl AgentControl { mut config: crate::config::Config, thread_id: ThreadId, session_source: SessionSource, - multi_agent_version: Option, ) -> CodexResult { + let state = self.upgrade()?; + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let multi_agent_version = stored_thread.multi_agent_version; if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = &session_source && *depth >= config.agent_max_depth && multi_agent_version != Some(MultiAgentVersion::V2) @@ -605,7 +610,6 @@ impl AgentControl { let _ = config.features.disable(Feature::SpawnCsv); let _ = config.features.disable(Feature::Collab); } - let state = self.upgrade()?; let state_db_ctx = state.state_db(); let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; let (session_source, agent_metadata) = match session_source { @@ -644,13 +648,6 @@ impl AgentControl { let inherited_exec_policy = self .inherited_exec_policy_for_source(&state, Some(&session_source), &config) .await; - let stored_thread = state - .read_stored_thread(ReadThreadParams { - thread_id, - include_archived: true, - include_history: true, - }) - .await?; let history = stored_thread .history .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? @@ -666,7 +663,6 @@ impl AgentControl { }), agent_control: self.clone(), session_source, - inherited_multi_agent_version: multi_agent_version, inherited_shell_snapshot, inherited_exec_policy, }) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 82f475a075..1990163184 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -341,12 +341,7 @@ async fn resume_agent_errors_when_manager_dropped() { let control = AgentControl::default(); let (_home, config) = test_config().await; let err = control - .resume_agent_from_rollout( - config, - ThreadId::new(), - SessionSource::Exec, - /*multi_agent_version*/ None, - ) + .resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec) .await .expect_err("resume_agent should fail without a manager"); assert_eq!( @@ -1235,6 +1230,7 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { SpawnAgentOptions { fork_parent_spawn_call_id: Some(parent_spawn_call_id), fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + multi_agent_version: Some(MultiAgentVersion::V2), ..Default::default() }, ) @@ -1447,12 +1443,7 @@ async fn resume_agent_respects_max_threads_limit() { .expect("spawn_agent should succeed for active slot"); let err = control - .resume_agent_from_rollout( - config, - resumable_id, - SessionSource::Exec, - /*multi_agent_version*/ None, - ) + .resume_agent_from_rollout(config, resumable_id, SessionSource::Exec) .await .expect_err("resume should respect max threads"); let CodexErr::AgentLimitReached { @@ -1486,12 +1477,7 @@ async fn resume_agent_releases_slot_after_resume_failure() { let control = manager.agent_control(); let _ = control - .resume_agent_from_rollout( - config.clone(), - ThreadId::new(), - SessionSource::Exec, - /*multi_agent_version*/ None, - ) + .resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec) .await .expect_err("resume should fail for missing rollout path"); @@ -1888,7 +1874,6 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { agent_nickname: None, agent_role: None, }), - /*multi_agent_version*/ None, ) .await .expect("resume should succeed"); @@ -1962,12 +1947,7 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() { let resumed_thread_id = harness .control - .resume_agent_from_rollout( - harness.config.clone(), - child_thread_id, - SessionSource::Exec, - /*multi_agent_version*/ None, - ) + .resume_agent_from_rollout(harness.config.clone(), child_thread_id, SessionSource::Exec) .await .expect("resume should find archived rollout"); assert_eq!(resumed_thread_id, child_thread_id); @@ -2410,7 +2390,6 @@ async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() { harness.config.clone(), parent_thread_id, SessionSource::Exec, - /*multi_agent_version*/ None, ) .await .expect("single-thread resume should succeed"); @@ -2507,7 +2486,6 @@ async fn resume_closed_child_reopens_open_descendants() { agent_nickname: None, agent_role: None, }), - /*multi_agent_version*/ None, ) .await .expect("child resume should succeed"); @@ -2533,6 +2511,79 @@ async fn resume_closed_child_reopens_open_descendants() { .expect("parent shutdown should succeed"); } +#[tokio::test] +async fn resume_agent_from_rollout_restores_persisted_multi_agent_version() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("hello"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + multi_agent_version: Some(MultiAgentVersion::V2), + ..Default::default() + }, + ) + .await + .expect("child spawn should succeed") + .thread_id; + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should exist"); + persist_thread_for_tree_resume(&child_thread, "persist selector").await; + assert_eq!( + child_thread.codex.session.multi_agent_version, + Some(MultiAgentVersion::V2) + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let resumed_thread_id = harness + .control + .resume_agent_from_rollout( + harness.config.clone(), + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + ) + .await + .expect("child resume should succeed"); + let resumed_thread = harness + .manager + .get_thread(resumed_thread_id) + .await + .expect("resumed child thread should exist"); + + assert_eq!( + resumed_thread.codex.session.multi_agent_version, + Some(MultiAgentVersion::V2) + ); + + let _ = harness + .control + .shutdown_agent_tree(parent_thread_id) + .await + .expect("agent tree shutdown should succeed"); +} + #[tokio::test] async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdown() { let harness = AgentControlHarness::new().await; @@ -2600,7 +2651,6 @@ async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdo harness.config.clone(), parent_thread_id, SessionSource::Exec, - /*multi_agent_version*/ None, ) .await .expect("tree resume should succeed"); @@ -2714,7 +2764,6 @@ async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_sourc harness.config.clone(), parent_thread_id, SessionSource::Exec, - /*multi_agent_version*/ None, ) .await .expect("tree resume should succeed"); @@ -2830,7 +2879,6 @@ async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails() harness.config.clone(), parent_thread_id, SessionSource::Exec, - /*multi_agent_version*/ None, ) .await .expect("root resume should succeed"); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index e35a1857ae..be6ed9561d 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -77,7 +77,7 @@ pub(crate) async fn run_codex_thread_interactive( let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, installation_id: parent_session.installation_id.clone(), - inherited_multi_agent_version: None, + parent_multi_agent_version: parent_ctx.multi_agent_version, auth_manager, models_manager, environment_manager: Arc::clone(&parent_session.services.environment_manager), diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index ba9a037d7a..a83cd6bc60 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -55,6 +55,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R agent_nickname: None, agent_role: None, model_provider: None, + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index f36239f2ae..955738af1b 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -37,6 +37,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre model_provider: "test-provider".to_string(), model: Some("gpt-5.2".to_string()), reasoning_effort: None, + multi_agent_version: None, created_at: Utc .timestamp_opt(1_709_251_100, 0) .single() diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 62a659d257..519c660ce5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -226,6 +226,7 @@ use self::turn::collect_explicit_app_ids_from_skill_items; use self::turn::realtime_text_for_event; use self::turn_context::TurnContext; use self::turn_context::TurnSkillsContext; +use self::turn_context::resolve_multi_agent_version; #[cfg(test)] mod rollout_reconstruction_tests; @@ -393,7 +394,7 @@ pub struct CodexSpawnOk { pub(crate) struct CodexSpawnArgs { pub(crate) config: Config, pub(crate) installation_id: String, - pub(crate) inherited_multi_agent_version: Option, + pub(crate) parent_multi_agent_version: Option, pub(crate) auth_manager: Arc, pub(crate) models_manager: SharedModelsManager, pub(crate) environment_manager: Arc, @@ -459,7 +460,7 @@ impl Codex { let CodexSpawnArgs { mut config, installation_id, - inherited_multi_agent_version, + parent_multi_agent_version, auth_manager, models_manager, environment_manager, @@ -488,14 +489,6 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source - && depth >= config.agent_max_depth - && inherited_multi_agent_version != Some(MultiAgentVersion::V2) - { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } - let primary_environment = environment_selections.primary_environment(); let mut user_instruction_warnings = Vec::new(); let user_instructions = AgentsMdManager::new(&config) @@ -546,6 +539,24 @@ impl Codex { let model_info = models_manager .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; + let multi_agent_version = match &conversation_history { + InitialHistory::Resumed(_) => conversation_history.get_multi_agent_version(), + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { + resolve_multi_agent_version( + &model_info, + &config, + &session_source, + parent_multi_agent_version, + ) + } + }; + if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source + && depth >= config.agent_max_depth + && multi_agent_version != Some(MultiAgentVersion::V2) + { + let _ = config.features.disable(Feature::SpawnCsv); + let _ = config.features.disable(Feature::Collab); + } let base_instructions = config .base_instructions .clone() @@ -613,7 +624,7 @@ impl Codex { session_configuration, config.clone(), installation_id, - inherited_multi_agent_version, + multi_agent_version, auth_manager.clone(), models_manager.clone(), exec_policy, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index aaf8e7b8f9..300c3e9cba 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -20,8 +20,8 @@ use tokio::sync::Semaphore; pub(crate) struct Session { pub(crate) conversation_id: ThreadId, pub(crate) installation_id: String, - /// Parent turn's resolved selector for child sessions. - pub(crate) inherited_multi_agent_version: Option, + /// Thread-scoped multi-agent runtime selector, resolved when the thread starts. + pub(crate) multi_agent_version: Option, pub(super) tx_event: Sender, pub(super) agent_status: watch::Sender, pub(super) out_of_band_elicitation_paused: watch::Sender, @@ -487,7 +487,7 @@ impl Session { mut session_configuration: SessionConfiguration, config: Arc, installation_id: String, - inherited_multi_agent_version: Option, + multi_agent_version: Option, auth_manager: Arc, models_manager: SharedModelsManager, exec_policy: Arc, @@ -563,6 +563,7 @@ impl Session { metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), + multi_agent_version, memory_mode: if config.memories.generate_memories { ThreadMemoryMode::Enabled } else { @@ -585,6 +586,7 @@ impl Session { metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), + multi_agent_version, memory_mode: if config.memories.generate_memories { ThreadMemoryMode::Enabled } else { @@ -1059,7 +1061,7 @@ impl Session { let sess = Arc::new(Session { conversation_id: thread_id, installation_id, - inherited_multi_agent_version, + multi_agent_version, tx_event: tx_event.clone(), agent_status, out_of_band_elicitation_paused, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 07714e359e..9a5c3c94ba 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3458,6 +3458,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), + multi_agent_version: None, memory_mode: if config.memories.generate_memories { ThreadMemoryMode::Enabled } else { @@ -4494,7 +4495,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { session_configuration, Arc::clone(&config), "11111111-1111-4111-8111-111111111111".to_string(), - /*inherited_multi_agent_version*/ None, + /*multi_agent_version*/ None, auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -4724,13 +4725,13 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { "turn_id".to_string(), skills_outcome, /*goal_tools_supported*/ true, - /*inherited_multi_agent_version*/ None, + /*multi_agent_version*/ None, ); let session = Session { conversation_id: thread_id, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), - inherited_multi_agent_version: None, + multi_agent_version: None, tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -4843,7 +4844,7 @@ async fn make_session_with_config_and_rx( session_configuration, Arc::clone(&config), "11111111-1111-4111-8111-111111111111".to_string(), - /*inherited_multi_agent_version*/ None, + /*multi_agent_version*/ None, auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -4948,7 +4949,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( session_configuration, Arc::clone(&config), "11111111-1111-4111-8111-111111111111".to_string(), - /*inherited_multi_agent_version*/ None, + /*multi_agent_version*/ None, auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -5958,6 +5959,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { metadata: ThreadPersistenceMetadata { cwd: Some(config.cwd.to_path_buf()), model_provider: config.model_provider_id.clone(), + multi_agent_version: None, memory_mode: if config.memories.generate_memories { ThreadMemoryMode::Enabled } else { @@ -6571,13 +6573,13 @@ where "turn_id".to_string(), skills_outcome, /*goal_tools_supported*/ true, - /*inherited_multi_agent_version*/ None, + /*multi_agent_version*/ None, )); let session = Arc::new(Session { conversation_id: thread_id, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), - inherited_multi_agent_version: None, + multi_agent_version: None, tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -7255,6 +7257,24 @@ async fn spawned_child_multi_agent_version_follows_parent_system() { ); } +#[tokio::test] +async fn model_switch_preserves_thread_multi_agent_version() { + let (session, mut turn_context) = make_session_and_context().await; + turn_context.multi_agent_version = Some(MultiAgentVersion::V1); + + let switched_context = turn_context + .with_model( + turn_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + + assert_eq!( + switched_context.multi_agent_version, + Some(MultiAgentVersion::V1) + ); +} + #[tokio::test] async fn build_initial_context_omits_default_image_save_location_with_image_history() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 677f407d13..0c045b6da9 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -681,7 +681,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), - inherited_multi_agent_version: None, + parent_multi_agent_version: None, auth_manager, models_manager, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b5a2556c11..910de99d33 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -110,7 +110,7 @@ pub(crate) fn resolve_multi_agent_version( model_info: &ModelInfo, config: &Config, session_source: &SessionSource, - inherited_multi_agent_version: Option, + parent_multi_agent_version: Option, ) -> Option { if is_guardian_reviewer_source(session_source) || matches!( @@ -120,7 +120,7 @@ pub(crate) fn resolve_multi_agent_version( { return None; } - inherited_multi_agent_version.or_else(|| { + parent_multi_agent_version.or_else(|| { model_info.multi_agent_version.or_else(|| { if config.features.enabled(Feature::MultiAgentV2) { Some(MultiAgentVersion::V2) @@ -217,16 +217,7 @@ impl TurnContext { ToolMode::Direct } }); - let multi_agent_version = if self.session_source.is_non_root_agent() { - self.multi_agent_version - } else { - resolve_multi_agent_version( - &model_info, - &config, - &self.session_source, - /*inherited_multi_agent_version*/ None, - ) - }; + let multi_agent_version = self.multi_agent_version; let truncation_policy = model_info.truncation_policy.into(); let supported_reasoning_levels = model_info .supported_reasoning_levels @@ -508,7 +499,7 @@ impl Session { sub_id: String, skills_outcome: Arc, goal_tools_supported: bool, - inherited_multi_agent_version: Option, + multi_agent_version: Option, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration @@ -542,12 +533,6 @@ impl Session { ToolMode::Direct } }); - let multi_agent_version = resolve_multi_agent_version( - &model_info, - &per_turn_config, - &session_source, - inherited_multi_agent_version, - ); per_turn_config.service_tier = get_service_tier( per_turn_config.service_tier, per_turn_config.features.enabled(Feature::FastMode), @@ -797,7 +782,7 @@ impl Session { sub_id, skills_outcome, goal_tools_supported, - self.inherited_multi_agent_version, + self.multi_agent_version, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index cf0607454d..b14eacd50a 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -50,7 +50,6 @@ use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::WarningEvent; -use codex_features::Feature; use codex_protocol::models::ContentItem; use codex_protocol::openai_models::MultiAgentVersion; pub(crate) use compact::CompactTask; @@ -71,11 +70,14 @@ pub(crate) enum InterruptedTurnHistoryMarker { } impl InterruptedTurnHistoryMarker { - pub(crate) fn from_config(config: &Config) -> Self { + pub(crate) fn from_config( + config: &Config, + multi_agent_version: Option, + ) -> Self { if !config.agent_interrupt_message_enabled { return Self::Disabled; } - if config.features.enabled(Feature::MultiAgentV2) { + if multi_agent_version == Some(MultiAgentVersion::V2) { Self::Developer } else { Self::ContextualUser diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index bd79d68d48..c3650cbee2 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -188,7 +188,6 @@ pub(crate) struct ResumeThreadWithHistoryOptions { pub(crate) initial_history: InitialHistory, pub(crate) agent_control: AgentControl, pub(crate) session_source: SessionSource, - pub(crate) inherited_multi_agent_version: Option, pub(crate) inherited_shell_snapshot: Option>, pub(crate) inherited_exec_policy: Option>, } @@ -597,13 +596,14 @@ impl ThreadManager { let thread_source = options .thread_source .or_else(|| options.initial_history.get_resumed_thread_source()); + let parent_multi_agent_version = options.initial_history.get_multi_agent_version(); Box::pin(self.state.spawn_thread_with_source( options.config, options.initial_history, Arc::clone(&self.state.auth_manager), self.agent_control(), session_source, - /*inherited_multi_agent_version*/ None, + parent_multi_agent_version, forked_from_thread_id, thread_source, options.dynamic_tools, @@ -643,7 +643,10 @@ impl ThreadManager { options.initial_history = fork_history_from_snapshot( ForkSnapshot::Interrupted, history, - InterruptedTurnHistoryMarker::from_config(&options.config), + InterruptedTurnHistoryMarker::from_config( + &options.config, + fork_source.codex.session.multi_agent_version, + ), ); self.start_thread_with_options_and_fork_source(options, Some(forked_from_thread_id)) .await @@ -898,7 +901,8 @@ impl ThreadManager { InitialHistory::Forked(_) => history.forked_from_id(), InitialHistory::New | InitialHistory::Cleared => None, }; - let interrupted_marker = InterruptedTurnHistoryMarker::from_config(&config); + let interrupted_marker = + InterruptedTurnHistoryMarker::from_config(&config, history.get_multi_agent_version()); let history = fork_history_from_snapshot(snapshot, history, interrupted_marker); let environments = default_thread_environment_selections( self.state.environment_manager.as_ref(), @@ -1032,7 +1036,7 @@ impl ThreadManagerState { config, agent_control, self.session_source.clone(), - /*inherited_multi_agent_version*/ None, + /*parent_multi_agent_version*/ None, /*forked_from_thread_id*/ None, /*thread_source*/ None, /*persist_extended_history*/ false, @@ -1050,7 +1054,7 @@ impl ThreadManagerState { config: Config, agent_control: AgentControl, session_source: SessionSource, - inherited_multi_agent_version: Option, + parent_multi_agent_version: Option, forked_from_thread_id: Option, thread_source: Option, persist_extended_history: bool, @@ -1068,7 +1072,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, - inherited_multi_agent_version, + parent_multi_agent_version, forked_from_thread_id, thread_source, Vec::new(), @@ -1092,7 +1096,6 @@ impl ThreadManagerState { initial_history, agent_control, session_source, - inherited_multi_agent_version, inherited_shell_snapshot, inherited_exec_policy, } = options; @@ -1105,7 +1108,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, - inherited_multi_agent_version, + /*parent_multi_agent_version*/ None, /*forked_from_thread_id*/ None, thread_source, Vec::new(), @@ -1127,7 +1130,7 @@ impl ThreadManagerState { initial_history: InitialHistory, agent_control: AgentControl, session_source: SessionSource, - inherited_multi_agent_version: Option, + parent_multi_agent_version: Option, thread_source: Option, forked_from_thread_id: Option, persist_extended_history: bool, @@ -1144,7 +1147,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, - inherited_multi_agent_version, + parent_multi_agent_version, forked_from_thread_id, thread_source, Vec::new(), @@ -1176,13 +1179,14 @@ impl ThreadManagerState { environments: Vec, user_shell_override: Option, ) -> CodexResult { + let parent_multi_agent_version = initial_history.get_multi_agent_version(); Box::pin(self.spawn_thread_with_source( config, initial_history, auth_manager, agent_control, self.session_source.clone(), - /*inherited_multi_agent_version*/ None, + parent_multi_agent_version, forked_from_thread_id, thread_source, dynamic_tools, @@ -1205,7 +1209,7 @@ impl ThreadManagerState { auth_manager: Arc, agent_control: AgentControl, session_source: SessionSource, - inherited_multi_agent_version: Option, + parent_multi_agent_version: Option, forked_from_thread_id: Option, thread_source: Option, dynamic_tools: Vec, @@ -1250,7 +1254,7 @@ impl ThreadManagerState { } = Codex::spawn(CodexSpawnArgs { config, installation_id: self.installation_id.clone(), - inherited_multi_agent_version, + parent_multi_agent_version, auth_manager, models_manager: Arc::clone(&self.models_manager), environment_manager: Arc::clone(&self.environment_manager), diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 1eff4d1956..0a9d3094a0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -192,7 +192,6 @@ async fn try_resume_closed_agent( /*agent_role*/ None, /*task_name*/ None, )?, - turn.multi_agent_version, )) .await .map(|_| ()) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index ede2270d05..f19dc32865 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -32,6 +32,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::models::SandboxEnforcement; +use codex_protocol::openai_models::MultiAgentVersion; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::AskForApproval; @@ -382,6 +383,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { .expect("test config should allow feature update"); let turn = TurnContext { config: Arc::new(config), + multi_agent_version: Some(MultiAgentVersion::V2), ..turn }; @@ -425,6 +427,7 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -892,6 +895,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let manager = thread_manager(); let root = manager .start_thread((*turn.config).clone()) @@ -959,6 +963,7 @@ async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { .expect("test config should allow feature update"); let turn = TurnContext { config: Arc::new(config), + multi_agent_version: Some(MultiAgentVersion::V2), ..turn }; @@ -1041,6 +1046,7 @@ async fn multi_agent_v2_spawn_requires_task_name() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let invocation = invocation( Arc::new(session), @@ -1075,6 +1081,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_items_field() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let invocation = invocation( Arc::new(session), @@ -1135,6 +1142,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1232,6 +1240,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1272,6 +1281,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1312,6 +1322,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let err = SpawnAgentHandlerV2::default() .handle(invocation( @@ -1352,6 +1363,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -1428,6 +1440,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let child_path = AgentPath::try_from("/root/worker").expect("agent path"); let child_thread_id = session @@ -1506,6 +1519,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1600,6 +1614,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); turn.config = Arc::new(config.clone()); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path"); let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path"); @@ -1687,6 +1702,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { let mut config = (*turn.config).clone(); let _ = config.features.enable(Feature::MultiAgentV2); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1751,6 +1767,7 @@ async fn multi_agent_v2_send_message_rejects_legacy_items_field() { let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1807,6 +1824,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() { let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1880,6 +1898,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2015,6 +2034,7 @@ async fn multi_agent_v2_followup_task_rejects_legacy_items_field() { let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2068,6 +2088,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { let mut config = turn.config.as_ref().clone(); let _ = config.features.enable(Feature::MultiAgentV2); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2148,6 +2169,7 @@ async fn multi_agent_v2_spawn_omits_agent_id_when_named() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let output = SpawnAgentHandlerV2::default() .handle(invocation( @@ -2187,6 +2209,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let invocation = invocation( Arc::new(session), @@ -2397,6 +2420,7 @@ async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() { session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let parent_path = AgentPath::try_from("/root/parent").expect("agent path"); turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, @@ -2871,6 +2895,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2956,6 +2981,7 @@ async fn multi_agent_v2_wait_agent_rejects_timeout_below_configured_min() { config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let Err(err) = WaitAgentHandlerV2::default() .handle(invocation( @@ -2986,6 +3012,7 @@ async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_min() config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let output = WaitAgentHandlerV2::default() .handle(invocation( @@ -3021,6 +3048,7 @@ async fn multi_agent_v2_wait_agent_uses_configured_default_timeout() { config.multi_agent_v2.max_wait_timeout_ms = 1_000; config.multi_agent_v2.default_wait_timeout_ms = 50; turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3076,6 +3104,7 @@ async fn multi_agent_v2_wait_agent_allows_zero_configured_timeout() { config.multi_agent_v2.max_wait_timeout_ms = 0; config.multi_agent_v2.default_wait_timeout_ms = 0; turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3116,6 +3145,7 @@ async fn multi_agent_v2_wait_agent_rejects_timeout_above_configured_max() { config.multi_agent_v2.max_wait_timeout_ms = 50; config.multi_agent_v2.default_wait_timeout_ms = 1; turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let Err(err) = WaitAgentHandlerV2::default() .handle(invocation( @@ -3146,6 +3176,7 @@ async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_max() config.multi_agent_v2.max_wait_timeout_ms = 1; config.multi_agent_v2.default_wait_timeout_ms = 1; turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let output = WaitAgentHandlerV2::default() .handle(invocation( @@ -3354,6 +3385,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3448,6 +3480,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3529,6 +3562,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3620,6 +3654,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3709,6 +3744,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3782,6 +3818,7 @@ async fn multi_agent_v2_close_agent_reaps_stale_task_name_target() { session.services.agent_control = manager.agent_control(); session.conversation_id = root.thread_id; turn.config = Arc::new(config.clone()); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); @@ -3888,6 +3925,7 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); turn.config = Arc::new(config); + turn.multi_agent_version = Some(MultiAgentVersion::V2); let session = Arc::new(session); let turn = Arc::new(turn); diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 7a0d2b6f7e..24474065cb 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -888,7 +888,7 @@ async fn multi_agent_version_selector_overrides_feature_flags() { &turn.model_info, turn.config.as_ref(), &turn.session_source, - /*inherited_multi_agent_version*/ None, + /*multi_agent_version*/ None, ); }) .await; @@ -909,7 +909,7 @@ async fn multi_agent_version_selector_overrides_feature_flags() { &turn.model_info, turn.config.as_ref(), &turn.session_source, - /*inherited_multi_agent_version*/ None, + /*multi_agent_version*/ None, ); }) .await; diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 9cee89d761..b091c79410 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -71,6 +71,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R agent_nickname: None, agent_role: None, model_provider: None, + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, @@ -119,6 +120,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re agent_nickname: None, agent_role: None, model_provider: None, + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 48a648d1bc..40b702783a 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -186,6 +186,7 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> /*forked_from_id*/ None, SessionSource::Exec, /*thread_source*/ None, + /*multi_agent_version*/ None, BaseInstructions::default(), Vec::new(), ), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 85c1e08c84..74557f56e1 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -221,6 +221,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { agent_nickname: None, agent_role: None, model_provider: None, + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3c0b5cb462..cfa712c19b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -43,6 +43,7 @@ use crate::models::ResponseItem; use crate::models::SandboxEnforcement; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; +use crate::openai_models::MultiAgentVersion; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; @@ -53,6 +54,7 @@ use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; use serde_json::Value; use serde_with::serde_as; @@ -2451,6 +2453,32 @@ impl InitialHistory { } } } + + pub fn get_multi_agent_version(&self) -> Option { + match self { + InitialHistory::New | InitialHistory::Cleared => None, + InitialHistory::Resumed(resumed) => resumed + .history + .iter() + .find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) + if meta_line.meta.id == resumed.conversation_id => + { + Some(meta_line.meta.multi_agent_version) + } + _ => None, + }) + .flatten(), + InitialHistory::Forked(items) => items + .iter() + .rev() + .find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.multi_agent_version), + _ => None, + }) + .flatten(), + } + } } fn session_cwd_from_items(items: &[RolloutItem]) -> Option { @@ -2687,6 +2715,13 @@ pub struct SessionMeta { #[serde(skip_serializing_if = "Option::is_none")] pub agent_path: Option, pub model_provider: Option, + /// Thread-scoped multi-agent runtime selector. + #[serde( + default, + deserialize_with = "deserialize_optional_multi_agent_version", + skip_serializing_if = "Option::is_none" + )] + pub multi_agent_version: Option, /// base_instructions for the session. This *should* always be present when creating a new session, /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions /// from ModelsManager. @@ -2697,6 +2732,18 @@ pub struct SessionMeta { pub memory_mode: Option, } +fn deserialize_optional_multi_agent_version<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + Ok(serde_json::from_value(Value::String(value)).ok()) +} + impl Default for SessionMeta { fn default() -> Self { SessionMeta { @@ -2712,6 +2759,7 @@ impl Default for SessionMeta { agent_role: None, agent_path: None, model_provider: None, + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, @@ -5441,4 +5489,22 @@ mod tests { assert_eq!(info.model_context_window, Some(258_400)); } + + #[test] + fn session_meta_treats_unknown_multi_agent_version_as_omitted() -> Result<()> { + let mut value = serde_json::to_value(SessionMeta::default())?; + value["multi_agent_version"] = json!("v3"); + + let meta: SessionMeta = serde_json::from_value(value)?; + + assert_eq!(meta.multi_agent_version, None); + assert_eq!( + serde_json::to_value(meta)? + .as_object() + .expect("session meta should serialize as an object") + .get("multi_agent_version"), + None + ); + Ok(()) + } } diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index d4f0677898..d53573f817 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -22,6 +22,7 @@ use crate::protocol::EventMsg; use crate::state_db; use codex_file_search as file_search; use codex_protocol::ThreadId; +use codex_protocol::openai_models::MultiAgentVersion; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMetaLine; @@ -68,6 +69,8 @@ pub struct ThreadItem { pub agent_role: Option, /// Model provider from session metadata. pub model_provider: Option, + /// Thread-scoped multi-agent runtime selector. + pub multi_agent_version: Option, /// CLI version from session metadata. pub cli_version: Option, /// RFC3339 timestamp string for when the session was created, if available. @@ -98,6 +101,7 @@ struct HeadTailSummary { agent_nickname: Option, agent_role: Option, model_provider: Option, + multi_agent_version: Option, cli_version: Option, created_at: Option, updated_at: Option, @@ -781,6 +785,7 @@ async fn build_thread_item( agent_nickname, agent_role, model_provider, + multi_agent_version, cli_version, created_at, updated_at: mut summary_updated_at, @@ -802,6 +807,7 @@ async fn build_thread_item( agent_nickname, agent_role, model_provider, + multi_agent_version, cli_version, created_at, updated_at: summary_updated_at, @@ -1104,6 +1110,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result, source: SessionSource, thread_source: Option, + multi_agent_version: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, }, @@ -158,6 +160,7 @@ impl RolloutRecorderParams { forked_from_id: Option, source: SessionSource, thread_source: Option, + multi_agent_version: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, ) -> Self { @@ -166,6 +169,7 @@ impl RolloutRecorderParams { forked_from_id, source, thread_source, + multi_agent_version, base_instructions, dynamic_tools, } @@ -654,6 +658,7 @@ impl RolloutRecorder { forked_from_id, source, thread_source, + multi_agent_version, base_instructions, dynamic_tools, } => { @@ -683,6 +688,7 @@ impl RolloutRecorder { source, thread_source, model_provider: Some(config.model_provider_id().to_string()), + multi_agent_version, base_instructions: Some(base_instructions), dynamic_tools: if dynamic_tools.is_empty() { None @@ -1023,6 +1029,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt agent_nickname, agent_role, model_provider, + multi_agent_version, cli_version, created_at, updated_at, @@ -1058,6 +1065,9 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.model_provider.is_none() { item.model_provider = model_provider; } + if item.multi_agent_version.is_none() { + item.multi_agent_version = multi_agent_version; + } if item.cli_version.is_none() { item.cli_version = cli_version; } @@ -1693,6 +1703,7 @@ fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadI agent_nickname: item.agent_nickname, agent_role: item.agent_role, model_provider: Some(item.model_provider), + multi_agent_version: item.multi_agent_version, cli_version: Some(item.cli_version), created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 371155e041..29d3137090 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -95,6 +95,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { agent_nickname: None, agent_role: None, model_provider: None, + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, @@ -371,6 +372,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< /*forked_from_id*/ None, SessionSource::Exec, /*thread_source*/ None, + /*multi_agent_version*/ None, BaseInstructions::default(), Vec::new(), ), @@ -451,6 +453,7 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i /*forked_from_id*/ None, SessionSource::Exec, /*thread_source*/ None, + /*multi_agent_version*/ None, BaseInstructions::default(), Vec::new(), ), @@ -977,6 +980,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi agent_nickname: None, agent_role: None, model_provider: None, + multi_agent_version: None, cli_version: None, created_at: None, updated_at: None, @@ -994,6 +998,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi agent_nickname: Some("state-agent".to_string()), agent_role: Some("state-role".to_string()), model_provider: Some("state-provider".to_string()), + multi_agent_version: None, cli_version: Some("state-version".to_string()), created_at: Some("2025-01-03T16:00:00Z".to_string()), updated_at: Some("2025-01-03T16:01:02.003Z".to_string()), diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 757b08b4d4..26a541d4f9 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -37,6 +37,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res agent_nickname: None, agent_role: None, model_provider: Some("test-provider".into()), + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index a8bfc0cd84..6f668cb79f 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -609,6 +609,7 @@ async fn test_list_conversations_latest_first() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some("2025-01-03T12-00-00".into()), updated_at: updated_times.first().cloned().flatten(), @@ -626,6 +627,7 @@ async fn test_list_conversations_latest_first() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some("2025-01-02T12-00-00".into()), updated_at: updated_times.get(1).cloned().flatten(), @@ -643,6 +645,7 @@ async fn test_list_conversations_latest_first() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some("2025-01-01T12-00-00".into()), updated_at: updated_times.get(2).cloned().flatten(), @@ -753,6 +756,7 @@ async fn test_pagination_cursor() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some("2025-03-05T09-00-00".into()), updated_at: updated_page1.first().cloned().flatten(), @@ -770,6 +774,7 @@ async fn test_pagination_cursor() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some("2025-03-04T09-00-00".into()), updated_at: updated_page1.get(1).cloned().flatten(), @@ -823,6 +828,7 @@ async fn test_pagination_cursor() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some("2025-03-03T09-00-00".into()), updated_at: updated_page2.first().cloned().flatten(), @@ -840,6 +846,7 @@ async fn test_pagination_cursor() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some("2025-03-02T09-00-00".into()), updated_at: updated_page2.get(1).cloned().flatten(), @@ -885,6 +892,7 @@ async fn test_pagination_cursor() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some("2025-03-01T09-00-00".into()), updated_at: updated_page3.first().cloned().flatten(), @@ -1055,6 +1063,7 @@ async fn test_get_thread_contents() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some(ts.into()), updated_at: page.items[0].updated_at.clone(), @@ -1261,6 +1270,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { agent_nickname: None, agent_role: None, model_provider: Some("test-provider".into()), + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, @@ -1406,6 +1416,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), updated_at: updated_page1.first().cloned().flatten(), @@ -1423,6 +1434,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), + multi_agent_version: None, cli_version: Some("test_version".to_string()), created_at: Some(ts.to_string()), updated_at: updated_page1.get(1).cloned().flatten(), diff --git a/codex-rs/state/migrations/0036_threads_multi_agent_version.sql b/codex-rs/state/migrations/0036_threads_multi_agent_version.sql new file mode 100644 index 0000000000..f28991d323 --- /dev/null +++ b/codex-rs/state/migrations/0036_threads_multi_agent_version.sql @@ -0,0 +1 @@ +ALTER TABLE threads ADD COLUMN multi_agent_version TEXT; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 5f0734e330..8384c00d6b 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -54,6 +54,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi metadata.agent_nickname = meta_line.meta.agent_nickname.clone(); metadata.agent_role = meta_line.meta.agent_role.clone(); metadata.agent_path = meta_line.meta.agent_path.clone(); + metadata.multi_agent_version = meta_line.meta.multi_agent_version; if let Some(provider) = meta_line.meta.model_provider.as_deref() { metadata.model_provider = provider.to_string(); } @@ -330,6 +331,7 @@ mod tests { agent_nickname: None, agent_role: None, model_provider: Some("openai".to_string()), + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, @@ -452,6 +454,7 @@ mod tests { agent_nickname: None, agent_role: None, model_provider: Some("openai".to_string()), + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, @@ -481,6 +484,7 @@ mod tests { model_provider: "openai".to_string(), model: None, reasoning_effort: None, + multi_agent_version: None, cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), title: String::new(), diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index f0b8d315e5..dbfca14f4a 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -2,11 +2,13 @@ use anyhow::Result; use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; +use codex_protocol::openai_models::MultiAgentVersion; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; +use serde_json::Value; use sqlx::Row; use sqlx::sqlite::SqliteRow; use std::path::PathBuf; @@ -83,6 +85,8 @@ pub struct ThreadMetadata { pub model: Option, /// The latest observed reasoning effort for the thread. pub reasoning_effort: Option, + /// Thread-scoped multi-agent runtime selector. + pub multi_agent_version: Option, /// The working directory for the thread. pub cwd: PathBuf, /// Version of the CLI that created the thread. @@ -132,6 +136,8 @@ pub struct ThreadMetadataBuilder { pub agent_path: Option, /// The model provider identifier, if known. pub model_provider: Option, + /// Thread-scoped multi-agent runtime selector. + pub multi_agent_version: Option, /// The working directory for the thread. pub cwd: PathBuf, /// Version of the CLI that created the thread. @@ -169,6 +175,7 @@ impl ThreadMetadataBuilder { agent_role: None, agent_path: None, model_provider: None, + multi_agent_version: None, cwd: PathBuf::new(), cli_version: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), @@ -209,6 +216,7 @@ impl ThreadMetadataBuilder { .unwrap_or_else(|| default_provider.to_string()), model: None, reasoning_effort: None, + multi_agent_version: self.multi_agent_version, cwd: self.cwd.clone(), cli_version: self.cli_version.clone().unwrap_or_default(), title: String::new(), @@ -275,6 +283,9 @@ impl ThreadMetadata { if self.reasoning_effort != other.reasoning_effort { diffs.push("reasoning_effort"); } + if self.multi_agent_version != other.multi_agent_version { + diffs.push("multi_agent_version"); + } if self.cwd != other.cwd { diffs.push("cwd"); } @@ -333,6 +344,7 @@ pub(crate) struct ThreadRow { model_provider: String, model: Option, reasoning_effort: Option, + multi_agent_version: Option, cwd: String, cli_version: String, title: String, @@ -362,6 +374,7 @@ impl ThreadRow { model_provider: row.try_get("model_provider")?, model: row.try_get("model")?, reasoning_effort: row.try_get("reasoning_effort")?, + multi_agent_version: row.try_get("multi_agent_version")?, cwd: row.try_get("cwd")?, cli_version: row.try_get("cli_version")?, title: row.try_get("title")?, @@ -395,6 +408,7 @@ impl TryFrom for ThreadMetadata { model_provider, model, reasoning_effort, + multi_agent_version, cwd, cli_version, title, @@ -426,6 +440,8 @@ impl TryFrom for ThreadMetadata { model, reasoning_effort: reasoning_effort .and_then(|value| value.parse::().ok()), + multi_agent_version: multi_agent_version + .and_then(|value| serde_json::from_value(Value::String(value)).ok()), cwd: PathBuf::from(cwd), cli_version, title, @@ -512,6 +528,7 @@ mod tests { model_provider: "openai".to_string(), model: Some("gpt-5".to_string()), reasoning_effort: reasoning_effort.map(str::to_string), + multi_agent_version: None, cwd: "/tmp/workspace".to_string(), cli_version: "0.0.0".to_string(), title: String::new(), @@ -542,6 +559,7 @@ mod tests { model_provider: "openai".to_string(), model: Some("gpt-5".to_string()), reasoning_effort, + multi_agent_version: None, cwd: PathBuf::from("/tmp/workspace"), cli_version: "0.0.0".to_string(), title: String::new(), diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 3101500796..227d99e938 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -177,6 +177,7 @@ SELECT threads.agent_nickname, threads.agent_role, threads.model_provider, + threads.multi_agent_version, threads.model, threads.reasoning_effort, threads.cwd, @@ -547,6 +548,7 @@ SELECT threads.agent_role, threads.agent_path, threads.model_provider, + threads.multi_agent_version, threads.model, threads.reasoning_effort, threads.cwd, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 848a193335..e92fd0fb42 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -55,6 +55,7 @@ pub(super) fn test_thread_metadata( model_provider: "test-provider".to_string(), model: Some("gpt-5".to_string()), reasoning_effort: Some(ReasoningEffort::Medium), + multi_agent_version: None, cwd, cli_version: "0.0.0".to_string(), title: String::new(), diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index c67056ab54..46cceadf87 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -18,6 +18,7 @@ SELECT threads.agent_role, threads.agent_path, threads.model_provider, + threads.multi_agent_version, threads.model, threads.reasoning_effort, threads.cwd, @@ -491,6 +492,7 @@ INSERT INTO threads ( agent_role, agent_path, model_provider, + multi_agent_version, model, reasoning_effort, cwd, @@ -507,7 +509,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -527,6 +529,12 @@ ON CONFLICT(id) DO NOTHING .bind(metadata.agent_role.as_deref()) .bind(metadata.agent_path.as_deref()) .bind(metadata.model_provider.as_str()) + .bind( + metadata + .multi_agent_version + .as_ref() + .map(crate::extract::enum_to_string), + ) .bind(metadata.model.as_deref()) .bind( metadata @@ -697,6 +705,7 @@ INSERT INTO threads ( agent_role, agent_path, model_provider, + multi_agent_version, model, reasoning_effort, cwd, @@ -713,7 +722,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, @@ -726,6 +735,7 @@ ON CONFLICT(id) DO UPDATE SET agent_role = excluded.agent_role, agent_path = excluded.agent_path, model_provider = excluded.model_provider, + multi_agent_version = excluded.multi_agent_version, model = excluded.model, reasoning_effort = excluded.reasoning_effort, cwd = excluded.cwd, @@ -759,6 +769,12 @@ ON CONFLICT(id) DO UPDATE SET .bind(metadata.agent_role.as_deref()) .bind(metadata.agent_path.as_deref()) .bind(metadata.model_provider.as_str()) + .bind( + metadata + .multi_agent_version + .as_ref() + .map(crate::extract::enum_to_string), + ) .bind(metadata.model.as_deref()) .bind( metadata @@ -929,6 +945,7 @@ SELECT threads.agent_role, threads.agent_path, threads.model_provider, + threads.multi_agent_version, threads.model, threads.reasoning_effort, threads.cwd, @@ -1334,6 +1351,7 @@ mod tests { agent_nickname: None, agent_role: None, model_provider: None, + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: Some("polluted".to_string()), @@ -1393,6 +1411,7 @@ mod tests { agent_nickname: None, agent_role: None, model_provider: None, + multi_agent_version: None, base_instructions: None, dynamic_tools: None, memory_mode: None, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 5956b3dc41..c08d810427 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -337,6 +337,9 @@ fn stored_thread_from_state( .unwrap_or_else(|| "test".to_string()), model: metadata.and_then(|metadata| metadata.model.clone()), reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort), + multi_agent_version: metadata + .and_then(|metadata| metadata.multi_agent_version) + .unwrap_or(created.metadata.multi_agent_version), created_at: metadata .and_then(|metadata| metadata.created_at) .unwrap_or_else(Utc::now), diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 56882e4cba..d6ee405ad2 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -32,6 +32,7 @@ pub(super) async fn create_thread( params.forked_from_id, params.source, params.thread_source, + params.metadata.multi_agent_version, params.base_instructions, params.dynamic_tools, ), diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index bfdc94fbf0..7d9db73532 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -128,6 +128,7 @@ pub(super) fn stored_thread_from_rollout_item( .unwrap_or_else(|| default_provider.to_string()), model: None, reasoning_effort: None, + multi_agent_version: item.multi_agent_version, created_at, updated_at, archived_at, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 7cb2ff827f..7d89a9229c 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -296,6 +296,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; + use codex_protocol::openai_models::MultiAgentVersion; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -360,6 +361,53 @@ mod tests { ); } + #[tokio::test] + async fn live_thread_persists_multi_agent_version_in_rollout_and_sqlite() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let mut params = create_thread_params(thread_id); + params.metadata.multi_agent_version = Some(MultiAgentVersion::V2); + let live_thread = LiveThread::create(store.clone(), params) + .await + .expect("create live thread"); + + live_thread + .append_items(&[user_message_item("persist selector")]) + .await + .expect("append live item"); + live_thread.flush().await.expect("flush live thread"); + + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + let stored_thread = store + .read_thread(ReadThreadParams { + thread_id, + include_archived: false, + include_history: true, + }) + .await + .expect("stored thread read"); + + assert_eq!( + ( + metadata.multi_agent_version, + stored_thread.multi_agent_version + ), + (Some(MultiAgentVersion::V2), Some(MultiAgentVersion::V2)) + ); + } + #[tokio::test] async fn raw_append_items_does_not_update_sqlite_metadata() { // This pins the ThreadStore contract: raw appends are history-only. Callers that need @@ -537,6 +585,7 @@ mod tests { metadata: ThreadPersistenceMetadata { cwd: Some(home.path().to_path_buf()), model_provider: "different-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Enabled, }, event_persistence_mode: ThreadEventPersistenceMode::Limited, @@ -592,6 +641,7 @@ mod tests { metadata: ThreadPersistenceMetadata { cwd: Some(home.path().to_path_buf()), model_provider: "different-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Enabled, }, event_persistence_mode: ThreadEventPersistenceMode::Limited, @@ -806,6 +856,7 @@ mod tests { metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Enabled, }, event_persistence_mode: ThreadEventPersistenceMode::Limited, @@ -1036,6 +1087,7 @@ mod tests { ThreadPersistenceMetadata { cwd: Some(std::env::current_dir().expect("cwd")), model_provider: "test-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Enabled, } } diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 718527e7ec..a9d305e9dc 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -281,6 +281,10 @@ async fn stored_thread_from_sqlite_metadata( .ok() .map(|meta_line| meta_line.meta); let forked_from_id = session_meta.as_ref().and_then(|meta| meta.forked_from_id); + let multi_agent_version = session_meta + .as_ref() + .map(|meta| meta.multi_agent_version) + .unwrap_or(metadata.multi_agent_version); let preview = metadata .preview .clone() @@ -299,6 +303,7 @@ async fn stored_thread_from_sqlite_metadata( }, model: metadata.model, reasoning_effort: metadata.reasoning_effort, + multi_agent_version, created_at: metadata.created_at, updated_at: metadata.updated_at, archived_at: metadata.archived_at, @@ -365,6 +370,7 @@ fn stored_thread_from_meta_line( .unwrap_or_else(|| store.config.default_model_provider_id.clone()), model: None, reasoning_effort: None, + multi_agent_version: meta_line.meta.multi_agent_version, created_at, updated_at, archived_at: archived.then_some(updated_at), diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 612328f4c7..ef5bfd7c5d 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -212,6 +212,7 @@ async fn apply_metadata_update( patch.source.clone().unwrap_or(SessionSource::Unknown), ); builder.model_provider = patch.model_provider.clone(); + builder.multi_agent_version = patch.multi_agent_version.flatten(); builder.thread_source = patch.thread_source.flatten(); builder.agent_nickname = patch.agent_nickname.clone().flatten(); builder.agent_role = patch.agent_role.clone().flatten(); @@ -245,6 +246,9 @@ async fn apply_metadata_update( if let Some(reasoning_effort) = patch.reasoning_effort { metadata.reasoning_effort = Some(reasoning_effort); } + if let Some(multi_agent_version) = patch.multi_agent_version { + metadata.multi_agent_version = multi_agent_version; + } if let Some(created_at) = patch.created_at { metadata.created_at = created_at; } @@ -373,6 +377,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.model_provider.is_some() || patch.model.is_some() || patch.reasoning_effort.is_some() + || patch.multi_agent_version.is_some() || patch.created_at.is_some() || patch.source.is_some() || patch.thread_source.is_some() @@ -1574,6 +1579,7 @@ mod tests { ThreadPersistenceMetadata { cwd: Some(std::env::current_dir().expect("cwd")), model_provider: "test-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Enabled, } } diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index d25a0e65a2..cf8dc65320 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -63,6 +63,7 @@ impl ThreadMetadataSync { }; let update = ThreadMetadataPatch { model_provider: Some(params.metadata.model_provider.clone()), + multi_agent_version: Some(params.metadata.multi_agent_version), created_at: Some(created_at), updated_at: Some(created_at), source: Some(params.source.clone()), @@ -205,6 +206,7 @@ impl ThreadMetadataSync { update.agent_nickname = Some(meta_line.meta.agent_nickname.clone()); update.agent_role = Some(meta_line.meta.agent_role.clone()); update.agent_path = Some(meta_line.meta.agent_path.clone()); + update.multi_agent_version = Some(meta_line.meta.multi_agent_version); if let Some(model_provider) = meta_line.meta.model_provider.clone() && !model_provider.is_empty() { @@ -527,6 +529,7 @@ mod tests { metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), + multi_agent_version: None, memory_mode: ThreadMemoryMode::Enabled, }, event_persistence_mode: ThreadEventPersistenceMode::Limited, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 1e26e5dc77..d9706e64d1 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -5,6 +5,7 @@ use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; +use codex_protocol::openai_models::MultiAgentVersion; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GitInfo; @@ -61,6 +62,8 @@ pub struct ThreadPersistenceMetadata { pub cwd: Option, /// Model provider associated with the thread. pub model_provider: String, + /// Thread-scoped multi-agent runtime selector. + pub multi_agent_version: Option, /// Memory mode associated with the live thread. pub memory_mode: MemoryMode, } @@ -372,6 +375,8 @@ pub struct StoredThread { pub model: Option, /// Latest observed reasoning effort, if known. pub reasoning_effort: Option, + /// Thread-scoped multi-agent runtime selector. + pub multi_agent_version: Option, /// Thread creation timestamp. pub created_at: DateTime, /// Thread last-update timestamp. @@ -479,6 +484,13 @@ pub struct ThreadMetadataPatch { pub model: Option, /// Latest observed reasoning effort. pub reasoning_effort: Option, + /// Thread-scoped multi-agent runtime selector. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub multi_agent_version: ClearableField, /// Creation timestamp when known. pub created_at: Option>, /// Last update timestamp for this metadata observation. @@ -559,6 +571,9 @@ impl ThreadMetadataPatch { if next.reasoning_effort.is_some() { self.reasoning_effort = next.reasoning_effort; } + if next.multi_agent_version.is_some() { + self.multi_agent_version = next.multi_agent_version; + } if next.created_at.is_some() { self.created_at = next.created_at; } @@ -616,6 +631,7 @@ impl ThreadMetadataPatch { && self.model_provider.is_none() && self.model.is_none() && self.reasoning_effort.is_none() + && self.multi_agent_version.is_none() && self.created_at.is_none() && self.updated_at.is_none() && self.source.is_none()