mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
## 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
308 lines
10 KiB
Rust
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");
|
|
}
|