codex: persist multi-agent version metadata

This commit is contained in:
Ahmed Ibrahim
2026-05-29 15:33:17 -07:00
parent a076b21730
commit 88f173945d
37 changed files with 386 additions and 3 deletions
@@ -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,
@@ -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,
@@ -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,
@@ -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(),
@@ -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(),
@@ -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,
@@ -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(),
@@ -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,
@@ -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()
+2
View File
@@ -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 {
+2
View File
@@ -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 {
@@ -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,
@@ -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(),
),
@@ -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,
+7
View File
@@ -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<Option<T>, D::Error>
where
D: Deserializer<'de>,
+107
View File
@@ -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<MultiAgentVersion> {
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<PathBuf> {
@@ -2734,6 +2763,13 @@ pub struct SessionMeta {
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_path: Option<String>,
pub model_provider: Option<String>,
/// 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<MultiAgentVersion>,
/// 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<String>,
}
fn deserialize_optional_multi_agent_version<'de, D>(
deserializer: D,
) -> Result<Option<MultiAgentVersion>, D::Error>
where
D: Deserializer<'de>,
{
let Some(value) = Option::<String>::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)
);
}
}
+7
View File
@@ -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<String>,
/// Model provider from session metadata.
pub model_provider: Option<String>,
/// Thread-scoped multi-agent runtime selector.
pub multi_agent_version: Option<MultiAgentVersion>,
/// CLI version from session metadata.
pub cli_version: Option<String>,
/// RFC3339 timestamp string for when the session was created, if available.
@@ -98,6 +101,7 @@ struct HeadTailSummary {
agent_nickname: Option<String>,
agent_role: Option<String>,
model_provider: Option<String>,
multi_agent_version: Option<MultiAgentVersion>,
cli_version: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
@@ -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<HeadTai
summary.agent_nickname = session_meta_line.meta.agent_nickname.clone();
summary.agent_role = session_meta_line.meta.agent_role.clone();
summary.model_provider = session_meta_line.meta.model_provider.clone();
summary.multi_agent_version = session_meta_line.meta.multi_agent_version;
summary.thread_id = Some(session_meta_line.meta.id);
summary.cwd = Some(session_meta_line.meta.cwd.clone());
summary.git_branch = session_meta_line
+1
View File
@@ -47,6 +47,7 @@ pub(crate) fn builder_from_session_meta(
session_meta.meta.source.clone(),
);
builder.model_provider = session_meta.meta.model_provider.clone();
builder.multi_agent_version = session_meta.meta.multi_agent_version;
builder.agent_nickname = session_meta.meta.agent_nickname.clone();
builder.agent_role = session_meta.meta.agent_role.clone();
builder.agent_path = session_meta.meta.agent_path.clone();
+3
View File
@@ -45,6 +45,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() {
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,
@@ -97,6 +98,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() {
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,
@@ -357,6 +359,7 @@ fn write_rollout_in_sessions_with_cwd(
agent_nickname: None,
agent_role: None,
model_provider: Some("test-provider".to_string()),
multi_agent_version: None,
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
+11
View File
@@ -13,6 +13,7 @@ use chrono::SecondsFormat;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::BaseInstructions;
use codex_protocol::openai_models::MultiAgentVersion;
use serde_json::Value;
use time::OffsetDateTime;
use time::format_description::FormatItem;
@@ -83,6 +84,7 @@ pub enum RolloutRecorderParams {
forked_from_id: Option<ThreadId>,
source: SessionSource,
thread_source: Option<ThreadSource>,
multi_agent_version: Option<MultiAgentVersion>,
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
},
@@ -158,6 +160,7 @@ impl RolloutRecorderParams {
forked_from_id: Option<ThreadId>,
source: SessionSource,
thread_source: Option<ThreadSource>,
multi_agent_version: Option<MultiAgentVersion>,
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
) -> 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)),
+5
View File
@@ -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()),
@@ -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,
+12
View File
@@ -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(),
@@ -0,0 +1 @@
ALTER TABLE threads ADD COLUMN multi_agent_version TEXT;
+4
View File
@@ -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(),
@@ -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<String>,
/// The latest observed reasoning effort for the thread.
pub reasoning_effort: Option<ReasoningEffort>,
/// Thread-scoped multi-agent runtime selector.
pub multi_agent_version: Option<MultiAgentVersion>,
/// 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<String>,
/// The model provider identifier, if known.
pub model_provider: Option<String>,
/// Thread-scoped multi-agent runtime selector.
pub multi_agent_version: Option<MultiAgentVersion>,
/// 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<String>,
reasoning_effort: Option<String>,
multi_agent_version: Option<String>,
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<ThreadRow> for ThreadMetadata {
model_provider,
model,
reasoning_effort,
multi_agent_version,
cwd,
cli_version,
title,
@@ -426,6 +440,8 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
model,
reasoning_effort: reasoning_effort
.and_then(|value| value.parse::<ReasoningEffort>().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(),
+2
View File
@@ -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,
@@ -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(),
+21 -2
View File
@@ -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,
+3
View File
@@ -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),
@@ -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,
),
@@ -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,
+94
View File
@@ -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::<Vec<_>>();
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,
}
}
@@ -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),
@@ -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,
}
}
@@ -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,
+16
View File
@@ -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<PathBuf>,
/// Model provider associated with the thread.
pub model_provider: String,
/// Thread-scoped multi-agent runtime selector.
pub multi_agent_version: Option<MultiAgentVersion>,
/// Memory mode associated with the live thread.
pub memory_mode: MemoryMode,
}
@@ -372,6 +375,8 @@ pub struct StoredThread {
pub model: Option<String>,
/// Latest observed reasoning effort, if known.
pub reasoning_effort: Option<ReasoningEffort>,
/// Thread-scoped multi-agent runtime selector.
pub multi_agent_version: Option<MultiAgentVersion>,
/// Thread creation timestamp.
pub created_at: DateTime<Utc>,
/// Thread last-update timestamp.
@@ -479,6 +484,13 @@ pub struct ThreadMetadataPatch {
pub model: Option<String>,
/// Latest observed reasoning effort.
pub reasoning_effort: Option<ReasoningEffort>,
/// Thread-scoped multi-agent runtime selector.
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub multi_agent_version: ClearableField<MultiAgentVersion>,
/// Creation timestamp when known.
pub created_at: Option<DateTime<Utc>>,
/// 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()