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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion crates/toolpath-claude/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
57 changes: 56 additions & 1 deletion crates/toolpath-claude/src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ impl ChainIndex {
}
self.known_files.insert(file_stem.clone());

// Rotation artifacts (`<uuid>.orphaned-<ts>-<hash>`) 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 {
Expand Down Expand Up @@ -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<String> {
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()
}
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 42 additions & 1 deletion crates/toolpath-claude/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion site/_data/crates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading