From 2c9f39f98d0ae82d517cc76cd5288ddae19d918d Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 30 Jul 2026 16:11:22 -0400 Subject: [PATCH 1/2] fix(claude): projected sessions with thinking blocks are resumable again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes found by live-resuming a projected session against the real API: - toolpath-claude 0.12.2: ContentPart::Thinking serializes a None signature as an absent key instead of "signature": null. The Anthropic API rejects a null signature outright (400 thinking.signature.str: Input should be a valid string), so any session projected by p export claude / path resume that contained thinking blocks — most real agent sessions — failed on the first message after resume. An absent signature is tolerated: the unsigned thinking block is dropped on replay, per the documented writer contract in docs/agents/formats/claude-code/writing-compatible-jsonl.md. Real signatures still pass through untouched. - path-cli: the Claude JSONL export ends with a trailing newline. Claude Code appends to the projected file on resume, and without one the first appended entry landed on the same line as the last projected entry, corrupting the JSONL (gemini/codex exports already did this). Verified live: a projected thinking-bearing session that previously 400'd now resumes cleanly under claude -r and summarizes its own history. --- CHANGELOG.md | 20 ++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/src/cmd_export.rs | 6 +++++- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/types.rs | 27 ++++++++++++++++++++++++++- site/_data/crates.json | 2 +- 7 files changed, 55 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f6bc04..be2a4068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to the Toolpath workspace are documented here. +## Projected Claude sessions are resumable again — 2026-07-30 + +Two fixes found by live-resuming a projected session against the real +API. Together they broke resume for most projected sessions. + +- **`toolpath-claude`** (0.12.2): `ContentPart::Thinking` serializes a + `None` signature as an **absent key** instead of `"signature": null`. + The Anthropic API rejects a null signature outright + (`400 …thinking.signature.str: Input should be a valid string`), so + any projected session containing thinking blocks — most real agent + sessions — failed on the first message after resume. An absent + signature is tolerated: the thinking block is dropped on replay, per + the documented writer contract in + `docs/agents/formats/claude-code/writing-compatible-jsonl.md`. Real + signatures still round-trip untouched. +- **`path-cli`** (0.16.0, unreleased): the Claude JSONL export now ends + with a trailing newline. Claude Code appends to the projected file on + resume, and without one the first appended entry landed on the same + line as the last projected entry, corrupting the JSONL. + ## `path p cache sync` — incremental session ingestion — 2026-07-16 Adds `path p cache sync [types…]`, the first step toward a cache that diff --git a/Cargo.lock b/Cargo.lock index 6cdb1e62..98aa19b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.1" +version = "0.12.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 23223581..10b8e2fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.0", 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.12.1", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.12.2", 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/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index b1a4a5cf..42cd7e00 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -684,7 +684,11 @@ fn serialize_jsonl(conv: &toolpath_claude::Conversation) -> Result { for entry in &conv.entries { lines.push(serde_json::to_string(entry)?); } - Ok(lines.join("\n")) + // Trailing newline matters: Claude Code appends to this file on resume, + // and without it the first appended entry lands on the last line. + let mut out = lines.join("\n"); + out.push('\n'); + Ok(out) } #[cfg(not(target_os = "emscripten"))] diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index 0ed4b496..acb66c2f 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.1" +version = "0.12.2" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/types.rs b/crates/toolpath-claude/src/types.rs index a848c1f9..9be71b90 100644 --- a/crates/toolpath-claude/src/types.rs +++ b/crates/toolpath-claude/src/types.rs @@ -97,7 +97,11 @@ pub enum ContentPart { }, Thinking { thinking: String, - #[serde(default)] + // Serialized as absent when None: the Anthropic API rejects + // `"signature": null` outright (400), while an absent signature just + // means the thinking block is dropped on resume — see + // docs/agents/formats/claude-code/writing-compatible-jsonl.md. + #[serde(default, skip_serializing_if = "Option::is_none")] signature: Option, }, ToolUse { @@ -731,6 +735,27 @@ mod tests { assert_eq!(part.summary(), "[thinking]"); } + #[test] + fn test_thinking_none_signature_serializes_absent() { + let part = ContentPart::Thinking { + thinking: "deep thought".to_string(), + signature: None, + }; + let json = serde_json::to_value(&part).unwrap(); + assert!( + json.get("signature").is_none(), + "None signature must serialize as an absent key, not null: the \ + API 400s on `\"signature\": null` but tolerates absence" + ); + + let signed = ContentPart::Thinking { + thinking: "deep thought".to_string(), + signature: Some("sig123".to_string()), + }; + let json = serde_json::to_value(&signed).unwrap(); + assert_eq!(json.get("signature").unwrap(), "sig123"); + } + #[test] fn test_content_part_summary_tool_use() { let part = ContentPart::ToolUse { diff --git a/site/_data/crates.json b/site/_data/crates.json index 99bcad0b..2849525e 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.1", + "version": "0.12.2", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", From 4ffca3c302403f673efeef6d78da790b558ada7b Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 30 Jul 2026 16:16:04 -0400 Subject: [PATCH 2/2] chore: bump path-cli and toolpath-cli to 0.16.1 The Claude export newline fix rides a patch bump so the pending 0.16 release carries it distinctly. The toolpath-cli shim tracks path-cli in lockstep as usual. --- CHANGELOG.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/Cargo.toml | 2 +- crates/toolpath-cli/Cargo.toml | 4 ++-- site/_data/crates.json | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be2a4068..dd31e345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ API. Together they broke resume for most projected sessions. the documented writer contract in `docs/agents/formats/claude-code/writing-compatible-jsonl.md`. Real signatures still round-trip untouched. -- **`path-cli`** (0.16.0, unreleased): the Claude JSONL export now ends +- **`path-cli`** (0.16.1): the Claude JSONL export now ends with a trailing newline. Claude Code appends to the projected file on resume, and without one the first appended entry landed on the same line as the last projected entry, corrupting the JSONL. diff --git a/Cargo.lock b/Cargo.lock index 98aa19b5..d9b355b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.16.0" +version = "0.16.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 10b8e2fa..ec3e2606 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.16.0", path = "crates/path-cli" } +path-cli = { version = "0.16.1", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 575c972f..1ce394b6 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.16.0" +version = "0.16.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 54cf9fe6..9b38ca37 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.16.0" +version = "0.16.1" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.16.0" } +path-cli = { path = "../path-cli", version = "0.16.1" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index 2849525e..4d282a97 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.16.0", + "version": "0.16.1", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -121,7 +121,7 @@ }, { "name": "toolpath-cli", - "version": "0.16.0", + "version": "0.16.1", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli",