Preserve the resolved multi-agent version when reverting threads (#43000)

## Why

Reverting a paginated thread can remove the turn context that records its resolved multi-agent version. Preserve that version so a model-selected V2 thread keeps its collaboration tools after reload or a server restart.

## What changed

Pass the live thread's `multi_agent_version` into the thread store and persist it in the replacement rollout's session metadata. When no live version is supplied, retain the existing metadata value. Document this guarantee for `thread/revert`.

## Testing

Add regression tests for model-selected V2 across live reload and cold resume, plus metadata preservation for `V1`, `V2`, and `Disabled` across repeated reverts.

GitOrigin-RevId: bd7d4d76a50d23da16caa2a0631acf10bfa49629
This commit is contained in:
jif authored and copyberry committed 2026-09-05 12:58:26 +00:00
1 parent 531f3836a1
commit a7a4321593
6 files changed
+186 -2

No files matched your search

@@ -23,6 +23,7 @@ pub(super) async fn revert(
let RevertThreadParams {
thread_id,
before_turn_id,
multi_agent_version,
} = params;
let state_db = store
.state_db()
@@ -48,7 +49,7 @@ pub(super) async fn revert(
.await?
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })?;
let source_path = current_rollout.path;
let source_meta = codex_rollout::read_session_meta_line(source_path.as_path())
let mut source_meta = codex_rollout::read_session_meta_line(source_path.as_path())
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!(
@@ -106,6 +107,7 @@ pub(super) async fn revert(
cutoff.min(history_base.map_or(0, |base| base.end_ordinal_exclusive))
});
source_meta.multi_agent_version = multi_agent_version.or(source_meta.multi_agent_version);
let rollout_id = ThreadId::new();
let recorder = create_replacement_recorder(
store,
@@ -1,6 +1,7 @@
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadMemoryMode;
@@ -72,6 +73,7 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
.revert_thread(RevertThreadParams {
thread_id,
before_turn_id: "turn-2".to_string(),
multi_agent_version: None,
})
.await
.expect("revert before second turn");
@@ -98,6 +100,7 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
.revert_thread(RevertThreadParams {
thread_id,
before_turn_id: "turn-1".to_string(),
multi_agent_version: None,
})
.await
.expect("revert before first turn");
@@ -134,6 +137,80 @@ async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
}
}
#[tokio::test]
async fn revert_preserves_resolved_multi_agent_version_in_session_metadata() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let state_db = codex_state::StateRuntime::init(
config.sqlite.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("initialize state database");
let store = LocalThreadStore::new(config, Some(state_db.clone()));
for version in [
MultiAgentVersion::V1,
MultiAgentVersion::V2,
MultiAgentVersion::Disabled,
] {
let thread_id = ThreadId::new();
create_paginated_thread(&store, thread_id).await;
store
.append_items(AppendThreadItemsParams {
thread_id,
items: vec![
turn_started("turn-1"),
turn_completed("turn-1"),
turn_started("turn-2"),
turn_completed("turn-2"),
],
})
.await
.expect("append turns");
let path = store
.live_rollout_path(thread_id)
.await
.expect("source rollout path");
store
.shutdown_thread(thread_id)
.await
.expect("close source writer");
codex_rollout::state_db::reconcile_rollout(
Some(state_db.as_ref()),
path.as_path(),
"test-provider",
/*builder*/ None,
&[],
/*archived_only*/ Some(false),
/*new_thread_memory_mode*/ None,
)
.await;
// The second revert has no live version to supply and must keep the persisted one.
for (before_turn_id, multi_agent_version) in [("turn-2", Some(version)), ("turn-1", None)] {
store
.revert_thread(RevertThreadParams {
thread_id,
before_turn_id: before_turn_id.to_string(),
multi_agent_version,
})
.await
.expect("revert thread");
let path = state_db
.get_thread(thread_id)
.await
.expect("read metadata")
.expect("thread metadata")
.rollout_path;
let meta = codex_rollout::read_session_meta_line(&path)
.await
.expect("read replacement metadata");
assert_eq!(meta.meta.multi_agent_version, Some(version));
}
}
}
async fn rollout_paths_for_thread(
home: &std::path::Path,
thread_id: ThreadId,