diff --git a/src/harness/claude_code.rs b/src/harness/claude_code.rs index 5466689..0b178b0 100644 --- a/src/harness/claude_code.rs +++ b/src/harness/claude_code.rs @@ -545,12 +545,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 3cc4402..dfc0440 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -32,6 +32,7 @@ use crate::common::{ 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)] @@ -1020,7 +1021,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 @@ -1031,6 +1036,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 5105966..5d3c40f 100644 --- a/src/harness/pi.rs +++ b/src/harness/pi.rs @@ -589,9 +589,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)); @@ -601,7 +606,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 916a4fa..6f869c3 100644 --- a/src/local.rs +++ b/src/local.rs @@ -941,14 +941,24 @@ fn materialize_artifacts_for_claude_code( common: &Transcript, projects_root: &Path, ) -> Result> { - 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"); - materialize_artifacts(common, &artifact_root) + let mut stamped = common.clone(); + stamped.meta.id = session_id; + materialize_artifacts(&stamped, &artifact_root) } fn materialize_artifacts(common: &Transcript, root: &Path) -> Result> { diff --git a/tests/integration/claude_code.rs b/tests/integration/claude_code.rs index 0e6033f..d8c9812 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 c437a45..acdc310 100644 --- a/tests/integration/codex.rs +++ b/tests/integration/codex.rs @@ -395,6 +395,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_preserves_interleaved_block_ordering() { let meta = common::Meta { diff --git a/tests/integration/pi.rs b/tests/integration/pi.rs index cb39cef..d008274 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();