diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e59dbfa2..b1aa13a1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -683,6 +683,7 @@ dependencies = [ "tree-sitter", "tree-sitter-bash", "uuid", + "walkdir", "wildmatch", "wiremock", ] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index c55d7d39..ff066cc5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -65,4 +65,5 @@ predicates = "3" pretty_assertions = "1.4.1" tempfile = "3" tokio-test = "0.4" +walkdir = "2.5.0" wiremock = "0.6" diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index c18a58df..0ff2e94a 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -153,13 +153,15 @@ struct LogFileInfo { } fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result { - // Resolve ~/.codex/sessions and create it if missing. - let mut dir = config.codex_home.clone(); - dir.push(SESSIONS_SUBDIR); - fs::create_dir_all(&dir)?; - + // Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing. let timestamp = OffsetDateTime::now_local() .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?; + let mut dir = config.codex_home.clone(); + dir.push(SESSIONS_SUBDIR); + dir.push(timestamp.year().to_string()); + dir.push(format!("{:02}", u8::from(timestamp.month()))); + dir.push(format!("{:02}", timestamp.day())); + fs::create_dir_all(&dir)?; // Custom format for YYYY-MM-DDThh-mm-ss. Use `-` instead of `:` for // compatibility with filesystems that do not allow colons in filenames. diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs index 9ef042eb..3669b93f 100644 --- a/codex-rs/core/tests/cli_stream.rs +++ b/codex-rs/core/tests/cli_stream.rs @@ -2,7 +2,12 @@ use assert_cmd::Command as AssertCommand; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use serde_json::Value; +use std::time::Duration; +use std::time::Instant; use tempfile::TempDir; +use uuid::Uuid; +use walkdir::WalkDir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -117,3 +122,154 @@ async fn responses_api_stream_cli() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("fixture hello")); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn integration_creates_and_checks_session_file() { + // Honor sandbox network restrictions for CI parity with the other tests. + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + // 1. Temp home so we read/write isolated session files. + let home = TempDir::new().unwrap(); + + // 2. Unique marker we'll look for in the session log. + let marker = format!("integration-test-{}", Uuid::new_v4()); + let prompt = format!("echo {marker}"); + + // 3. Use the same offline SSE fixture as responses_api_stream_cli so the test is hermetic. + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli_responses_fixture.sse"); + + // 4. Run the codex CLI through cargo (ensures the right bin is built) and invoke `exec`, + // which is what records a session. + let mut cmd = AssertCommand::new("cargo"); + cmd.arg("run") + .arg("-p") + .arg("codex-cli") + .arg("--quiet") + .arg("--") + .arg("exec") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg(&prompt); + cmd.env("CODEX_HOME", home.path()) + .env("OPENAI_API_KEY", "dummy") + .env("CODEX_RS_SSE_FIXTURE", &fixture) + // Required for CLI arg parsing even though fixture short-circuits network usage. + .env("OPENAI_BASE_URL", "http://unused.local"); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "codex-cli exec failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // 5. Sessions are written asynchronously; wait briefly for the directory to appear. + let sessions_dir = home.path().join("sessions"); + let start = Instant::now(); + while !sessions_dir.exists() && start.elapsed() < Duration::from_secs(2) { + std::thread::sleep(Duration::from_millis(50)); + } + + // 6. Scan all session files and find the one that contains our marker. + let mut matching_files = vec![]; + for entry in WalkDir::new(&sessions_dir) { + let entry = entry.unwrap(); + if entry.file_type().is_file() && entry.file_name().to_string_lossy().ends_with(".jsonl") { + let path = entry.path(); + let content = std::fs::read_to_string(path).unwrap(); + let mut lines = content.lines(); + // Skip SessionMeta (first line) + let _ = lines.next(); + for line in lines { + let item: Value = serde_json::from_str(line).unwrap(); + if let Some("message") = item.get("type").and_then(|t| t.as_str()) { + if let Some(content) = item.get("content") { + if content.to_string().contains(&marker) { + matching_files.push(path.to_owned()); + break; + } + } + } + } + } + } + assert_eq!( + matching_files.len(), + 1, + "Expected exactly one session file containing the marker, found {}", + matching_files.len() + ); + let path = &matching_files[0]; + + // 7. Verify directory structure: sessions/YYYY/MM/DD/filename.jsonl + let rel = match path.strip_prefix(&sessions_dir) { + Ok(r) => r, + Err(_) => panic!("session file should live under sessions/"), + }; + let comps: Vec = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + comps.len(), + 4, + "Expected sessions/YYYY/MM/DD/, got {rel:?}" + ); + let year = &comps[0]; + let month = &comps[1]; + let day = &comps[2]; + assert!( + year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()), + "Year dir not 4-digit numeric: {year}" + ); + assert!( + month.len() == 2 && month.chars().all(|c| c.is_ascii_digit()), + "Month dir not zero-padded 2-digit numeric: {month}" + ); + assert!( + day.len() == 2 && day.chars().all(|c| c.is_ascii_digit()), + "Day dir not zero-padded 2-digit numeric: {day}" + ); + // Range checks (best-effort; won't fail on leading zeros) + if let Ok(m) = month.parse::() { + assert!((1..=12).contains(&m), "Month out of range: {m}"); + } + if let Ok(d) = day.parse::() { + assert!((1..=31).contains(&d), "Day out of range: {d}"); + } + + // 8. Parse SessionMeta line and basic sanity checks. + let content = std::fs::read_to_string(path).unwrap(); + let mut lines = content.lines(); + let meta: Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + assert!(meta.get("id").is_some(), "SessionMeta missing id"); + assert!( + meta.get("timestamp").is_some(), + "SessionMeta missing timestamp" + ); + + // 9. Confirm at least one message contains the marker. + let mut found_message = false; + for line in lines { + let item: Value = serde_json::from_str(line).unwrap(); + if item.get("type").map(|t| t == "message").unwrap_or(false) { + if let Some(content) = item.get("content") { + if content.to_string().contains(&marker) { + found_message = true; + break; + } + } + } + } + assert!( + found_message, + "No message found in session file containing the marker" + ); +}