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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

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.
- **`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

Groundwork for a cache that fills itself: one enum for artifact
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ 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.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" }
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.12.1"
version = "0.12.2"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down
107 changes: 99 additions & 8 deletions crates/toolpath-claude/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,27 @@ fn project_view(view: &ConversationView) -> std::result::Result<Conversation, St
convo.add_entry(entry);
}
Role::Assistant => {
// 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.)
// Content-empty *and* not carrying attached tool-result
// events (those would be orphaned if we dropped the turn).
let is_droppable_seed =
turn.is_content_empty() && !tool_result_events_by_parent.contains_key(&turn.id);
if is_droppable_seed {
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<toolpath_convo::TokenUsage> = match turn.group_id.as_deref()
Expand Down Expand Up @@ -1103,14 +1124,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]
Expand Down Expand Up @@ -1158,6 +1180,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]
Expand Down
2 changes: 1 addition & 1 deletion crates/toolpath-convo/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "toolpath-convo"
version = "0.11.1"
version = "0.12.0"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down
82 changes: 82 additions & 0 deletions crates/toolpath-convo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,23 @@ pub struct Turn {
pub file_mutations: Vec<FileMutation>,
}

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 {
Expand Down Expand Up @@ -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()
);
}
}
4 changes: 2 additions & 2 deletions site/_data/crates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down