use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::ThreadUnarchiveResponse; use codex_app_server_protocol::ThreadUnarchivedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; use pretty_assertions::assert_eq; use serde_json::Value; use std::fs::FileTimes; use std::fs::OpenOptions; use std::path::Path; use std::time::Duration; use std::time::SystemTime; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); #[tokio::test] async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) .await?; let start_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(start_id)), ) .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; let rollout_path = thread.path.clone().expect("thread path"); let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), input: vec![UserInput::Text { text: "materialize".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; let turn_start_response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)), ) .await??; let _: TurnStartResponse = to_response::(turn_start_response)?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; let found_rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id) .await? .expect("expected rollout path for thread id to exist"); assert_paths_match_on_disk(&found_rollout_path, &rollout_path)?; let archive_id = mcp .send_thread_archive_request(ThreadArchiveParams { thread_id: thread.id.clone(), }) .await?; let archive_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), ) .await??; let _: ThreadArchiveResponse = to_response::(archive_resp)?; let archived_path = find_archived_thread_path_by_id_str(codex_home.path(), &thread.id) .await? .expect("expected archived rollout path for thread id to exist"); let archived_path_display = archived_path.display(); assert!( archived_path.exists(), "expected {archived_path_display} to exist" ); let old_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1); let old_timestamp = old_time .duration_since(SystemTime::UNIX_EPOCH) .expect("old timestamp") .as_secs() as i64; let times = FileTimes::new().set_modified(old_time); OpenOptions::new() .append(true) .open(&archived_path)? .set_times(times)?; let unarchive_id = mcp .send_thread_unarchive_request(ThreadUnarchiveParams { thread_id: thread.id.clone(), }) .await?; let unarchive_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(unarchive_id)), ) .await??; let unarchive_result = unarchive_resp.result.clone(); let ThreadUnarchiveResponse { thread: unarchived_thread, } = to_response::(unarchive_resp)?; let unarchive_notification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("thread/unarchived"), ) .await??; let unarchived_notification: ThreadUnarchivedNotification = serde_json::from_value( unarchive_notification .params .expect("thread/unarchived notification params"), )?; assert_eq!(unarchived_notification.thread_id, thread.id); assert!( unarchived_thread.updated_at > old_timestamp, "expected updated_at to be bumped on unarchive" ); assert_eq!(unarchived_thread.status, ThreadStatus::NotLoaded); // Wire contract: thread title field is `name`, serialized as null when unset. let thread_json = unarchive_result .get("thread") .and_then(Value::as_object) .expect("thread/unarchive result.thread must be an object"); assert_eq!(unarchived_thread.name, None); assert_eq!( thread_json.get("name"), Some(&Value::Null), "thread/unarchive must serialize `name: null` when unset" ); let rollout_path_display = rollout_path.display(); assert!( rollout_path.exists(), "expected rollout path {rollout_path_display} to be restored" ); assert!( !archived_path.exists(), "expected archived rollout path {archived_path_display} to be moved" ); Ok(()) } fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write(config_toml, config_contents(server_uri)) } fn config_contents(server_uri: &str) -> String { format!( r#"model = "mock-model" approval_policy = "never" sandbox_mode = "read-only" model_provider = "mock_provider" [model_providers.mock_provider] name = "Mock provider for test" base_url = "{server_uri}/v1" wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 "# ) } fn assert_paths_match_on_disk(actual: &Path, expected: &Path) -> std::io::Result<()> { let actual = actual.canonicalize()?; let expected = expected.canonicalize()?; assert_eq!(actual, expected); Ok(()) }