From 2c5f8b54e34e1a444704d7a5619f053a972771c4 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:02:50 -0400 Subject: [PATCH 1/2] fix(claude): drop empty streaming-seed assistant lines on projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code streams an assistant message as several JSONL lines — the first an empty "seed" (text: "") superseded by the real-text line, both sharing one message.id. The projector wrote that seed back as content:[{"type":"text","text":""}], an API-invalid message: on the next turn of a *resumed* session Claude replays the whole transcript and Anthropic rejects the empty text block with 400 messages: text content blocks must be non-empty so a resumed session couldn't take a second turn. The projector now skips any assistant turn with no text, thinking, tool-uses, delegations, or file-mutations (and no attached tool-result events), re-linking the following turn's parentUuid to the dropped seed's parent via the existing parent_rewrites machinery so the uuid chain stays intact. The group token total is re-expanded onto the surviving line as before. Verified end-to-end by re-exporting a real captured session: empty text blocks 1 -> 0, chain re-linked past the seed. toolpath-claude 0.12.0 -> 0.12.1. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 15 ++++ Cargo.toml | 2 +- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/project.rs | 109 ++++++++++++++++++++++++-- site/_data/crates.json | 2 +- 5 files changed, 119 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13265c85..7550ca04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to the Toolpath workspace are documented here. +## Project: drop empty streaming-seed assistant lines — 2026-07-23 + +- **`toolpath-claude`** (0.12.2): the Claude projector no longer emits + content-empty assistant messages. Claude Code streams a message as several + JSONL lines — the first an empty "seed" (`text: ""`) superseded by the + real-text line, both sharing one `message.id`. Projecting that seed as + `content:[{"type":"text","text":""}]` produced an API-invalid message: on the + next turn of a *resumed* session Claude replays the whole transcript and + Anthropic rejects the empty text block with `400 … text content blocks must + be non-empty`, so the resumed session couldn't take a second turn. The + projector now skips any assistant turn with no text, thinking, tool-uses, + delegations, or file-mutations and re-links the following turn's `parentUuid` + to the dropped seed's parent, keeping the uuid chain intact. The group's + token total is re-expanded onto the surviving line as before. + ## One artifact-type layer and per-session imports — 2026-07-16 Groundwork for a cache that fills itself: one enum for artifact 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/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/project.rs b/crates/toolpath-claude/src/project.rs index 5435649a..8aec6d92 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -140,6 +140,29 @@ fn project_view(view: &ConversationView) -> std::result::Result { + // Drop content-empty assistant turns. Claude Code streams a + // message as several JSONL lines, the first an empty "seed" + // (text: "") superseded by the real-text line. Projecting that + // seed as `content:[{type:text, text:""}]` yields an + // API-invalid message — on the next resumed turn Anthropic + // rejects the replayed transcript with "text content blocks + // must be non-empty". Skip the seed and re-link any child that + // pointed at it to its parent, keeping the uuid chain intact. + // (Turns carrying tool_result events are never skipped — those + // events would be orphaned.) + let is_content_empty = turn.text.trim().is_empty() + && turn.thinking.is_none() + && turn.tool_uses.is_empty() + && turn.delegations.is_empty() + && turn.file_mutations.is_empty() + && !tool_result_events_by_parent.contains_key(&turn.id); + if is_content_empty { + if let Some(parent) = effective_parent { + parent_rewrites.insert(turn.id.clone(), parent); + } + continue; + } + // Grouped: the message total on every line of the split. // Ungrouped: the turn's own usage. let wire_usage: Option = match turn.group_id.as_deref() @@ -1103,14 +1126,15 @@ mod tests { .iter() .filter(|e| e.entry_type == "assistant") .collect(); - assert_eq!(assistants.len(), 2); - for entry in &assistants { - let msg = entry.message.as_ref().unwrap(); - assert_eq!(msg.id.as_deref(), Some("msg_A")); - let u = msg.usage.as_ref().expect("every line carries usage"); - assert_eq!(u.output_tokens, Some(997)); - assert_eq!(u.cache_creation_input_tokens, Some(429_831)); - } + // a2 is the content-empty tail of the split; it's dropped (an empty + // text block would be API-invalid on resume). The group total it + // carried is re-expanded onto the surviving line via `group_total`. + assert_eq!(assistants.len(), 1); + let msg = assistants[0].message.as_ref().unwrap(); + assert_eq!(msg.id.as_deref(), Some("msg_A")); + let u = msg.usage.as_ref().expect("surviving line carries usage"); + assert_eq!(u.output_tokens, Some(997)); + assert_eq!(u.cache_creation_input_tokens, Some(429_831)); } #[test] @@ -1158,6 +1182,75 @@ mod tests { assert!(a.iter().all(|t| t.attributed_token_usage.is_none())); } + // ── Empty streaming-seed assistant lines ───────────────────────── + + #[test] + fn test_projector_skips_empty_text_seed_and_relinks_chain() { + // Claude Code streams an assistant message as multiple JSONL lines; + // the first is an empty "seed" (text: "") superseded by the real-text + // line, both sharing one message id. Projecting the empty seed as + // `content:[{type:text, text:""}]` produces an API-invalid message — + // on the next resumed turn Anthropic rejects it with "text content + // blocks must be non-empty". The projector must drop the content-empty + // seed and re-link the following turn's parent to the seed's parent so + // the uuid chain stays intact. + let usage = TokenUsage { + input_tokens: Some(5710), + output_tokens: Some(224), + ..Default::default() + }; + let mut seed = assistant_turn("seed", ""); + seed.parent_id = Some("u1".into()); + seed.group_id = Some("msg_A".into()); + seed.stop_reason = Some("end_turn".into()); + let mut real = assistant_turn("real", "Chocolate, vanilla, strawberry."); + real.parent_id = Some("seed".into()); + real.group_id = Some("msg_A".into()); + real.token_usage = Some(usage); + + let view = make_view( + "sess-1", + vec![user_turn("u1", "icecream flavors"), seed, real], + ); + let convo = ClaudeProjector.project(&view).unwrap(); + + let assistants: Vec<&ConversationEntry> = content_entries(&convo) + .iter() + .filter(|e| e.entry_type == "assistant") + .collect(); + + // The empty seed is gone; only the real-text line survives. + assert_eq!(assistants.len(), 1, "empty seed line must be dropped"); + let entry = assistants[0]; + assert_eq!(entry.uuid, "real"); + // Re-linked past the dropped seed to its parent. + assert_eq!( + entry.parent_uuid.as_deref(), + Some("u1"), + "surviving turn must re-parent to the dropped seed's parent" + ); + + // No assistant message anywhere carries an empty/whitespace text block. + for e in content_entries(&convo) + .iter() + .filter(|e| e.entry_type == "assistant") + { + if let Some(MessageContent::Parts(parts)) = + e.message.as_ref().and_then(|m| m.content.as_ref()) + { + for p in parts { + if let ContentPart::Text { text } = p { + assert!(!text.trim().is_empty(), "empty text content block leaked"); + } + } + } + } + + // Usage from the group is preserved on the surviving line. + let msg = entry.message.as_ref().unwrap(); + assert_eq!(msg.usage.as_ref().unwrap().output_tokens, Some(224)); + } + // ── Permission-mode preamble ───────────────────────────────────── #[test] 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 7ddfa5bdba4645230d828435e28fd5e018eb254b Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Thu, 23 Jul 2026 23:15:19 -0400 Subject: [PATCH 2/2] refactor(convo): extract Turn::is_content_empty() (review #141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The projector's seed-drop check was an inline five-way conjunction over turn content fields. Promote the intrinsic part to `Turn::is_content_empty()` on toolpath-convo — a reusable predicate (no text/thinking/tool-uses/delegations/file-mutations) — and keep the projector-specific tool-result-events check at the call site. toolpath-convo 0.11.1 → 0.12.0 (additive: new public method). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++ Cargo.toml | 2 +- crates/toolpath-claude/src/project.rs | 12 ++-- crates/toolpath-convo/Cargo.toml | 2 +- crates/toolpath-convo/src/lib.rs | 82 +++++++++++++++++++++++++++ site/_data/crates.json | 2 +- 6 files changed, 94 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7550ca04..371794df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ All notable changes to the Toolpath workspace are documented here. delegations, or file-mutations and re-links the following turn's `parentUuid` to the dropped seed's parent, keeping the uuid chain intact. The group's token total is re-expanded onto the surviving line as before. +- **`toolpath-convo`** (0.12.0): new `Turn::is_content_empty()` — the + "no renderable content of its own" predicate (no text/thinking/tool-uses/ + delegations/file-mutations) that the Claude projector's seed-drop check + now calls instead of an inline conjunction. ## One artifact-type layer and per-session imports — 2026-07-16 diff --git a/Cargo.toml b/Cargo.toml index 10b8e2fa..6e0ba96f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ license = "Apache-2.0" [workspace.dependencies] toolpath = { version = "0.7.0", path = "crates/toolpath" } -toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } +toolpath-convo = { version = "0.12.0", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } 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 } diff --git a/crates/toolpath-claude/src/project.rs b/crates/toolpath-claude/src/project.rs index 8aec6d92..fa888e86 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -150,13 +150,11 @@ fn project_view(view: &ConversationView) -> std::result::Result, } +impl Turn { + /// True when the turn carries no renderable content of its own — no + /// visible text (ignoring whitespace), thinking, tool uses, + /// delegations, or file mutations. Such turns are streaming "seeds" + /// or placeholders that projectors may safely drop. Note this is + /// intrinsic to the turn; a caller that also tracks attached events + /// (e.g. tool-result entries keyed by parent) must check those + /// separately. + pub fn is_content_empty(&self) -> bool { + self.text.trim().is_empty() + && self.thinking.is_none() + && self.tool_uses.is_empty() + && self.delegations.is_empty() + && self.file_mutations.is_empty() + } +} + /// A complete conversation from any provider. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ConversationView { @@ -1153,4 +1170,69 @@ mod tests { let view: ConversationView = serde_json::from_str(json).unwrap(); assert!(view.events.is_empty()); } + + #[test] + fn test_turn_is_content_empty() { + // Turn has no Default; build a whitespace-only base and clone it. + let base = Turn { + id: "t1".into(), + parent_id: None, + group_id: None, + role: Role::Assistant, + timestamp: "2026-01-01T00:00:00Z".into(), + text: " ".into(), // whitespace-only counts as empty + thinking: None, + tool_uses: vec![], + model: None, + stop_reason: None, + token_usage: None, + attributed_token_usage: None, + environment: None, + delegations: vec![], + file_mutations: vec![], + }; + assert!(base.is_content_empty()); + + // Any one kind of content makes it non-empty. + assert!( + !Turn { + text: "hi".into(), + ..base.clone() + } + .is_content_empty() + ); + assert!( + !Turn { + thinking: Some("…".into()), + ..base.clone() + } + .is_content_empty() + ); + assert!( + !Turn { + tool_uses: vec![ToolInvocation::default()], + ..base.clone() + } + .is_content_empty() + ); + assert!( + !Turn { + delegations: vec![DelegatedWork { + agent_id: "a".into(), + prompt: "p".into(), + turns: vec![], + result: None, + }], + ..base.clone() + } + .is_content_empty() + ); + assert!( + !Turn { + file_mutations: vec![FileMutation::default()], + ..base.clone() + } + .is_content_empty() + ); + } } diff --git a/site/_data/crates.json b/site/_data/crates.json index 2849525e..3992e4de 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -9,7 +9,7 @@ }, { "name": "toolpath-convo", - "version": "0.11.1", + "version": "0.12.0", "description": "Provider-agnostic conversation types, traits, and Toolpath-Path derivation", "docs": "https://docs.rs/toolpath-convo", "crate": "https://crates.io/crates/toolpath-convo",