Skip to content

fix(claude): drop empty streaming-seed assistant lines on projection#141

Open
bdelanghe wants to merge 2 commits into
empathic:mainfrom
bdelanghe:bdelanghe/fix-empty-seed-assistant-projection
Open

fix(claude): drop empty streaming-seed assistant lines on projection#141
bdelanghe wants to merge 2 commits into
empathic:mainfrom
bdelanghe:bdelanghe/fix-empty-seed-assistant-projection

Conversation

@bdelanghe

@bdelanghe bdelanghe commented Jul 23, 2026

Copy link
Copy Markdown

Problem

Resuming a projected Claude session works, but the next turn dies with:

API Error: 400 messages: text content blocks must be non-empty

Root cause

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 Claude projector wrote that seed back as content:[{"type":"text","text":""}]. That message is API-invalid: on the next turn of a resumed session Claude replays the whole transcript to Anthropic, which rejects the empty text block. The bug was in build_assistant_content's text-only fast path, which (unlike the tool/thinking path) had no empty-text guard.

Fix

The projector now skips content-empty assistant turns (no text/thinking/tool-uses/delegations/file-mutations, and no attached tool-result events) and re-links the following turn's parentUuid to the dropped seed's parent — reusing the existing parent_rewrites machinery, so the uuid chain stays intact. The group's token total is re-expanded onto the surviving line as before.

Verification

  • New TDD test test_projector_skips_empty_text_seed_and_relinks_chain (failed before, passes after).
  • Full toolpath-claude suite: 230 passed, 0 failed. Clippy clean.
  • Real end-to-end: re-exported a real captured session — empty text blocks 1 → 0; the seed step is gone and the surviving assistant line re-parents to the user turn.
  • Updated the older test_projector_reexpands_group_usage_onto_every_line test that had encoded the old emit-empty-line behavior.

Version: toolpath-claude 0.12.0 → 0.12.1 (Cargo.toml, workspace dep, crates.json, CHANGELOG).

Versioning

toolpath-claude 0.12.1 → 0.12.2 — 0.12.1 was taken by main's ClaudeConvo::session_chain bump (#133), so the empty-seed fix gets its own patch.


Stack (review bottom-up): #141 empty-seed projection fix → #142 opencode test isolation → #145 path resume --remote.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Comment thread crates/toolpath-claude/src/project.rs Outdated
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@bdelanghe

Copy link
Copy Markdown
Author

Good call — extracted it to Turn::is_content_empty() on toolpath-convo (the intrinsic "no text/thinking/tool-uses/delegations/file-mutations" predicate), and left the projector-specific tool-result-events check at the call site:

let is_droppable_seed = turn.is_content_empty()
    && !tool_result_events_by_parent.contains_key(&turn.id);

toolpath-convo 0.11.1 → 0.12.0 (additive), with a unit test for the new method. Pushed in 7ddfa5b.

bdelanghe pushed a commit to bdelanghe/toolpath that referenced this pull request Jul 24, 2026
… review)

Same cleanup the empathic#141 reviewer asked for, applied here where the
projector's seed-drop check lives in its improved (root-of-chain) form:
promote the intrinsic predicate to Turn::is_content_empty() on
toolpath-convo and keep the tool-result-events check at the call site.
toolpath-convo 0.11.1 → 0.12.0 (additive), with a unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant