diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f6bc04..dd31e345 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.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. + ## `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..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", @@ -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..ec3e2606 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" } @@ -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/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/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 99bcad0b..4d282a97 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", @@ -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",