Skip to content
Draft
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
108 changes: 105 additions & 3 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,16 +352,63 @@ pub(crate) fn project_codex(

let view = toolpath_convo::extract_conversation(path);
let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd_str);
let session = projector
let mut session = projector
.project(&view)
.map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?;
if session.id.is_empty() {
anyhow::bail!("Projected session has no id");
}
// Mint a deterministic fresh id (like opencode's `ses_<sha1>`): keeping
// the source id produces two rollouts sharing one session id, which
// makes `p import codex --session` ambiguous, lets INSERT OR REPLACE
// clobber the native session's thread row, and confuses
// `codex resume <id>` about which rollout to load. Deterministic so a
// re-resume overwrites its own projection instead of accumulating.
let fresh = codex_resume_session_id(&session.id);
for line in &mut session.lines {
if line.kind == "session_meta" {
if let Some(id) = line.payload.get_mut("id") {
*id = serde_json::Value::String(fresh.clone());
}
if let Some(id) = line.payload.get_mut("session_id") {
*id = serde_json::Value::String(fresh.clone());
}
}
}
session.id = fresh;
write_into_codex_project(&session)?;
Ok(session.id)
}

/// Deterministic UUID-shaped resume id for a projected Codex session,
/// derived from the source session id. `codex resume` requires a UUID.
fn codex_resume_session_id(source_id: &str) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(b"codex-resume:");
h.update(source_id.as_bytes());
let d = h.finalize();
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-4{:01x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
d[0],
d[1],
d[2],
d[3],
d[4],
d[5],
d[6] & 0x0f,
d[7],
(d[8] & 0x3f) | 0x80,
d[9],
d[10],
d[11],
d[12],
d[13],
d[14],
d[15]
)
}

