Files
codex/codex-rs/thread-store/src/local/revert_thread_tests.rs
T
Owen Lin 196964ef10 Preserve root turn attribution in turn-start events (#44611)
## Why

Persisted turn-start events lack the originating root turn ID, so they cannot directly associate child turns with the root turn that initiated them.

## What changed

- Add optional `root_turn_id` to `TurnStartedEvent`. Use the inherited root turn ID when available, otherwise the turn's own ID.
- Centralize turn-start emission for regular turns, shell commands, and manual compaction so they record attribution consistently.
- Carry attribution into `ThreadHistoryTurnChange` when processing turn-start events and retain it through completion in the history builder.
- Keep older records compatible by defaulting missing attribution to `None`.

## Testing

Add coverage for root and child turn attribution, persistence when tool collisions fail a turn before sampling, history change propagation, and deserialization of older turn-start records.

GitOrigin-RevId: 54a80b828ca7d1c4c19f13d468c01d052e1b9130
2026-09-10 18:24:13 +00:00

308 lines
10 KiB
Rust

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;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_rollout::RolloutItem;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use super::super::LocalThreadStore;
use super::super::test_support::test_config;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
use crate::CreateThreadParams;
use crate::DeleteThreadParams;
use crate::ListTurnsParams;
use crate::RevertThreadParams;
use crate::SortDirection;
use crate::StoredTurnItemsView;
use crate::ThreadPersistenceMetadata;
use crate::ThreadStore;
#[tokio::test]
async fn revert_keeps_thread_id_and_hides_suffix_across_repeated_reverts() {
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()));
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 original_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()),
original_path.as_path(),
"test-provider",
/*builder*/ None,
&[],
/*archived_only*/ Some(false),
/*new_thread_memory_mode*/ None,
)
.await;
compress_rollout(original_path.as_path());
store
.revert_thread(RevertThreadParams {
thread_id,
before_turn_id: "turn-2".to_string(),
multi_agent_version: None,
})
.await
.expect("revert before second turn");
let first_replacement_path = state_db
.get_thread(thread_id)
.await
.expect("read metadata")
.expect("thread metadata")
.rollout_path;
assert_ne!(first_replacement_path, original_path);
assert_ne!(
codex_rollout::rollout_id_from_path(first_replacement_path.as_path()),
Some(thread_id)
);
let replacement_meta = codex_rollout::read_session_meta_line(first_replacement_path.as_path())
.await
.expect("read replacement metadata")
.meta;
assert_eq!(replacement_meta.id, thread_id);
assert_eq!(replacement_meta.memory_mode, None);
assert_eq!(
replacement_meta.runtime_workspace_roots,
Some(vec![home.path().join("workspace")])
);
assert_eq!(turn_ids(&store, thread_id).await, vec!["turn-1"]);
store
.revert_thread(RevertThreadParams {
thread_id,
before_turn_id: "turn-1".to_string(),
multi_agent_version: None,
})
.await
.expect("revert before first turn");
assert_eq!(turn_ids(&store, thread_id).await, Vec::<String>::new());
store
.archive_thread(ArchiveThreadParams { thread_id })
.await
.expect("archive reverted thread");
assert!(
rollout_paths_for_thread(home.path(), thread_id)
.await
.iter()
.all(|path| path.starts_with(home.path().join("archived_sessions")))
);
store
.unarchive_thread(ArchiveThreadParams { thread_id })
.await
.expect("unarchive reverted thread");
let owned_rollout_paths = rollout_paths_for_thread(home.path(), thread_id).await;
assert_eq!(owned_rollout_paths.len(), 3);
assert!(
owned_rollout_paths
.iter()
.all(|path| path.starts_with(home.path().join("sessions")))
);
store
.delete_thread(DeleteThreadParams { thread_id })
.await
.expect("delete reverted thread");
for rollout_path in owned_rollout_paths {
assert!(!rollout_path.exists());
}
}
#[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,
) -> Vec<std::path::PathBuf> {
codex_rollout::RolloutReferenceIndex::scan(home)
.await
.expect("scan rollout references")
.rollouts_for_thread(thread_id)
.map(|(_, path)| path.to_path_buf())
.collect()
}
async fn create_paginated_thread(store: &LocalThreadStore, thread_id: ThreadId) {
store
.create_thread(CreateThreadParams {
session_id: thread_id.into(),
thread_id,
extra_config: None,
forked_from_id: None,
parent_thread_id: None,
source: SessionSource::Exec,
thread_source: None,
originator: "test_originator".to_string(),
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: ThreadHistoryMode::Paginated,
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: "window-1".to_string(),
runtime_workspace_roots: Some(vec![store.config.codex_home.join("workspace").abs()]),
metadata: ThreadPersistenceMetadata {
cwd: Some(std::env::current_dir().expect("cwd")),
model_provider: "test-provider".to_string(),
memory_mode: ThreadMemoryMode::Enabled,
},
})
.await
.expect("create paginated thread");
}
async fn turn_ids(store: &LocalThreadStore, thread_id: ThreadId) -> Vec<String> {
store
.list_turns(ListTurnsParams {
thread_id,
include_archived: false,
cursor: None,
page_size: 10,
sort_direction: SortDirection::Asc,
items_view: StoredTurnItemsView::NotLoaded,
})
.await
.expect("list turns")
.turns
.into_iter()
.map(|turn| turn.turn_id)
.collect()
}
fn turn_started(turn_id: &str) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: Some(10),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}))
}
fn turn_completed(turn_id: &str) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
error: None,
started_at: Some(10),
completed_at: Some(20),
duration_ms: Some(10_000),
time_to_first_token_ms: None,
}))
}
fn compress_rollout(path: &std::path::Path) {
let contents = std::fs::read(path).expect("read rollout");
let compressed = zstd::stream::encode_all(contents.as_slice(), 3).expect("compress rollout");
std::fs::write(path.with_extension("jsonl.zst"), compressed).expect("write compressed rollout");
std::fs::remove_file(path).expect("remove plain rollout");
}