diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ee110f56e2..3db45129e4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -189,6 +189,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "uuid", "wiremock", ] diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index ece8174dd9..176ea956f5 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -22,3 +22,4 @@ tokio = { workspace = true, features = [ "rt-multi-thread", ] } wiremock = { workspace = true } +uuid = { workspace = true } diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 951005b07b..dc3d24ccae 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -2,6 +2,7 @@ mod auth_fixtures; mod mcp_process; mod mock_model_server; mod responses; +mod rollout; pub use auth_fixtures::ChatGptAuthFixture; pub use auth_fixtures::ChatGptIdTokenClaims; @@ -14,6 +15,7 @@ pub use mock_model_server::create_mock_chat_completions_server_unchecked; pub use responses::create_apply_patch_sse_response; pub use responses::create_final_assistant_message_sse_response; pub use responses::create_shell_sse_response; +pub use rollout::create_fake_rollout; use serde::de::DeserializeOwned; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs new file mode 100644 index 0000000000..6537bb59a0 --- /dev/null +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -0,0 +1,77 @@ +use anyhow::Result; +use serde_json::json; +use std::fs; +use std::path::Path; +use uuid::Uuid; + +/// Create a minimal rollout file under `CODEX_HOME/sessions/YYYY/MM/DD/`. +/// +/// - `filename_ts` is the filename timestamp component in `YYYY-MM-DDThh-mm-ss` format. +/// - `meta_rfc3339` is the envelope timestamp used in JSON lines. +/// - `preview` is the user message preview text. +/// - `model_provider` optionally sets the provider in the session meta payload. +/// +/// Returns the generated conversation/session UUID as a string. +pub fn create_fake_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, +) -> Result { + let uuid = Uuid::new_v4(); + + // sessions/YYYY/MM/DD derived from filename_ts (YYYY-MM-DDThh-mm-ss) + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + let dir = codex_home.join("sessions").join(year).join(month).join(day); + fs::create_dir_all(&dir)?; + + let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); + + // Build JSONL lines + let mut payload = json!({ + "id": uuid, + "timestamp": meta_rfc3339, + "cwd": "/", + "originator": "codex", + "cli_version": "0.0.0", + "instructions": null, + }); + if let Some(provider) = model_provider { + payload["model_provider"] = json!(provider); + } + + let lines = [ + json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": payload + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type":"response_item", + "payload": { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text": preview}] + } + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type":"event_msg", + "payload": { + "type":"user_message", + "message": preview, + "kind": "plain" + } + }) + .to_string(), + ]; + + fs::write(file_path, lines.join("\n") + "\n")?; + Ok(uuid.to_string()) +} diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index 76b92b3edc..30be93a2e6 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -1,5 +1,6 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -15,12 +16,8 @@ use codex_core::protocol::EventMsg; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; -use serde_json::json; -use std::fs; -use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; -use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -357,70 +354,3 @@ async fn test_list_and_resume_conversations() -> Result<()> { Ok(()) } - -fn create_fake_rollout( - codex_home: &Path, - filename_ts: &str, - meta_rfc3339: &str, - preview: &str, - model_provider: Option<&str>, -) -> Result<()> { - let uuid = Uuid::new_v4(); - // sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss) - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let dir = codex_home.join("sessions").join(year).join(month).join(day); - fs::create_dir_all(&dir)?; - - let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); - let mut lines = Vec::new(); - // Meta line with timestamp (flattened meta in payload for new schema) - let mut payload = json!({ - "id": uuid, - "timestamp": meta_rfc3339, - "cwd": "/", - "originator": "codex", - "cli_version": "0.0.0", - "instructions": null, - }); - if let Some(provider) = model_provider { - payload["model_provider"] = json!(provider); - } - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type": "session_meta", - "payload": payload - }) - .to_string(), - ); - // Minimal user message entry as a persisted response item (with envelope timestamp) - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type":"response_item", - "payload": { - "type":"message", - "role":"user", - "content":[{"type":"input_text","text": preview}] - } - }) - .to_string(), - ); - // Add a matching user message event line to satisfy filters - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type":"event_msg", - "payload": { - "type":"user_message", - "message": preview, - "kind": "plain" - } - }) - .to_string(), - ); - fs::write(file_path, lines.join("\n") + "\n")?; - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index ce8b69a030..c4c531fc69 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -7,7 +7,6 @@ mod fuzzy_file_search; mod interrupt; mod list_resume; mod login; -mod model_list; mod rate_limits; mod send_message; mod set_default_model; diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 2be6824d26..20e1de7f48 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,3 +1,4 @@ +mod model_list; mod thread_archive; mod thread_list; mod thread_resume; diff --git a/codex-rs/app-server/tests/suite/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs similarity index 97% rename from codex-rs/app-server/tests/suite/model_list.rs rename to codex-rs/app-server/tests/suite/v2/model_list.rs index 71bcda36c7..cb5b4c693f 100644 --- a/codex-rs/app-server/tests/suite/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -19,7 +19,7 @@ use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -103,7 +103,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -156,7 +156,7 @@ async fn list_models_pagination_works() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs index 345580ca32..083f3da901 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_archive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -15,7 +15,7 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn thread_archive_moves_rollout_into_archived_directory() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 32cc946de7..25a15a9385 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1,5 +1,6 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -12,7 +13,7 @@ use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn thread_list_basic_empty() -> Result<()> { let codex_home = TempDir::new()?; create_minimal_config(codex_home.path())?; @@ -45,71 +46,14 @@ fn create_minimal_config(codex_home: &std::path::Path) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, - "model = \"mock-model\"\napproval_policy = \"never\"\n", + r#" +model = "mock-model" +approval_policy = "never" +"#, ) } -fn create_fake_rollout( - codex_home: &std::path::Path, - filename_ts: &str, - meta_rfc3339: &str, -) -> Result { - let uuid = Uuid::new_v4(); - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let dir = codex_home.join("sessions").join(year).join(month).join(day); - std::fs::create_dir_all(&dir)?; - - let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); - let mut lines = Vec::new(); - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type": "session_meta", - "payload": { - "id": uuid, - "timestamp": meta_rfc3339, - "cwd": "/", - "originator": "codex", - "cli_version": "0.0.0", - "instructions": null, - "source": "vscode", - "model_provider": "mock_provider" - } - }) - .to_string(), - ); - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type":"response_item", - "payload": { - "type":"message", - "role":"user", - "content":[{"type":"input_text","text": "Hello"}] - } - }) - .to_string(), - ); - // Add a matching user_message event so the scanner includes this rollout. - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type":"event_msg", - "payload": { - "type":"user_message", - "message":"Hello", - "kind":"plain" - } - }) - .to_string(), - ); - std::fs::write(file_path, lines.join("\n") + "\n")?; - Ok(uuid.to_string()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { let codex_home = TempDir::new()?; create_minimal_config(codex_home.path())?; @@ -119,16 +63,22 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { codex_home.path(), "2025-01-02T12-00-00", "2025-01-02T12:00:00Z", + "Hello", + Some("mock_provider"), )?; let _b = create_fake_rollout( codex_home.path(), "2025-01-01T13-00-00", "2025-01-01T13:00:00Z", + "Hello", + Some("mock_provider"), )?; let _c = create_fake_rollout( codex_home.path(), "2025-01-01T12-00-00", "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -180,7 +130,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn thread_list_respects_provider_filter() -> Result<()> { let codex_home = TempDir::new()?; create_minimal_config(codex_home.path())?; @@ -190,6 +140,8 @@ async fn thread_list_respects_provider_filter() -> Result<()> { codex_home.path(), "2025-01-02T10-00-00", "2025-01-02T10:00:00Z", + "X", + Some("mock_provider"), )?; // mock_provider // one with a different provider let uuid = Uuid::new_v4(); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index bde3f8d982..201ef34f5a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -13,7 +13,7 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn thread_resume_returns_existing_thread() -> Result<()> { let server = create_mock_chat_completions_server(vec![]).await; let codex_home = TempDir::new()?; @@ -25,7 +25,7 @@ async fn thread_resume_returns_existing_thread() -> Result<()> { // Start a thread. let start_id = mcp .send_thread_start_request(ThreadStartParams { - model: Some("o3".to_string()), + model: Some("gpt-5-codex".to_string()), ..Default::default() }) .await?; @@ -63,7 +63,7 @@ fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io r#" model = "mock-model" approval_policy = "never" -sandbox_mode = "danger-full-access" +sandbox_mode = "workspace-write" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index da25b5eb03..5f719265c0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -15,7 +15,7 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn thread_start_creates_thread_and_emits_started() -> Result<()> { // Provide a mock server and config so model wiring is valid. let server = create_mock_chat_completions_server(vec![]).await; @@ -55,7 +55,6 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread.id, thread.id); - drop(server); Ok(()) } @@ -68,7 +67,7 @@ fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<() r#" model = "mock-model" approval_policy = "never" -sandbox_mode = "danger-full-access" +sandbox_mode = "workspace-write" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 971d5a4a6e..6b8d200b06 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -19,7 +19,7 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn turn_interrupt_aborts_running_turn() -> Result<()> { // Use a portable sleep command to keep the turn running. #[cfg(target_os = "windows")] @@ -104,8 +104,6 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let _resp: TurnInterruptResponse = to_response::(interrupt_resp)?; // No fields to assert on; successful deserialization confirms proper response shape. - - drop(server); Ok(()) } @@ -118,7 +116,7 @@ fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io r#" model = "mock-model" approval_policy = "never" -sandbox_mode = "danger-full-access" +sandbox_mode = "workspace-write" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 0b4edc527a..7c5bb5fd81 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -21,6 +21,7 @@ use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::env; use std::path::Path; @@ -29,7 +30,7 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> { // Provide a mock server and config so model wiring is valid. // Three Codex turns hit the mock model (session start + two turn/start calls). @@ -136,11 +137,10 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( ) .await??; - drop(server); Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn turn_start_accepts_local_image_input() -> Result<()> { // Two Codex turns hit the mock model (session start + turn/start). let responses = vec![ @@ -194,12 +194,10 @@ async fn turn_start_accepts_local_image_input() -> Result<()> { assert!(!turn.id.is_empty()); // This test only validates that turn/start responds and returns a turn. - - drop(server); Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn turn_start_exec_approval_toggle_v2() -> Result<()> { if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { println!("Skipping v2 exec approval toggle test due to sandbox network disabled."); @@ -335,16 +333,14 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { ) .await??; - drop(server); Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { - if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!("Skipping v2 sandbox/cwd test due to sandbox network disabled."); - return Ok(()); - } + // When returning Result from a test, pass an Ok(()) to the skip macro + // so the early return type matches. The no-arg form returns unit. + skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); @@ -431,7 +427,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { ) .await??; - // second turn with danger-full-access and second_cwd, ensure exec begins in second_cwd + // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd let second_turn = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), @@ -482,7 +478,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { ) .await??; - drop(server); Ok(()) } @@ -495,7 +490,7 @@ fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<() r#" model = "mock-model" approval_policy = "never" -sandbox_mode = "danger-full-access" +sandbox_mode = "workspace-write" model_provider = "mock_provider" @@ -518,7 +513,7 @@ fn create_config_toml_untrusted(codex_home: &Path, server_uri: &str) -> std::io: r#" model = "mock-model" approval_policy = "untrusted" -sandbox_mode = "danger-full-access" +sandbox_mode = "workspace-write" model_provider = "mock_provider"