diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index dfc82d2b..c55fe3c2 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -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_`): 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 ` 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. /// @@ -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 (``, 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; } } @@ -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 { + 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] diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 48ee8ad2..41a55b9c 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -405,9 +405,14 @@ fn derive_claude_with_manager( session: Option, all: bool, ) -> Result> { + // 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 @@ -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 { 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) diff --git a/crates/toolpath-claude/src/reader.rs b/crates/toolpath-claude/src/reader.rs index 0fd75912..b75ec327 100644 --- a/crates/toolpath-claude/src/reader.rs +++ b/crates/toolpath-claude/src/reader.rs @@ -30,27 +30,35 @@ impl ConversationReader { continue; } - // Try to parse as a conversation entry - match serde_json::from_str::(&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::() + { + let Ok(value) = value else { break }; + parsed_any = true; + match serde_json::from_value::(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::(&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) @@ -205,11 +213,17 @@ impl ConversationReader { continue; } - // Try to parse as a conversation entry - if let Ok(entry) = serde_json::from_str::(&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::() + { + let Ok(value) = value else { break }; + if let Ok(entry) = serde_json::from_value::(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.) @@ -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();