diff --git a/src/harness/claude_code.rs b/src/harness/claude_code.rs index 15417e7..72cf8c5 100644 --- a/src/harness/claude_code.rs +++ b/src/harness/claude_code.rs @@ -482,12 +482,46 @@ impl Store for ClaudeStore { } fn save(&self, transcript: &Transcript) -> Result> { - let id = transcript.meta.id.clone(); + let id = if transcript.meta.id.is_empty() { + Uuid::new_v4().to_string() + } else { + transcript.meta.id.clone() + }; super::checked_id_component(ClaudeCode::NAME, &id)?; let cwd = transcript.meta.cwd.as_deref().unwrap_or_default(); let dir = self.root.join(encode_project_dir(cwd)); fs::create_dir_all(&dir)?; let path = dir.join(format!("{id}.jsonl")); + // If we synthesised a fresh id, stamp it into the transcript so the + // `sessionId` on every JSONL line matches the filename. Without this + // the file and its content would carry two different UUIDs and Claude + // Code would fail to resume the session. + let stamped; + let transcript = if transcript.meta.id == id { + transcript + } else { + let mut body = transcript.body.clone(); + for record in &mut body { + match record { + Record::User(entry) | Record::Assistant(entry) => { + entry.session_id = Some(id.clone()); + } + Record::Other(Value::Object(map)) if map.contains_key("sessionId") => { + map.insert("sessionId".to_string(), Value::String(id.clone())); + } + _ => {} + } + } + stamped = Transcript::new( + { + let mut m = transcript.meta.clone(); + m.id.clone_from(&id); + m + }, + body, + ); + &stamped + }; fs::write(&path, ClaudeCode::to_text(transcript)?)?; Ok(Saved { id, diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 31ddbe4..df781e4 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -26,6 +26,7 @@ use crate::common::{Block, ImageSource, Message, Meta, Role, Tool, ToolOutput, U use crate::error::{Error, Result}; use crate::harness::jsonl; use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; +use uuid::Uuid; /// The Codex harness marker. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -972,7 +973,11 @@ impl Store for CodexStore { } fn save(&self, transcript: &Transcript) -> Result> { - let id = transcript.meta.id.clone(); + let id = if transcript.meta.id.is_empty() { + Uuid::new_v4().to_string() + } else { + transcript.meta.id.clone() + }; super::checked_id_component(Codex::NAME, &id)?; let t = &transcript.meta.timestamp; let dir = self @@ -983,6 +988,50 @@ impl Store for CodexStore { fs::create_dir_all(&dir)?; let compact = t.format("%Y-%m-%dT%H-%M-%S").to_string(); let path = dir.join(format!("rollout-{compact}-{id}.jsonl")); + // Stamp the resolved id into the transcript so the session_meta line + // inside the rollout carries the same id as the filename. Without this + // a synthesised UUID in the filename and an empty string in the file + // would prevent Codex from resuming the session. + let stamped; + let transcript = if transcript.meta.id == id { + transcript + } else { + let mut body = transcript.body.clone(); + let mut found_session_meta = false; + for line in &mut body { + if line.kind == "session_meta" + && let Value::Object(map) = &mut line.payload + { + map.insert("id".to_string(), Value::String(id.clone())); + found_session_meta = true; + } + } + if !found_session_meta { + let payload = json!({ + "id": id, + "timestamp": transcript.meta.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true), + "cwd": transcript.meta.cwd.clone().unwrap_or_default(), + "originator": "codex_cli_rs", + "cli_version": transcript.meta.cli_version.clone().unwrap_or_default(), + "source": "cli", + "model_provider": "openai", + "base_instructions": Value::Null, + }); + body.insert( + 0, + meta_line(&transcript.meta.timestamp, "session_meta", payload), + ); + } + stamped = Transcript::new( + { + let mut m = transcript.meta.clone(); + m.id.clone_from(&id); + m + }, + body, + ); + &stamped + }; fs::write(&path, Codex::to_text(transcript)?)?; Ok(Saved { id, diff --git a/src/harness/pi.rs b/src/harness/pi.rs index 5bc0bfc..90555b2 100644 --- a/src/harness/pi.rs +++ b/src/harness/pi.rs @@ -576,9 +576,14 @@ pub(crate) fn write_session( meta: &Meta, records: &[Record], ) -> Result> { - let id = meta.id.clone(); // Callers are the pi and campfire stores; either way the id is the - // transcript's own. + // transcript's own. If the transcript has no id yet, mint a fresh UUID + // so the session header and the filename are consistent. + let id = if meta.id.is_empty() { + Uuid::new_v4().to_string() + } else { + meta.id.clone() + }; super::checked_id_component("pi", &id)?; let cwd = meta.cwd.as_deref().unwrap_or_default(); let dir = sessions_dir.join(encode_cwd(cwd)); @@ -588,7 +593,18 @@ pub(crate) fn write_session( .to_rfc3339_opts(SecondsFormat::Millis, true) .replace([':', '.'], "-"); let path = dir.join(format!("{file_ts}_{id}.jsonl")); - fs::write(&path, jsonl::render(records)?)?; + // If we minted a fresh id, patch the session header in the records so the + // id field inside the file matches the filename. A mismatch would confuse + // pi's `--continue` lookup which keys on the in-file session id. + if meta.id == id { + fs::write(&path, jsonl::render(records)?)?; + } else { + let mut patched: Vec = records.to_vec(); + if let Some(Record::Session(header)) = patched.first_mut() { + header.id.clone_from(&id); + } + fs::write(&path, jsonl::render(&patched)?)?; + } Ok(Saved { id, reference: path, diff --git a/src/local.rs b/src/local.rs index d4fb85f..3bbea04 100644 --- a/src/local.rs +++ b/src/local.rs @@ -862,14 +862,25 @@ fn materialize_artifacts_for_claude_code( ) -> Result> { const MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024; - crate::harness::checked_id_component(claude_code::ClaudeCode::NAME, &common.meta.id)?; + // Resolve the session id up front: if the transcript carries no id yet, + // mint a fresh UUID here so the artifact directory and the eventual + // session file both land under the same name. + let session_id = if common.meta.id.is_empty() { + uuid::Uuid::new_v4().to_string() + } else { + common.meta.id.clone() + }; + crate::harness::checked_id_component(claude_code::ClaudeCode::NAME, &session_id)?; let artifact_root = projects_root .join(claude_code::encode_project_dir( common.meta.cwd.as_deref().unwrap_or_default(), )) - .join(&common.meta.id) + .join(&session_id) .join("artifacts"); let mut prepared = common.clone(); + // Stamp the resolved id so the caller (ClaudeStore::save) and every + // downstream codec see the same session identity. + prepared.meta.id = session_id; for (message_index, message) in prepared.body.iter_mut().enumerate() { for (block_index, block) in message.content.iter_mut().enumerate() { let Block::Artifact { artifact } = block else { diff --git a/tests/integration/claude_code.rs b/tests/integration/claude_code.rs index ceb835a..c26f68b 100644 --- a/tests/integration/claude_code.rs +++ b/tests/integration/claude_code.rs @@ -91,6 +91,27 @@ fn store_round_trip_is_lossless_on_disk() { assert!(saved.reference.ends_with("sess-1.jsonl")); } +#[test] +fn store_save_empty_id_synthesizes_uuid() { + let dir = tempfile::tempdir().unwrap(); + let store = claude_code::ClaudeStore::new(dir.path()); + + let src = dir.path().join("orig.jsonl"); + std::fs::write(&src, sample_jsonl()).unwrap(); + + let mut loaded = store.load(&src).unwrap(); + loaded.meta.id = String::new(); + + let saved = store.save(&loaded).unwrap(); + assert!(!saved.id.is_empty()); + assert!(saved.reference.exists()); + assert!(saved.reference.ends_with(format!("{}.jsonl", saved.id))); + uuid::Uuid::parse_str(&saved.id).unwrap(); + + let reloaded = store.load(&saved.reference).unwrap(); + assert_eq!(reloaded.meta.id, saved.id); +} + #[test] fn windows_cwd_encodes_the_project_dir() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/integration/codex.rs b/tests/integration/codex.rs index 653fa4d..0091626 100644 --- a/tests/integration/codex.rs +++ b/tests/integration/codex.rs @@ -336,6 +336,31 @@ fn codec_fixpoint_through_common_loses_nothing() { assert_eq!(common, back); } +#[test] +fn store_save_empty_id_synthesizes_uuid() { + let dir = tempfile::tempdir().unwrap(); + let store = codex::CodexStore::new(dir.path()); + let src = dir.path().join("rollout-x.jsonl"); + std::fs::write(&src, exercise_rollout()).unwrap(); + + let mut loaded = store.load(&src).unwrap(); + loaded.meta.id = String::new(); + + let saved = store.save(&loaded).unwrap(); + assert!(!saved.id.is_empty()); + assert!(saved.reference.exists()); + assert!( + saved + .reference + .to_string_lossy() + .ends_with(&format!("{}.jsonl", saved.id)) + ); + uuid::Uuid::parse_str(&saved.id).unwrap(); + + let reloaded = store.load(&saved.reference).unwrap(); + assert_eq!(reloaded.meta.id, saved.id); +} + #[test] fn from_common_denormalizes_bash_to_exec_command() { let mut common = sample_common(); diff --git a/tests/integration/pi.rs b/tests/integration/pi.rs index 9108f02..8b287ef 100644 --- a/tests/integration/pi.rs +++ b/tests/integration/pi.rs @@ -67,6 +67,32 @@ fn store_round_trip_is_lossless_on_disk() { assert!(saved.reference.to_string_lossy().contains("--repo--")); } +#[test] +fn store_save_empty_id_synthesizes_uuid() { + let dir = tempfile::tempdir().unwrap(); + let store = pi::PiStore::new(dir.path()); + + let src = dir.path().join("orig.jsonl"); + std::fs::write(&src, sample_jsonl()).unwrap(); + + let mut loaded = store.load(&src).unwrap(); + loaded.meta.id = String::new(); + + let saved = store.save(&loaded).unwrap(); + assert!(!saved.id.is_empty()); + assert!(saved.reference.exists()); + assert!( + saved + .reference + .to_string_lossy() + .ends_with(&format!("{}.jsonl", saved.id)) + ); + uuid::Uuid::parse_str(&saved.id).unwrap(); + + let reloaded = store.load(&saved.reference).unwrap(); + assert_eq!(reloaded.meta.id, saved.id); +} + #[test] fn discover_extracts_metadata() { let dir = tempfile::tempdir().unwrap();