Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/harness/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,12 +482,46 @@ impl Store for ClaudeStore {
}

fn save(&self, transcript: &Transcript<ClaudeCode>) -> Result<Saved<PathBuf>> {
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,
Expand Down
51 changes: 50 additions & 1 deletion src/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -972,7 +973,11 @@ impl Store for CodexStore {
}

fn save(&self, transcript: &Transcript<Codex>) -> Result<Saved<PathBuf>> {
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
Expand All @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions src/harness/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,9 +576,14 @@ pub(crate) fn write_session(
meta: &Meta,
records: &[Record],
) -> Result<Saved<PathBuf>> {
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));
Expand All @@ -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<Record> = 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,
Expand Down
15 changes: 13 additions & 2 deletions src/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -862,14 +862,25 @@ fn materialize_artifacts_for_claude_code(
) -> Result<Transcript<Common>> {
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 {
Expand Down
21 changes: 21 additions & 0 deletions tests/integration/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions tests/integration/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 26 additions & 0 deletions tests/integration/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading