diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b870b5b17c..1659fdd03d 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 2a4b2722ec..beea7856a2 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/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 d60484d2e7..56b0033956 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/session.rs b/codex-rs/core/src/session/session.rs index c9e1807fe6..1b06b2470e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -559,6 +559,7 @@ impl Session { 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 { @@ -581,6 +582,7 @@ impl Session { 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 { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index aa2586cae4..34ee6e3286 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3456,6 +3456,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 { @@ -5951,6 +5952,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 { 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 4d9c89638e..8e4ef48236 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -222,6 +222,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/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index b9fa9c933d..087d9f5785 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -237,6 +237,13 @@ pub enum ToolMode { CodeModeOnly, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MultiAgentVersion { + V1, + V2, +} + fn deserialize_optional_model_selector<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c83c492e72..8411588f34 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; @@ -2461,6 +2463,33 @@ impl InitialHistory { } } } + + pub fn get_multi_agent_version(&self) -> Option { + match self { + InitialHistory::New | InitialHistory::Cleared => None, + InitialHistory::Resumed(resumed) => resumed + .history + .iter() + .rev() + .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 { @@ -2734,6 +2763,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. @@ -2744,6 +2780,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 { @@ -2759,6 +2807,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, @@ -5488,4 +5537,62 @@ 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(()) + } + + #[test] + fn resumed_history_uses_latest_matching_multi_agent_version() { + let thread_id = ThreadId::new(); + let unrelated_thread_id = ThreadId::new(); + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: vec![ + RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + ..Default::default() + }, + git: None, + }), + RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: unrelated_thread_id, + multi_agent_version: Some(MultiAgentVersion::V1), + ..Default::default() + }, + git: None, + }), + RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + multi_agent_version: Some(MultiAgentVersion::V2), + ..Default::default() + }, + git: None, + }), + ], + rollout_path: None, + }); + + assert_eq!( + initial_history.get_multi_agent_version(), + Some(MultiAgentVersion::V2) + ); + } } 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 0094e362e5..1e0bfc9279 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(); } @@ -332,6 +333,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, @@ -489,6 +491,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, @@ -518,6 +521,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 e005a0e94d..8d287bce61 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, @@ -1328,6 +1345,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()), @@ -1387,6 +1405,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 5dade3a268..fe6ef88c31 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 75120eb56f..b7abb8954f 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -130,6 +130,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..252bd07dbd 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -296,8 +296,11 @@ 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::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::UserMessageEvent; @@ -360,6 +363,93 @@ 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 live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id)) + .await + .expect("create live thread"); + + live_thread + .append_items(&[user_message_item("persist selector")]) + .await + .expect("append live item"); + live_thread + .append_items(&[RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + source: SessionSource::Exec, + multi_agent_version: Some(MultiAgentVersion::V2), + ..Default::default() + }, + git: None, + })]) + .await + .expect("append selector update"); + live_thread.flush().await.expect("flush live thread"); + let rollout_path = store + .live_rollout_path(thread_id) + .await + .expect("load rollout path"); + + 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: false, + }) + .await + .expect("stored thread read"); + let (items, _, _) = RolloutRecorder::load_rollout_items(rollout_path.as_path()) + .await + .expect("load rollout items"); + let persisted_versions = items + .iter() + .filter_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.multi_agent_version), + _ => None, + }) + .collect::>(); + let jsonl_only_thread = + LocalThreadStore::new(test_config(home.path()), /*state_db*/ None) + .read_thread_by_rollout_path( + rollout_path, + /*include_archived*/ false, + /*include_history*/ true, + ) + .await + .expect("jsonl-only stored thread read"); + + assert_eq!( + ( + persisted_versions, + metadata.multi_agent_version, + stored_thread.multi_agent_version, + jsonl_only_thread.multi_agent_version + ), + ( + vec![None, Some(MultiAgentVersion::V2)], + Some(MultiAgentVersion::V2), + 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 +627,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 +683,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 +898,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 +1129,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 767dbda6c0..9ab898eaed 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -2,6 +2,7 @@ use chrono::DateTime; use chrono::Utc; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_rollout::RolloutRecorder; @@ -59,6 +60,7 @@ pub(super) async fn read_thread( rollout_thread.name = thread.name; } rollout_thread.git_info = thread.git_info; + rollout_thread.multi_agent_version = thread.multi_agent_version; rollout_thread.permission_profile = permission_profile_from_metadata_value( &metadata_sandbox_policy, rollout_thread.cwd.as_path(), @@ -129,6 +131,9 @@ pub(super) async fn read_thread_by_rollout_path( metadata.git_branch.or(fallback_branch), metadata.git_origin_url.or(fallback_origin_url), ); + if metadata.multi_agent_version.is_some() { + thread.multi_agent_version = metadata.multi_agent_version; + } } attach_history_if_requested(&mut thread, include_history).await?; Ok(thread) @@ -162,6 +167,14 @@ async fn attach_history_if_requested( }); }; let items = load_history_items(&path).await?; + if let Some(multi_agent_version) = items.iter().rev().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) if meta_line.meta.id == thread_id => { + Some(meta_line.meta.multi_agent_version) + } + _ => None, + }) { + thread.multi_agent_version = multi_agent_version; + } thread.history = Some(StoredThreadHistory { thread_id, items }); Ok(()) } @@ -287,6 +300,11 @@ 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 = metadata.multi_agent_version.or_else(|| { + session_meta + .as_ref() + .and_then(|meta| meta.multi_agent_version) + }); let preview = metadata .preview .clone() @@ -307,6 +325,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, @@ -370,6 +389,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 3dd0b39a30..0b242c0c72 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -213,6 +213,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(); @@ -246,6 +247,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; } @@ -374,6 +378,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() @@ -1615,6 +1620,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 3e83d2f48b..e97318dcf0 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()), @@ -178,7 +179,10 @@ impl ThreadMetadataSync { self.observe_items_with_update( items, ThreadMetadataPatch { - updated_at: Some(Utc::now()), + updated_at: items + .iter() + .any(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .then(Utc::now), ..Default::default() }, ) @@ -205,6 +209,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() { @@ -345,6 +350,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.model_provider.is_some() || update.model.is_some() || update.reasoning_effort.is_some() + || update.multi_agent_version.is_some() || update.created_at.is_some() || update.source.is_some() || update.thread_source.is_some() @@ -371,6 +377,7 @@ fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch { #[cfg(test)] mod tests { + use codex_protocol::openai_models::MultiAgentVersion; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; @@ -494,6 +501,23 @@ mod tests { ); } + #[test] + fn session_metadata_only_append_does_not_touch_updated_at() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); + let mut session_meta = session_meta(thread_id); + session_meta.meta.multi_agent_version = Some(MultiAgentVersion::V2); + + let update = sync + .observe_appended_items(&[RolloutItem::SessionMeta(session_meta)]) + .expect("session metadata should update selector"); + + assert_eq!( + (update.patch.multi_agent_version, update.patch.updated_at,), + (Some(Some(MultiAgentVersion::V2)), None) + ); + } + #[test] fn resume_history_waits_for_append_before_flushing_metadata() { let thread_id = ThreadId::new(); @@ -527,6 +551,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 61763f10f8..ac29234086 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -6,6 +6,7 @@ use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; use codex_protocol::models::PermissionProfile; +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()