diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eecd9e2..c9fccae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to the Toolpath workspace are documented here. +## toolpath-claude 0.13.1 — 2026-08-25 + +- **Fix:** `.orphaned-*` rotation artifacts are no longer classified as + chain successors. Their mangled stems made every entry read as a + bridge entry, silently dropping the whole segment's turns and token + usage from the merged conversation (#236). Dotted stems are now + classified standalone — and deliberately excluded from + `list_conversations`, because a derived document id truncates the stem + to 8 characters, which for an orphan equals its parent session's + prefix and would collide in the cache. Ingesting orphans arrives with + the full-stem id work. `read_segment` reads them directly today. +- **Fix:** bridge-entry filtering in `read_conversation` now skips only + the *leading* bridge run of a successor segment — the copied + predecessor tail — instead of every entry with a foreign `sessionId`. + A foreign id deeper in a segment is data and is kept. + ## toolpath-claude 0.13.0 — 2026-08-23 - **Breaking:** `Conversation.segment_ids` replaces `Conversation.session_ids`. diff --git a/Cargo.lock b/Cargo.lock index 77131a28..dc1a45bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4226,7 +4226,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.13.0" +version = "0.13.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5944d9e9..9094b238 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.1", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.13.0", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.13.1", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index a0aaf7ab..e49e3345 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.13.0" +version = "0.13.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/chain.rs b/crates/toolpath-claude/src/chain.rs index 5854668e..62fa806b 100644 --- a/crates/toolpath-claude/src/chain.rs +++ b/crates/toolpath-claude/src/chain.rs @@ -90,6 +90,19 @@ impl ChainIndex { } self.known_files.insert(file_stem.clone()); + // Rotation artifacts (`.orphaned--`) open with a + // foreign-looking `sessionId` — their entries' own uuid, which the + // mangled stem no longer equals — so the old classifier chained + // them in as successors, whereupon every entry of the segment read + // as a bridge entry and the whole segment (turns, usage) silently + // vanished from the merged conversation. A session stem never + // contains a dot; anything dotted is classified standalone here + // and excluded from chain_heads() below. + if file_stem.contains('.') { + self.non_successors.insert(file_stem.clone()); + continue; + } + let path = resolver.conversation_file(project_path, file_stem)?; if let Some(first_sid) = ConversationReader::read_first_session_id(&path) { if first_sid != *file_stem { @@ -129,10 +142,17 @@ impl ChainIndex { } /// All chain heads — file stems that are not successors of another. + /// + /// Dotted stems (`.orphaned-*` rotation artifacts) are excluded even + /// though they stand outside every chain: a head becomes a derived + /// document whose id truncates the stem to its first 8 characters, + /// which for an orphan equals its parent session's prefix — the two + /// documents would overwrite each other in the cache. Until ids can + /// carry the full stem, orphans are neither chained nor listed. pub(crate) fn chain_heads(&self) -> Vec { self.known_files .iter() - .filter(|stem| !self.reverse.contains_key(stem.as_str())) + .filter(|stem| !self.reverse.contains_key(stem.as_str()) && !stem.contains('.')) .cloned() .collect() } @@ -382,6 +402,41 @@ mod tests { assert!(!is_bridge_entry(&entry, "session-a")); } + #[test] + fn test_orphaned_stem_is_not_a_successor() { + let (_temp, resolver) = setup_chain_env(); + + write_session( + &resolver, + "session-a", + &[ + r#"{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Hello"}}"#, + ], + ); + + // A rotation artifact: mangled stem, every entry carrying the + // original session's id. Must never be chained onto session-a. + write_session( + &resolver, + "session-a.orphaned-1787626221622-ac84712d", + &[ + r#"{"type":"user","uuid":"u2","timestamp":"2024-01-01T02:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Orphaned"}}"#, + ], + ); + + let mut index = ChainIndex::new(); + index.refresh(&resolver, "/test/project").unwrap(); + + assert!(index.successor_of("session-a").is_none()); + assert!(!index.is_successor("session-a.orphaned-1787626221622-ac84712d")); + + // And it is not a listable head either: its derived id would + // truncate to the parent session's prefix and collide in the cache. + let heads = index.chain_heads(); + assert!(heads.contains(&"session-a".to_string())); + assert!(!heads.iter().any(|h| h.contains(".orphaned-"))); + } + #[test] fn test_is_bridge_entry_no_session_id() { let entry: ConversationEntry = serde_json::from_str( diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index a3ae20d9..9078da95 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -153,10 +153,18 @@ impl ClaudeConvo { merged.project_path = convo.project_path.clone(); } + // Bridge entries are the predecessor's tail copied to the TOP of + // a successor file — skip only that leading run. A foreign + // `sessionId` deeper in the segment is data, not a bridge; the + // old every-entry filter could erase a whole segment's turns and + // usage when all its entries carried one (the `.orphaned-*` + // failure shape). + let mut past_bridge = false; for entry in &convo.entries { - if chain::is_bridge_entry(entry, segment_id) { + if !past_bridge && chain::is_bridge_entry(entry, segment_id) { continue; } + past_bridge = true; merged.add_entry(entry.clone()); } } @@ -716,6 +724,39 @@ mod tests { assert_eq!(convo_c.entries.len(), 3); } + #[test] + fn test_bridge_skip_is_leading_run_only() { + // A foreign sessionId deeper in a segment is data, not a bridge — + // resumed sessions can interleave entries stamped with a prior id. + // Only the leading run at the top of a successor file is the copied + // predecessor tail. + let temp = TempDir::new().unwrap(); + let claude_dir = temp.path().join(".claude"); + let project_dir = claude_dir.join("projects/-test-project"); + fs::create_dir_all(&project_dir).unwrap(); + + fs::write( + project_dir.join("session-a.jsonl"), + r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Start"}}"#, + ).unwrap(); + let b = [ + r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#, + r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Own"}}"#, + r#"{"uuid":"b2","type":"user","timestamp":"2024-01-01T01:00:02Z","sessionId":"session-a","message":{"role":"user","content":"Foreign but kept"}}"#, + ]; + fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap(); + + let resolver = PathResolver::new().with_claude_dir(claude_dir); + let manager = ClaudeConvo::with_resolver(resolver); + let convo = manager + .read_conversation("/test/project", "session-a") + .unwrap(); + + // b0 (leading bridge) filtered; b2 (mid-segment foreign id) kept. + let uuids: Vec<&str> = convo.entries.iter().map(|e| e.uuid.as_str()).collect(); + assert_eq!(uuids, vec!["a1", "b1", "b2"]); + } + #[test] fn test_list_conversations_returns_chain_heads() { let (_temp, manager) = setup_chained_conversations(); diff --git a/site/_data/crates.json b/site/_data/crates.json index 489fdd57..47cbf2ef 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.13.0", + "version": "0.13.1", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude",