mirror of
https://github.com/openai/codex.git
synced 2026-09-10 20:26:47 +00:00
## Description This PR adds a new `historyMode = "legacy" | "paginated"` to `Thread`. This will be stored in `SessionMeta` in the JSONL rollout file and as a new column in the SQLite thread_metadata table, and exposed on `thread/start` and on the `Thread` object in app-server. ## What changed - Added canonical `ThreadHistoryMode` with `legacy` and `paginated`, defaulting old and new SessionMeta to `legacy`. - Carried `history_mode` through core session config, ThreadStore stored metadata, local/in-memory stores, rollout metadata extraction, and the existing SQLite `threads` table. - Added experimental `historyMode` to app-server v2 `Thread` and `thread/start`. - Made paginated stored threads metadata-discoverable but unsupported for legacy full-history reads, `load_history`, live resume, and create paths. - Regenerated app-server schema fixtures and added protocol/state/thread-store/app-server coverage for persistence and fail-closed behavior. ## Compatibility floor Because users may be running various versions of Codex binaries on the same machine (TUI, Codex App, etc.), we will need to establish a compatibility floor for upcoming paginated threads, which will change how thread storage reads and writes work. The overall plan here: ``` Release N: - Add historyMode to SessionMeta / Thread / SQLite metadata. - Teach binaries to understand paginated threads. - If a binary sees `historyMode="paginated"` but does not support the paginated contract, it refuses to resume/mutate the thread. - Default remains `"legacy"`. Release N+1: - First-party clients start opting into paginated threads where appropriate. - Internal dogfood / staged rollout. - Measure old-client usage and paginated-thread unsupported errors. Release N+2: - Only after Release N+ is overwhelmingly deployed, make paginated the default. - Accept that a small tail of N-1-or-older binaries may not understand paginated threads. ``` The important behavior change is fail-closed handling for a binary that encounters a persisted `paginated` thread before it knows how to fully support paginated history. In app-server, if a thread is `paginated`, we will: - allow metadata-only discovery paths like `thread/list` and `thread/read(includeTurns=false)`, so clients can still see the thread and inspect its `historyMode` - reject legacy full-history/live-thread paths like `thread/read(includeTurns=true)` and `thread/resume` with an unsupported JSON-RPC error - avoid silently treating an unknown or future `historyMode` as `legacy` Under the hood, the ThreadStore layer also rejects legacy operations that would need to load or replay the full thread history for a paginated thread. That gives us the behavior we want for Release N: future paginated threads are visible, but this binary fails closed instead of trying to operate on them as if they were legacy threads.
1881 lines
64 KiB
Rust
1881 lines
64 KiB
Rust
use super::*;
|
|
use crate::config::test_config;
|
|
use crate::init_state_db;
|
|
use crate::installation_id::INSTALLATION_ID_FILENAME;
|
|
use crate::rollout::RolloutRecorder;
|
|
use crate::session::session::SessionSettingsUpdate;
|
|
use crate::session::tests::build_world_state_from_turn_context;
|
|
use crate::session::tests::make_session_and_context;
|
|
use crate::tasks::InterruptedTurnHistoryMarker;
|
|
use crate::tasks::interrupted_turn_history_marker;
|
|
use codex_extension_api::empty_extension_registry;
|
|
use codex_models_manager::manager::RefreshStrategy;
|
|
use codex_protocol::capabilities::CapabilityRootLocation;
|
|
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ReasoningItemReasoningSummary;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_protocol::protocol::AgentMessageEvent;
|
|
use codex_protocol::protocol::InitialHistory;
|
|
use codex_protocol::protocol::InternalSessionSource;
|
|
use codex_protocol::protocol::ResumedHistory;
|
|
use codex_protocol::protocol::SessionMeta;
|
|
use codex_protocol::protocol::SessionMetaLine;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::ThreadSource;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use codex_utils_path_uri::PathUri;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::PathExt;
|
|
use core_test_support::responses::mount_models_once;
|
|
use pretty_assertions::assert_eq;
|
|
use std::time::Duration;
|
|
use tempfile::tempdir;
|
|
use wiremock::MockServer;
|
|
|
|
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
|
|
|
|
struct FakeAgentGraphStore {
|
|
root_thread_id: ThreadId,
|
|
descendant_thread_ids: Vec<ThreadId>,
|
|
}
|
|
|
|
impl codex_agent_graph_store::AgentGraphStore for FakeAgentGraphStore {
|
|
fn upsert_thread_spawn_edge(
|
|
&self,
|
|
_parent_thread_id: ThreadId,
|
|
_child_thread_id: ThreadId,
|
|
_status: codex_agent_graph_store::ThreadSpawnEdgeStatus,
|
|
) -> codex_agent_graph_store::AgentGraphStoreFuture<'_, ()> {
|
|
Box::pin(async { panic!("unexpected graph upsert") })
|
|
}
|
|
|
|
fn set_thread_spawn_edge_status(
|
|
&self,
|
|
_child_thread_id: ThreadId,
|
|
_status: codex_agent_graph_store::ThreadSpawnEdgeStatus,
|
|
) -> codex_agent_graph_store::AgentGraphStoreFuture<'_, ()> {
|
|
Box::pin(async { panic!("unexpected graph status update") })
|
|
}
|
|
|
|
fn list_thread_spawn_children(
|
|
&self,
|
|
_parent_thread_id: ThreadId,
|
|
_status_filter: Option<codex_agent_graph_store::ThreadSpawnEdgeStatus>,
|
|
) -> codex_agent_graph_store::AgentGraphStoreFuture<'_, Vec<ThreadId>> {
|
|
Box::pin(async { panic!("unexpected direct-child listing") })
|
|
}
|
|
|
|
fn list_thread_spawn_descendants(
|
|
&self,
|
|
root_thread_id: ThreadId,
|
|
status_filter: Option<codex_agent_graph_store::ThreadSpawnEdgeStatus>,
|
|
) -> codex_agent_graph_store::AgentGraphStoreFuture<'_, Vec<ThreadId>> {
|
|
assert_eq!(root_thread_id, self.root_thread_id);
|
|
assert_eq!(status_filter, None);
|
|
let descendant_thread_ids = self.descendant_thread_ids.clone();
|
|
Box::pin(async move { Ok(descendant_thread_ids) })
|
|
}
|
|
}
|
|
|
|
fn user_msg(text: &str) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
}
|
|
fn assistant_msg(text: &str) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "assistant".to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: text.to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}
|
|
}
|
|
|
|
fn contextual_user_interrupted_marker() -> ResponseItem {
|
|
interrupted_turn_history_marker(InterruptedTurnHistoryMarker::ContextualUser)
|
|
.expect("contextual-user interrupted marker should be enabled")
|
|
}
|
|
|
|
fn developer_interrupted_marker() -> ResponseItem {
|
|
interrupted_turn_history_marker(InterruptedTurnHistoryMarker::Developer)
|
|
.expect("developer interrupted marker should be enabled")
|
|
}
|
|
|
|
#[test]
|
|
fn effective_originator_prefers_thread_scoped_sources_before_env_originator() {
|
|
for (metrics_service_name, persisted_originator, inherited_originator, expected_originator) in [
|
|
(
|
|
Some("codex_work_desktop"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"codex_work_desktop",
|
|
),
|
|
(
|
|
Some("codex_work_web"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"codex_work_web",
|
|
),
|
|
(
|
|
Some("codex_work_mobile"),
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"codex_work_mobile",
|
|
),
|
|
(
|
|
None,
|
|
Some("persisted_originator"),
|
|
Some("inherited_originator"),
|
|
"persisted_originator",
|
|
),
|
|
(
|
|
None,
|
|
None,
|
|
Some("inherited_originator"),
|
|
"inherited_originator",
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
effective_originator_value(
|
|
metrics_service_name,
|
|
Some("Codex Desktop".to_string()),
|
|
persisted_originator.map(str::to_string),
|
|
inherited_originator.map(str::to_string),
|
|
"codex_cli_rs".to_string(),
|
|
),
|
|
expected_originator
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn truncates_before_requested_user_message() {
|
|
let items = [
|
|
user_msg("u1"),
|
|
assistant_msg("a1"),
|
|
assistant_msg("a2"),
|
|
user_msg("u2"),
|
|
assistant_msg("a3"),
|
|
ResponseItem::Reasoning {
|
|
id: Some("r1".to_string()),
|
|
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
|
text: "s".to_string(),
|
|
}],
|
|
content: None,
|
|
encrypted_content: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
ResponseItem::FunctionCall {
|
|
id: None,
|
|
call_id: "c1".to_string(),
|
|
name: "tool".to_string(),
|
|
namespace: None,
|
|
arguments: "{}".to_string(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
},
|
|
assistant_msg("a4"),
|
|
];
|
|
|
|
let initial: Vec<RolloutItem> = items
|
|
.iter()
|
|
.cloned()
|
|
.map(RolloutItem::ResponseItem)
|
|
.collect();
|
|
let truncated = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(initial),
|
|
/*n*/ 1,
|
|
&SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
let got_items = truncated.get_rollout_items();
|
|
let expected_items = vec![
|
|
RolloutItem::ResponseItem(items[0].clone()),
|
|
RolloutItem::ResponseItem(items[1].clone()),
|
|
RolloutItem::ResponseItem(items[2].clone()),
|
|
];
|
|
assert_eq!(
|
|
serde_json::to_value(got_items).unwrap(),
|
|
serde_json::to_value(&expected_items).unwrap()
|
|
);
|
|
|
|
let initial2: Vec<RolloutItem> = items
|
|
.iter()
|
|
.cloned()
|
|
.map(RolloutItem::ResponseItem)
|
|
.collect();
|
|
let truncated2 = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(initial2.clone()),
|
|
/*n*/ 2,
|
|
&SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(truncated2.get_rollout_items()).unwrap(),
|
|
serde_json::to_value(initial2).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_range_truncation_drops_only_unfinished_suffix_mid_turn() {
|
|
let items = vec![
|
|
RolloutItem::ResponseItem(user_msg("u1")),
|
|
RolloutItem::ResponseItem(assistant_msg("a1")),
|
|
RolloutItem::ResponseItem(user_msg("u2")),
|
|
RolloutItem::ResponseItem(assistant_msg("partial")),
|
|
];
|
|
|
|
let truncated = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(items.clone()),
|
|
usize::MAX,
|
|
&SnapshotTurnState {
|
|
ends_mid_turn: true,
|
|
active_turn_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(truncated.get_rollout_items()).unwrap(),
|
|
serde_json::to_value(items[..2].to_vec()).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fork_thread_accepts_legacy_usize_snapshot_argument() {
|
|
fn assert_legacy_snapshot_callsite(
|
|
manager: &ThreadManager,
|
|
config: Config,
|
|
path: std::path::PathBuf,
|
|
) {
|
|
let _future = manager.fork_thread(
|
|
usize::MAX,
|
|
config,
|
|
path,
|
|
/*thread_source*/ None,
|
|
/*parent_trace*/ None,
|
|
);
|
|
}
|
|
|
|
let _: fn(&ThreadManager, Config, std::path::PathBuf) = assert_legacy_snapshot_callsite;
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_range_truncation_drops_pre_user_active_turn_prefix() {
|
|
let items = vec![
|
|
RolloutItem::ResponseItem(user_msg("u1")),
|
|
RolloutItem::ResponseItem(assistant_msg("a1")),
|
|
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-2".to_string(),
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
})),
|
|
RolloutItem::ResponseItem(user_msg("u2")),
|
|
RolloutItem::ResponseItem(assistant_msg("partial")),
|
|
];
|
|
|
|
let snapshot_state = snapshot_turn_state(&InitialHistory::Forked(items.clone()));
|
|
assert_eq!(
|
|
snapshot_state,
|
|
SnapshotTurnState {
|
|
ends_mid_turn: true,
|
|
active_turn_id: Some("turn-2".to_string()),
|
|
active_turn_start_index: Some(2),
|
|
},
|
|
);
|
|
|
|
let truncated = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(items.clone()),
|
|
usize::MAX,
|
|
&snapshot_state,
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(truncated.get_rollout_items()).unwrap(),
|
|
serde_json::to_value(items[..2].to_vec()).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ignores_session_prefix_messages_when_truncating() {
|
|
let (session, turn_context) = make_session_and_context().await;
|
|
let turn_context = Arc::new(turn_context);
|
|
let world_state = build_world_state_from_turn_context(&session, &turn_context).await;
|
|
let mut items = session
|
|
.build_initial_context_with_world_state(&turn_context, &world_state)
|
|
.await;
|
|
items.push(user_msg("feature request"));
|
|
items.push(assistant_msg("ack"));
|
|
items.push(user_msg("second question"));
|
|
items.push(assistant_msg("answer"));
|
|
|
|
let rollout_items: Vec<RolloutItem> = items
|
|
.iter()
|
|
.cloned()
|
|
.map(RolloutItem::ResponseItem)
|
|
.collect();
|
|
|
|
let truncated = truncate_before_nth_user_message(
|
|
InitialHistory::Forked(rollout_items),
|
|
/*n*/ 1,
|
|
&SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
let got_items = truncated.get_rollout_items();
|
|
|
|
let expected: Vec<RolloutItem> = vec![
|
|
RolloutItem::ResponseItem(items[0].clone()),
|
|
RolloutItem::ResponseItem(items[1].clone()),
|
|
RolloutItem::ResponseItem(items[2].clone()),
|
|
RolloutItem::ResponseItem(items[3].clone()),
|
|
];
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(got_items).unwrap(),
|
|
serde_json::to_value(&expected).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let thread_1 = manager
|
|
.start_thread(config.clone())
|
|
.await
|
|
.expect("start first thread")
|
|
.thread_id;
|
|
let thread_2 = manager
|
|
.start_thread(config.clone())
|
|
.await
|
|
.expect("start second thread")
|
|
.thread_id;
|
|
|
|
let report = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
|
|
let mut expected_completed = vec![thread_1, thread_2];
|
|
expected_completed.sort_by_key(std::string::ToString::to_string);
|
|
assert_eq!(report.completed, expected_completed);
|
|
assert!(report.submit_failed.is_empty());
|
|
assert!(report.timed_out.is_empty());
|
|
assert!(manager.list_thread_ids().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn code_mode_session_provider_is_shared_across_threads() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let first = manager
|
|
.start_thread(config.clone())
|
|
.await
|
|
.expect("start first thread");
|
|
let second = manager
|
|
.start_thread(config)
|
|
.await
|
|
.expect("start second thread");
|
|
|
|
let first_provider = first
|
|
.thread
|
|
.codex
|
|
.session
|
|
.services
|
|
.code_mode_service
|
|
.session_provider();
|
|
let second_provider = second
|
|
.thread
|
|
.codex
|
|
.session
|
|
.services
|
|
.code_mode_service
|
|
.session_provider();
|
|
assert!(Arc::ptr_eq(&first_provider, &second_provider));
|
|
assert!(Arc::ptr_eq(
|
|
&first_provider,
|
|
&manager.state.code_mode_session_provider
|
|
));
|
|
|
|
let mut completed = vec![first.thread_id, second.thread_id];
|
|
completed.sort_by_key(std::string::ToString::to_string);
|
|
let report = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
assert_eq!(
|
|
report,
|
|
ThreadShutdownReport {
|
|
completed,
|
|
submit_failed: Vec::new(),
|
|
timed_out: Vec::new(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let thread = manager
|
|
.start_thread_with_options(StartThreadOptions {
|
|
config,
|
|
allow_provider_model_fallback: false,
|
|
initial_history: InitialHistory::New,
|
|
history_mode: None,
|
|
session_source: Some(SessionSource::Internal(
|
|
InternalSessionSource::MemoryConsolidation,
|
|
)),
|
|
thread_source: None,
|
|
dynamic_tools: Vec::new(),
|
|
metrics_service_name: None,
|
|
parent_trace: None,
|
|
environments: Vec::new(),
|
|
thread_extension_init: Default::default(),
|
|
supports_openai_form_elicitation: false,
|
|
})
|
|
.await
|
|
.expect("internal thread should start");
|
|
|
|
assert_eq!(manager.list_thread_ids().await, Vec::new());
|
|
assert!(manager.get_thread(thread.thread_id).await.is_err());
|
|
|
|
let report = manager
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
assert_eq!(report.completed, vec![thread.thread_id]);
|
|
assert!(report.submit_failed.is_empty());
|
|
assert!(report.timed_out.is_empty());
|
|
assert!(manager.list_thread_ids().await.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() {
|
|
struct InitialDataRecorder {
|
|
lifecycle_observed: Arc<std::sync::Mutex<Vec<(String, String)>>>,
|
|
mcp_observed: Arc<std::sync::Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl codex_extension_api::ThreadLifecycleContributor<Config> for InitialDataRecorder {
|
|
fn on_thread_start<'a>(
|
|
&'a self,
|
|
input: codex_extension_api::ThreadStartInput<'a, Config>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let selected_root = input
|
|
.thread_store
|
|
.get::<Vec<SelectedCapabilityRoot>>()
|
|
.and_then(|roots| roots.first().cloned())
|
|
.expect("selected root should be available");
|
|
self.lifecycle_observed
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push((input.thread_store.level_id().to_string(), selected_root.id));
|
|
input
|
|
.thread_store
|
|
.insert(Vec::<SelectedCapabilityRoot>::new());
|
|
})
|
|
}
|
|
}
|
|
|
|
impl codex_extension_api::McpServerContributor<Config> for InitialDataRecorder {
|
|
fn id(&self) -> &'static str {
|
|
"selected_root_test"
|
|
}
|
|
|
|
fn contribute<'a>(
|
|
&'a self,
|
|
context: codex_extension_api::McpServerContributionContext<'a, Config>,
|
|
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::McpServerContribution>>
|
|
{
|
|
Box::pin(async move {
|
|
let thread_init = context
|
|
.thread_init()
|
|
.expect("initial MCP resolution should be thread-scoped");
|
|
let selected_root = thread_init
|
|
.get::<Vec<SelectedCapabilityRoot>>()
|
|
.and_then(|roots| roots.first().cloned())
|
|
.expect("selected root should be available");
|
|
self.mcp_observed
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push(selected_root.id.clone());
|
|
let mut server = codex_mcp::codex_apps_mcp_server_config(
|
|
"https://selected.invalid",
|
|
/*apps_mcp_product_sku*/ None,
|
|
);
|
|
let CapabilityRootLocation::Environment { environment_id, .. } =
|
|
&selected_root.location;
|
|
server.environment_id = environment_id.clone();
|
|
server.enabled = false;
|
|
let plugin_id = selected_root.id;
|
|
vec![codex_extension_api::McpServerContribution::SelectedPlugin {
|
|
name: plugin_id.clone(),
|
|
plugin_display_name: plugin_id.clone(),
|
|
plugin_id,
|
|
selection_order: 0,
|
|
config: Box::new(server),
|
|
}]
|
|
})
|
|
}
|
|
}
|
|
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let lifecycle_observed = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let mcp_observed = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let recorder = Arc::new(InitialDataRecorder {
|
|
lifecycle_observed: Arc::clone(&lifecycle_observed),
|
|
mcp_observed: Arc::clone(&mcp_observed),
|
|
});
|
|
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
|
|
extensions.thread_lifecycle_contributor(recorder.clone());
|
|
extensions.mcp_server_contributor(recorder);
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
Arc::new(extensions.build()),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, /*state_db*/ None),
|
|
/*agent_graph_store*/ None,
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
let selected_root_init = |id: &str, environment_id: &str| {
|
|
let mut init = codex_extension_api::ExtensionDataInit::new();
|
|
init.insert(vec![SelectedCapabilityRoot {
|
|
id: id.to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: environment_id.to_string(),
|
|
path: PathUri::parse(&format!("file:///plugins/{id}")).expect("plugin root URI"),
|
|
},
|
|
}]);
|
|
init
|
|
};
|
|
|
|
let first_thread = manager
|
|
.start_thread_with_options(StartThreadOptions {
|
|
config: config.clone(),
|
|
allow_provider_model_fallback: false,
|
|
initial_history: InitialHistory::New,
|
|
history_mode: None,
|
|
session_source: None,
|
|
thread_source: None,
|
|
dynamic_tools: Vec::new(),
|
|
metrics_service_name: None,
|
|
parent_trace: None,
|
|
environments: Vec::new(),
|
|
thread_extension_init: selected_root_init("selected-a", "env-a"),
|
|
supports_openai_form_elicitation: false,
|
|
})
|
|
.await
|
|
.expect("start first thread");
|
|
let second_thread = manager
|
|
.start_thread_with_options(StartThreadOptions {
|
|
config: config.clone(),
|
|
allow_provider_model_fallback: false,
|
|
initial_history: InitialHistory::New,
|
|
history_mode: None,
|
|
session_source: None,
|
|
thread_source: None,
|
|
dynamic_tools: Vec::new(),
|
|
metrics_service_name: None,
|
|
parent_trace: None,
|
|
environments: Vec::new(),
|
|
thread_extension_init: selected_root_init("selected-b", "env-b"),
|
|
supports_openai_form_elicitation: false,
|
|
})
|
|
.await
|
|
.expect("start second thread");
|
|
let first_session = &first_thread.thread.codex.session;
|
|
let first_resolved = first_session
|
|
.services
|
|
.mcp_manager
|
|
.runtime_config_for_step(
|
|
&config,
|
|
&first_session.services.mcp_thread_init,
|
|
&first_session.services.thread_extension_data,
|
|
/*available_environment_ids*/ &[],
|
|
)
|
|
.await;
|
|
let second_session = &second_thread.thread.codex.session;
|
|
let second_resolved = second_session
|
|
.services
|
|
.mcp_manager
|
|
.runtime_config_for_step(
|
|
&config,
|
|
&second_session.services.mcp_thread_init,
|
|
&second_session.services.thread_extension_data,
|
|
/*available_environment_ids*/ &[],
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
*lifecycle_observed
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
|
vec![
|
|
(first_thread.thread_id.to_string(), "selected-a".to_string()),
|
|
(
|
|
second_thread.thread_id.to_string(),
|
|
"selected-b".to_string()
|
|
),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
*mcp_observed
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
|
vec![
|
|
"selected-a".to_string(),
|
|
"selected-b".to_string(),
|
|
"selected-a".to_string(),
|
|
"selected-b".to_string(),
|
|
]
|
|
);
|
|
let selected_servers = |config: &codex_mcp::McpConfig| {
|
|
codex_mcp::configured_mcp_servers(config)
|
|
.into_iter()
|
|
.filter(|(name, _)| name.starts_with("selected-"))
|
|
.map(|(name, server)| (name, server.environment_id))
|
|
.collect::<std::collections::BTreeMap<_, _>>()
|
|
};
|
|
assert_eq!(
|
|
selected_servers(&first_resolved),
|
|
std::collections::BTreeMap::from([("selected-a".to_string(), "env-a".to_string())])
|
|
);
|
|
assert_eq!(
|
|
selected_servers(&second_resolved),
|
|
std::collections::BTreeMap::from([("selected-b".to_string(), "env-b".to_string())])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn selected_capability_roots_round_trip_through_fork() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
|
CodexAuth::from_api_key("dummy"),
|
|
config.model_provider.clone(),
|
|
config.codex_home.to_path_buf(),
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
);
|
|
let selected_roots = vec![SelectedCapabilityRoot {
|
|
id: "demo@1".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "build".to_string(),
|
|
path: PathUri::parse("file:///plugins/demo").expect("plugin root URI"),
|
|
},
|
|
}];
|
|
let inherited = manager
|
|
.start_thread_with_options(StartThreadOptions {
|
|
config,
|
|
allow_provider_model_fallback: false,
|
|
initial_history: InitialHistory::Forked(vec![RolloutItem::SessionMeta(
|
|
SessionMetaLine {
|
|
meta: SessionMeta {
|
|
selected_capability_roots: selected_roots.clone(),
|
|
..SessionMeta::default()
|
|
},
|
|
git: None,
|
|
},
|
|
)]),
|
|
history_mode: None,
|
|
session_source: None,
|
|
thread_source: None,
|
|
dynamic_tools: Vec::new(),
|
|
metrics_service_name: None,
|
|
parent_trace: None,
|
|
environments: Vec::new(),
|
|
thread_extension_init: Default::default(),
|
|
supports_openai_form_elicitation: false,
|
|
})
|
|
.await
|
|
.expect("start inherited fork");
|
|
inherited.thread.ensure_rollout_materialized().await;
|
|
inherited
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush inherited fork");
|
|
let inherited_history = RolloutRecorder::get_rollout_history(
|
|
&inherited
|
|
.thread
|
|
.rollout_path()
|
|
.expect("inherited fork rollout path"),
|
|
)
|
|
.await
|
|
.expect("read inherited fork rollout");
|
|
|
|
assert_eq!(
|
|
inherited_history.get_selected_capability_roots(),
|
|
selected_roots
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager.clone(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, /*state_db*/ None),
|
|
/*agent_graph_store*/ None,
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
let selected_cwd =
|
|
AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path");
|
|
std::fs::create_dir_all(&selected_cwd).expect("create selected cwd");
|
|
let environments = vec![TurnEnvironmentSelection {
|
|
environment_id: "local".to_string(),
|
|
cwd: PathUri::from_abs_path(&selected_cwd),
|
|
}];
|
|
let default_cwd = config.cwd.clone();
|
|
let mut source_config = config.clone();
|
|
source_config.cwd = selected_cwd.clone();
|
|
let source = manager
|
|
.start_thread_with_options(StartThreadOptions {
|
|
config: source_config,
|
|
allow_provider_model_fallback: false,
|
|
initial_history: InitialHistory::New,
|
|
history_mode: None,
|
|
session_source: None,
|
|
thread_source: None,
|
|
dynamic_tools: Vec::new(),
|
|
metrics_service_name: None,
|
|
parent_trace: None,
|
|
environments: environments.clone(),
|
|
thread_extension_init: Default::default(),
|
|
supports_openai_form_elicitation: false,
|
|
})
|
|
.await
|
|
.expect("start source thread");
|
|
source.thread.ensure_rollout_materialized().await;
|
|
source
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread before resume");
|
|
let _ = manager.remove_thread(&source.thread_id).await;
|
|
|
|
let resumed = manager
|
|
.resume_thread_from_rollout(
|
|
config.clone(),
|
|
rollout_path.clone(),
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("resume source thread");
|
|
let resumed_turn = resumed
|
|
.thread
|
|
.codex
|
|
.session
|
|
.new_turn_with_sub_id("resume-turn".to_string(), SessionSettingsUpdate::default())
|
|
.await
|
|
.expect("build resumed turn context");
|
|
assert_eq!(resumed_turn.environments.turn_environments.len(), 1);
|
|
assert_eq!(
|
|
resumed_turn.environments.turn_environments[0].cwd(),
|
|
&PathUri::from_abs_path(&default_cwd)
|
|
);
|
|
assert_ne!(
|
|
resumed_turn.environments.turn_environments[0].cwd(),
|
|
&PathUri::from_abs_path(&selected_cwd)
|
|
);
|
|
|
|
let forked = manager
|
|
.fork_thread(
|
|
ForkSnapshot::Interrupted,
|
|
config,
|
|
rollout_path,
|
|
/*thread_source*/ None,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
.expect("fork source thread");
|
|
let forked_turn = forked
|
|
.thread
|
|
.codex
|
|
.session
|
|
.new_turn_with_sub_id("fork-turn".to_string(), SessionSettingsUpdate::default())
|
|
.await
|
|
.expect("build forked turn context");
|
|
assert_eq!(forked_turn.environments.turn_environments.len(), 1);
|
|
assert_eq!(
|
|
forked_turn.environments.turn_environments[0].cwd(),
|
|
&PathUri::from_abs_path(&default_cwd)
|
|
);
|
|
assert_ne!(
|
|
forked_turn.environments.turn_environments[0].cwd(),
|
|
&PathUri::from_abs_path(&selected_cwd)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_installation_id_skips_codex_home_file() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let installation_id = uuid::Uuid::new_v4().to_string();
|
|
let state_db = init_state_db(&config).await;
|
|
let thread_store = thread_store_from_config(&config, state_db.clone());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager,
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store,
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
installation_id.clone(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let thread = manager
|
|
.start_thread(config.clone())
|
|
.await
|
|
.expect("start thread with explicit installation id");
|
|
|
|
assert!(!config.codex_home.join(INSTALLATION_ID_FILENAME).exists());
|
|
assert_eq!(thread.thread.codex.session.installation_id, installation_id);
|
|
|
|
thread
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown thread");
|
|
let _ = manager.remove_thread(&thread.thread_id).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_active_thread_from_rollout_returns_running_thread() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager.clone(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, /*state_db*/ None),
|
|
/*agent_graph_store*/ None,
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(config.clone())
|
|
.await
|
|
.expect("start source thread");
|
|
source.thread.ensure_rollout_materialized().await;
|
|
source
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
|
|
let resumed = manager
|
|
.resume_thread_from_rollout(
|
|
config,
|
|
rollout_path,
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("resume active source thread");
|
|
assert_eq!(resumed.thread_id, source.thread_id);
|
|
assert!(Arc::ptr_eq(&resumed.thread, &source.thread));
|
|
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager.clone(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, /*state_db*/ None),
|
|
/*agent_graph_store*/ None,
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(config.clone())
|
|
.await
|
|
.expect("start source thread");
|
|
source.thread.ensure_rollout_materialized().await;
|
|
source
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread");
|
|
|
|
let resumed = manager
|
|
.resume_thread_from_rollout(
|
|
config,
|
|
rollout_path,
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("resume stopped source thread");
|
|
assert_eq!(resumed.thread_id, source.thread_id);
|
|
assert!(!Arc::ptr_eq(&resumed.thread, &source.thread));
|
|
|
|
resumed
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown resumed thread");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let thread_store = thread_store_from_config(&config, state_db.clone());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager.clone(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store,
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread_with_options(StartThreadOptions {
|
|
config: config.clone(),
|
|
allow_provider_model_fallback: false,
|
|
initial_history: InitialHistory::New,
|
|
history_mode: None,
|
|
session_source: None,
|
|
thread_source: Some(ThreadSource::User),
|
|
dynamic_tools: Vec::new(),
|
|
metrics_service_name: None,
|
|
parent_trace: None,
|
|
environments: Vec::new(),
|
|
thread_extension_init: Default::default(),
|
|
supports_openai_form_elicitation: false,
|
|
})
|
|
.await
|
|
.expect("start source thread");
|
|
source.thread.ensure_rollout_materialized().await;
|
|
source
|
|
.thread
|
|
.flush_rollout()
|
|
.await
|
|
.expect("flush source rollout");
|
|
let rollout_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread before resume");
|
|
let _ = manager.remove_thread(&source.thread_id).await;
|
|
|
|
let resumed = manager
|
|
.resume_thread_from_rollout(
|
|
config,
|
|
rollout_path,
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("resume source thread");
|
|
|
|
assert_eq!(
|
|
resumed
|
|
.thread
|
|
.config_snapshot()
|
|
.await
|
|
.thread_source
|
|
.as_ref(),
|
|
Some(&ThreadSource::User)
|
|
);
|
|
|
|
resumed
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown resumed thread");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subtree_listing_uses_injected_graph_store_without_state_db() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let root_thread_id = ThreadId::new();
|
|
let descendant_thread_ids = vec![ThreadId::new(), ThreadId::new()];
|
|
let agent_graph_store = Arc::new(FakeAgentGraphStore {
|
|
root_thread_id,
|
|
descendant_thread_ids: descendant_thread_ids.clone(),
|
|
});
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager,
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, /*state_db*/ None),
|
|
Some(agent_graph_store),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let mut expected_thread_ids = vec![root_thread_id];
|
|
expected_thread_ids.extend(descendant_thread_ids);
|
|
assert_eq!(
|
|
manager
|
|
.list_agent_subtree_thread_ids(root_thread_id)
|
|
.await
|
|
.expect("subtree should load from injected graph store"),
|
|
expected_thread_ids
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
config.experimental_thread_store = ThreadStoreConfig::InMemory {
|
|
id: format!("thread-manager-{}", uuid::Uuid::new_v4()),
|
|
};
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let thread_store = thread_store_from_config(&config, state_db.clone());
|
|
let in_memory_store = thread_store
|
|
.as_any()
|
|
.downcast_ref::<InMemoryThreadStore>()
|
|
.expect("configured in-memory store");
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager.clone(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store.clone(),
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.start_thread(config.clone())
|
|
.await
|
|
.expect("start source thread");
|
|
source
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown source thread");
|
|
let _ = manager.remove_thread(&source.thread_id).await;
|
|
|
|
let rollout_path = config
|
|
.codex_home
|
|
.join("rollouts/source.jsonl")
|
|
.to_path_buf();
|
|
let resumed = manager
|
|
.resume_thread_with_history(
|
|
config.clone(),
|
|
InitialHistory::Resumed(ResumedHistory {
|
|
conversation_id: source.thread_id,
|
|
history: Arc::new(vec![RolloutItem::ResponseItem(user_msg("hello"))]),
|
|
rollout_path: Some(rollout_path.clone()),
|
|
}),
|
|
auth_manager.clone(),
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("seed rollout path in store");
|
|
resumed
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown seeded resumed thread");
|
|
let _ = manager.remove_thread(&resumed.thread_id).await;
|
|
|
|
let resumed_from_path = manager
|
|
.resume_thread_from_rollout(
|
|
config.clone(),
|
|
rollout_path.clone(),
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("resume from rollout path");
|
|
assert_eq!(resumed_from_path.thread_id, resumed.thread_id);
|
|
|
|
let forked = manager
|
|
.fork_thread(
|
|
ForkSnapshot::Interrupted,
|
|
config,
|
|
rollout_path,
|
|
/*thread_source*/ None,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
.expect("fork from rollout path");
|
|
assert_ne!(forked.thread_id, resumed.thread_id);
|
|
|
|
let calls = in_memory_store.calls().await;
|
|
assert_eq!(calls.read_thread_by_rollout_path, 2);
|
|
|
|
resumed_from_path
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown path-resumed thread");
|
|
forked
|
|
.thread
|
|
.shutdown_and_wait()
|
|
.await
|
|
.expect("shutdown forked thread");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_uses_active_provider_for_model_refresh() {
|
|
let server = MockServer::start().await;
|
|
let models_mock = mount_models_once(&server, ModelsResponse { models: vec![] }).await;
|
|
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
config.model_catalog = None;
|
|
config.model_provider.base_url = Some(server.uri());
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager,
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, /*state_db*/ None),
|
|
/*agent_graph_store*/ None,
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let _ = manager.list_models(RefreshStrategy::Online).await;
|
|
assert_eq!(models_mock.requests().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn interrupted_fork_snapshot_appends_interrupt_boundary() {
|
|
let committed_history =
|
|
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello"))]);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(
|
|
append_interrupted_boundary(
|
|
committed_history,
|
|
/*turn_id*/ None,
|
|
InterruptedTurnHistoryMarker::ContextualUser,
|
|
)
|
|
.get_rollout_items()
|
|
)
|
|
.expect("serialize interrupted fork history"),
|
|
serde_json::to_value(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello")),
|
|
RolloutItem::ResponseItem(contextual_user_interrupted_marker()),
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
turn_id: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})),
|
|
])
|
|
.expect("serialize expected interrupted fork history"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(
|
|
append_interrupted_boundary(
|
|
InitialHistory::New,
|
|
/*turn_id*/ None,
|
|
InterruptedTurnHistoryMarker::ContextualUser,
|
|
)
|
|
.get_rollout_items()
|
|
)
|
|
.expect("serialize interrupted empty fork history"),
|
|
serde_json::to_value(vec![
|
|
RolloutItem::ResponseItem(contextual_user_interrupted_marker()),
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
turn_id: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})),
|
|
])
|
|
.expect("serialize expected interrupted empty history"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() {
|
|
let committed_history =
|
|
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello"))]);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(
|
|
append_interrupted_boundary(
|
|
committed_history,
|
|
/*turn_id*/ None,
|
|
InterruptedTurnHistoryMarker::Disabled,
|
|
)
|
|
.get_rollout_items()
|
|
)
|
|
.expect("serialize disabled interrupted fork history"),
|
|
serde_json::to_value(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello")),
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
turn_id: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})),
|
|
])
|
|
.expect("serialize expected disabled interrupted fork history"),
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(
|
|
append_interrupted_boundary(
|
|
InitialHistory::New,
|
|
/*turn_id*/ None,
|
|
InterruptedTurnHistoryMarker::Disabled,
|
|
)
|
|
.get_rollout_items()
|
|
)
|
|
.expect("serialize disabled interrupted empty fork history"),
|
|
serde_json::to_value(vec![RolloutItem::EventMsg(EventMsg::TurnAborted(
|
|
TurnAbortedEvent {
|
|
turn_id: None,
|
|
reason: TurnAbortReason::Interrupted,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
))])
|
|
.expect("serialize expected disabled interrupted empty fork history"),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn interrupted_snapshot_is_not_mid_turn() {
|
|
let interrupted_history = InitialHistory::Forked(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello")),
|
|
RolloutItem::ResponseItem(assistant_msg("partial")),
|
|
RolloutItem::ResponseItem(contextual_user_interrupted_marker()),
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
turn_id: Some("turn-1".to_string()),
|
|
reason: TurnAbortReason::Interrupted,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})),
|
|
]);
|
|
|
|
assert_eq!(
|
|
snapshot_turn_state(&interrupted_history),
|
|
SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_agent_v2_interrupted_marker_uses_developer_input_message() {
|
|
let marker = developer_interrupted_marker();
|
|
|
|
let ResponseItem::Message { role, content, .. } = marker else {
|
|
panic!("expected interrupted marker to be a message");
|
|
};
|
|
assert_eq!(role, "developer");
|
|
assert!(
|
|
matches!(
|
|
content.as_slice(),
|
|
[ContentItem::InputText { text }]
|
|
if text.contains(crate::context::TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
|
|
),
|
|
"expected interrupted marker to use developer InputText content"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completed_legacy_event_history_is_not_mid_turn() {
|
|
let completed_history = InitialHistory::Forked(vec![
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: "hello".to_string(),
|
|
images: None,
|
|
text_elements: Vec::new(),
|
|
local_images: Vec::new(),
|
|
..Default::default()
|
|
})),
|
|
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
|
|
message: "done".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
})),
|
|
]);
|
|
|
|
assert_eq!(
|
|
snapshot_turn_state(&completed_history),
|
|
SnapshotTurnState {
|
|
ends_mid_turn: false,
|
|
active_turn_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_response_and_legacy_user_event_history_is_mid_turn() {
|
|
let mixed_history = InitialHistory::Forked(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello")),
|
|
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
|
client_id: None,
|
|
message: "hello".to_string(),
|
|
images: None,
|
|
text_elements: Vec::new(),
|
|
local_images: Vec::new(),
|
|
..Default::default()
|
|
})),
|
|
]);
|
|
|
|
assert_eq!(
|
|
snapshot_turn_state(&mixed_history),
|
|
SnapshotTurnState {
|
|
ends_mid_turn: true,
|
|
active_turn_id: None,
|
|
active_turn_start_index: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager.clone(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, state_db.clone()),
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.resume_thread_with_history(
|
|
config.clone(),
|
|
InitialHistory::Forked(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello")),
|
|
RolloutItem::ResponseItem(assistant_msg("partial")),
|
|
]),
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("create source thread from completed history");
|
|
let source_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
|
.await
|
|
.expect("read source rollout history");
|
|
let source_snapshot_state = snapshot_turn_state(&source_history);
|
|
assert!(source_snapshot_state.ends_mid_turn);
|
|
let expected_turn_id = source_snapshot_state.active_turn_id.clone();
|
|
assert_eq!(expected_turn_id, None);
|
|
|
|
let forked = manager
|
|
.fork_thread(
|
|
ForkSnapshot::Interrupted,
|
|
config.clone(),
|
|
source_path,
|
|
/*thread_source*/ None,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
.expect("fork interrupted snapshot");
|
|
let forked_path = forked
|
|
.thread
|
|
.rollout_path()
|
|
.expect("forked rollout path should exist");
|
|
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
|
.await
|
|
.expect("read forked rollout history");
|
|
assert!(!snapshot_turn_state(&history).ends_mid_turn);
|
|
let rollout_items: Vec<_> = history
|
|
.get_rollout_items()
|
|
.iter()
|
|
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
|
.collect();
|
|
let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem(
|
|
contextual_user_interrupted_marker(),
|
|
))
|
|
.expect("serialize interrupted marker");
|
|
let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg(
|
|
EventMsg::TurnAborted(TurnAbortedEvent {
|
|
turn_id: expected_turn_id,
|
|
reason: TurnAbortReason::Interrupted,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
}),
|
|
))
|
|
.expect("serialize interrupted abort event");
|
|
assert_eq!(
|
|
rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
serde_json::to_value(item).expect("serialize rollout item")
|
|
== interrupted_marker_json
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
assert_eq!(
|
|
rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
serde_json::to_value(item).expect("serialize rollout item")
|
|
== interrupted_abort_json
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager.clone(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, state_db.clone()),
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.resume_thread_with_history(
|
|
config.clone(),
|
|
InitialHistory::Forked(vec![
|
|
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-explicit".to_string(),
|
|
trace_id: None,
|
|
started_at: None,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
})),
|
|
RolloutItem::ResponseItem(user_msg("hello")),
|
|
RolloutItem::ResponseItem(assistant_msg("partial")),
|
|
]),
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("create source thread from explicit partial history");
|
|
let source_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
|
.await
|
|
.expect("read source rollout history");
|
|
let source_snapshot_state = snapshot_turn_state(&source_history);
|
|
assert_eq!(
|
|
source_snapshot_state,
|
|
SnapshotTurnState {
|
|
ends_mid_turn: true,
|
|
active_turn_id: Some("turn-explicit".to_string()),
|
|
active_turn_start_index: Some(1),
|
|
},
|
|
);
|
|
|
|
let forked = manager
|
|
.fork_thread(
|
|
ForkSnapshot::Interrupted,
|
|
config.clone(),
|
|
source_path,
|
|
/*thread_source*/ None,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
.expect("fork interrupted snapshot");
|
|
let forked_path = forked
|
|
.thread
|
|
.rollout_path()
|
|
.expect("forked rollout path should exist");
|
|
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
|
.await
|
|
.expect("read forked rollout history");
|
|
let rollout_items: Vec<_> = history
|
|
.get_rollout_items()
|
|
.iter()
|
|
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
|
.collect();
|
|
|
|
assert!(rollout_items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
turn_id: Some(turn_id),
|
|
reason: TurnAbortReason::Interrupted,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
})) if turn_id == "turn-explicit"
|
|
)
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() {
|
|
let temp_dir = tempdir().expect("tempdir");
|
|
let mut config = test_config().await;
|
|
config.codex_home = temp_dir.path().join("codex-home").abs();
|
|
config.cwd = config.codex_home.abs();
|
|
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
|
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
|
let state_db = init_state_db(&config).await;
|
|
let manager = ThreadManager::new(
|
|
&config,
|
|
auth_manager.clone(),
|
|
SessionSource::Exec,
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
empty_extension_registry(),
|
|
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
|
|
/*analytics_events_client*/ None,
|
|
thread_store_from_config(&config, state_db.clone()),
|
|
local_agent_graph_store_from_state_db(state_db.as_ref()),
|
|
TEST_INSTALLATION_ID.to_string(),
|
|
/*attestation_provider*/ None,
|
|
/*external_time_provider*/ None,
|
|
);
|
|
|
|
let source = manager
|
|
.resume_thread_with_history(
|
|
config.clone(),
|
|
InitialHistory::Forked(vec![
|
|
RolloutItem::ResponseItem(user_msg("hello")),
|
|
RolloutItem::ResponseItem(assistant_msg("partial")),
|
|
]),
|
|
auth_manager,
|
|
/*parent_trace*/ None,
|
|
/*supports_openai_form_elicitation*/ false,
|
|
)
|
|
.await
|
|
.expect("create source thread from partial history");
|
|
let source_path = source
|
|
.thread
|
|
.rollout_path()
|
|
.expect("source rollout path should exist");
|
|
let source_history = RolloutRecorder::get_rollout_history(&source_path)
|
|
.await
|
|
.expect("read source rollout history");
|
|
assert!(snapshot_turn_state(&source_history).ends_mid_turn);
|
|
manager.remove_thread(&source.thread_id).await;
|
|
|
|
let forked = manager
|
|
.fork_thread(
|
|
ForkSnapshot::Interrupted,
|
|
config.clone(),
|
|
source_path,
|
|
/*thread_source*/ None,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
.expect("fork interrupted snapshot");
|
|
let forked_path = forked
|
|
.thread
|
|
.rollout_path()
|
|
.expect("forked rollout path should exist");
|
|
let history = RolloutRecorder::get_rollout_history(&forked_path)
|
|
.await
|
|
.expect("read forked rollout history");
|
|
assert!(!snapshot_turn_state(&history).ends_mid_turn);
|
|
|
|
let forked_rollout_items: Vec<_> = history
|
|
.get_rollout_items()
|
|
.iter()
|
|
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
|
.collect();
|
|
let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem(
|
|
contextual_user_interrupted_marker(),
|
|
))
|
|
.expect("serialize interrupted marker");
|
|
assert_eq!(
|
|
forked_rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
serde_json::to_value(item).expect("serialize forked rollout item")
|
|
== interrupted_marker_json
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
|
|
manager.remove_thread(&forked.thread_id).await;
|
|
let reforked = manager
|
|
.fork_thread(
|
|
ForkSnapshot::Interrupted,
|
|
config.clone(),
|
|
forked_path,
|
|
/*thread_source*/ None,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
.expect("re-fork interrupted snapshot");
|
|
let reforked_path = reforked
|
|
.thread
|
|
.rollout_path()
|
|
.expect("re-forked rollout path should exist");
|
|
let reforked_history = RolloutRecorder::get_rollout_history(&reforked_path)
|
|
.await
|
|
.expect("read re-forked rollout history");
|
|
let reforked_rollout_items: Vec<_> = reforked_history
|
|
.get_rollout_items()
|
|
.iter()
|
|
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
reforked_rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
serde_json::to_value(item).expect("serialize re-forked rollout item")
|
|
== interrupted_marker_json
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
assert_eq!(
|
|
reforked_rollout_items
|
|
.iter()
|
|
.filter(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
|
|
reason: TurnAbortReason::Interrupted,
|
|
..
|
|
}))
|
|
)
|
|
})
|
|
.count(),
|
|
1,
|
|
);
|
|
}
|