/// Build a Copilot [`Session`](toolpath_copilot::Session) from `path`, rooted
/// at `project_dir` with a fresh session id.
///
Expand Down Expand Up @@ -1260,7 +1307,15 @@ fn first_user_message_text(session: &toolpath_codex::Session) -> String {
&& m.role == "user"
{
let t = m.text();
if !t.is_empty() {
// System-injected envelopes (`<environment_context>`, caveats)
// precede the real prompt in the rollout; titling from one gave
// the registered thread an XML blob for a title in
// `codex resume`'s picker.
let is_envelope = {
let trimmed = t.trim_start();
trimmed.starts_with('<') && trimmed.contains('>')
};
if !t.is_empty() && !is_envelope {
return t;
}
}
Expand Down Expand Up @@ -3235,10 +3290,57 @@ mod tests {
}

let returned_id = result.expect("project_codex should succeed");
assert_eq!(returned_id, session_uuid);
assert_ne!(
returned_id, session_uuid,
"resume must mint a fresh id — reusing the source id collides \
with the native rollout"
);
assert_eq!(
returned_id,
codex_resume_session_id(session_uuid),
"minted id is deterministic so a re-resume overwrites itself"
);
assert_eq!(returned_id.len(), 36, "codex resume requires a UUID");

let codex_sessions = fake_home.join(".codex/sessions");
assert!(codex_sessions.exists(), "codex sessions dir missing");

// The written rollout's session_meta must carry the minted id, and
// the file name must embed it.
fn find_file(dir: &std::path::Path) -> Option<std::path::PathBuf> {
for entry in std::fs::read_dir(dir).ok()? {
let p = entry.ok()?.path();
if p.is_dir() {
if let Some(f) = find_file(&p) {
return Some(f);
}
} else {
return Some(p);
}
}
None
}
let rollout = find_file(&codex_sessions).expect("a rollout file was written");
assert!(
rollout
.file_name()
.unwrap()
.to_string_lossy()
.contains(&returned_id),
"rollout filename embeds the minted id"
);
let first_line = std::fs::read_to_string(&rollout)
.unwrap()
.lines()
.next()
.unwrap()
.to_string();
let meta: serde_json::Value = serde_json::from_str(&first_line).unwrap();
assert_eq!(
meta["payload"]["id"].as_str(),
Some(returned_id.as_str()),
"session_meta payload carries the minted id"
);
}

#[test]
Expand Down
11 changes: 9 additions & 2 deletions crates/path-cli/src/cmd_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,14 @@ fn derive_claude_with_manager(
session: Option<String>,
all: bool,
) -> Result<Vec<DerivedDoc>> {
// Thinking rides along: dropping it degrades resume (the harness
// re-reads it) and used to leave empty-thinking assistant entries
// projecting as `[{"type":"text","text":""}]` — a content shape that
// aborts Claude 2.1.216's transcript renderer, blanking every ❯ prompt
// and the Compacted indicator on a resumed session.
let make_config = |p: &str| toolpath_claude::derive::DeriveConfig {
project_path: Some(p.to_string()),
include_thinking: false,
include_thinking: true,
};

// Interactive picker fires only when no explicit `--session` (and not
Expand Down Expand Up @@ -485,9 +490,11 @@ fn derive_claude_with_manager(
/// `(Some(p), Some(s), _)` arm in [`derive_claude_with_manager`].
pub(crate) fn derive_claude_session(project: &str, session: &str) -> Result<DerivedDoc> {
let manager = toolpath_claude::ClaudeConvo::new();
// include_thinking matches derive_claude_with_manager — see the note
// there (resume fidelity + the empty-text-block renderer abort).
let cfg = toolpath_claude::derive::DeriveConfig {
project_path: Some(project.to_string()),
include_thinking: false,
include_thinking: true,
};
let convo = manager
.read_conversation(project, session)
Expand Down
84 changes: 64 additions & 20 deletions crates/toolpath-claude/src/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,35 @@ impl ConversationReader {
continue;
}

// Try to parse as a conversation entry
match serde_json::from_str::<ConversationEntry>(&line) {
Ok(entry) if !entry.uuid.is_empty() => {
conversation.add_entry(entry);
}
Ok(_) | Err(_) => {
// Parse the line as a *stream* of JSON values: reading a session
// Claude Code is actively writing can catch two objects
// concatenated on one line (a mid-write flush boundary), and
// whole-line parsing dropped both. A truncated trailing object
// still drops, but every complete value on the line survives.
let mut parsed_any = false;
for value in
serde_json::Deserializer::from_str(&line).into_iter::<serde_json::Value>()
{
let Ok(value) = value else { break };
parsed_any = true;
match serde_json::from_value::<ConversationEntry>(value.clone()) {
Ok(entry) if !entry.uuid.is_empty() => {
conversation.add_entry(entry);
}
// Headerless / metadata lines (ai-title, last-prompt,
// queue-operation, permission-mode, file-history-snapshot,
// etc.) are preserved verbatim so the projector can
// re-emit them on roundtrip.
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
conversation.preamble.push(value);
} else if line_num < 5 || std::env::var("CLAUDE_CLI_DEBUG").is_ok() {
eprintln!(
"Warning: Failed to parse line {} in {:?}: not valid JSON",
line_num + 1,
path.file_name().unwrap_or_default()
);
}
Ok(_) | Err(_) => conversation.preamble.push(value),
}
}
if !parsed_any && (line_num < 5 || std::env::var("CLAUDE_CLI_DEBUG").is_ok()) {
eprintln!(
"Warning: Failed to parse line {} in {:?}: not valid JSON",
line_num + 1,
path.file_name().unwrap_or_default()
);
}
}

Ok(conversation)
Expand Down Expand Up @@ -205,11 +213,17 @@ impl ConversationReader {
continue;
}

// Try to parse as a conversation entry
if let Ok(entry) = serde_json::from_str::<ConversationEntry>(&line) {
// Only add entries with valid UUIDs (skip metadata entries)
if !entry.uuid.is_empty() {
entries.push(entry);
// Stream-parse like `read_conversation`: a mid-write capture can
// concatenate two objects on one line; keep every complete one.
for value in
serde_json::Deserializer::from_str(&line).into_iter::<serde_json::Value>()
{
let Ok(value) = value else { break };
if let Ok(entry) = serde_json::from_value::<ConversationEntry>(value) {
// Only add entries with valid UUIDs (skip metadata entries)
if !entry.uuid.is_empty() {
entries.push(entry);
}
}
}
// Silently skip unparseable lines (metadata, file-history-snapshot, etc.)
Expand Down Expand Up @@ -283,6 +297,36 @@ mod tests {
assert_eq!(convo.assistant_messages().len(), 1);
}

#[test]
fn test_concatenated_objects_on_one_line_all_survive() {
// A mid-write flush boundary can land two entries on one physical
// line. Whole-line parsing dropped both; the stream parse keeps
// every complete object. A truncated trailing object still drops
// without taking the complete prefix with it.
let mut temp = NamedTempFile::new().unwrap();
write!(
temp,
r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","sessionId":"t","message":{{"role":"user","content":"a"}}}}{{"type":"assistant","uuid":"a1","timestamp":"2024-01-01T00:00:01Z","sessionId":"t","message":{{"role":"assistant","content":"b"}}}}"#
)
.unwrap();
writeln!(temp).unwrap();
write!(
temp,
r#"{{"type":"user","uuid":"u2","timestamp":"2024-01-01T00:00:02Z","sessionId":"t","message":{{"role":"user","content":"c"}}}}{{"type":"assist"#
)
.unwrap();
writeln!(temp).unwrap();
temp.flush().unwrap();

let convo = ConversationReader::read_conversation(temp.path()).unwrap();
let uuids: Vec<&str> = convo.entries.iter().map(|e| e.uuid.as_str()).collect();
assert_eq!(uuids, vec!["u1", "a1", "u2"]);

let (entries, _) = ConversationReader::read_from_offset(temp.path(), 0).unwrap();
let uuids: Vec<&str> = entries.iter().map(|e| e.uuid.as_str()).collect();
assert_eq!(uuids, vec!["u1", "a1", "u2"]);
}

#[test]
fn test_read_history() {
let mut temp = NamedTempFile::new().unwrap();
Expand Down
Loading