The transcript promised a tool call that does not exist
A coding agent thinks for a while, says nothing useful, and stops. Retrying looks like the obvious response. The transcript can tell a stranger story: the assistant message ended with stop_reason: "tool_use", but its content contains no tool_use block.
No shell command failed. The recorded message has no command to inspect.
A reporter in anthropics/claude-code#64235 found this shape after Claude Code began reporting malformed tool calls. Their earlier transcripts contained 32,173 assistant turns ending in tool_use and no parse failures through May 28. They counted 57 failures on May 29 and another 15 on May 31.
A second person scanned roughly 30 days of local transcripts and reported 59 malformed or dropped calls across 20 sessions and 11 Claude Code versions. They classified 39 as the contradiction above and 16 as tool-call-shaped XML in an ordinary text block after a stray word such as call, count, or court. The comment labels the total as 59, but those two categories account for 55; four incidents are not categorized in the posted breakdown.
The UI can make both modes look like an agent that simply forgot to act. The saved evidence says otherwise.
Two failures, not one error string
The missing-block case looks like this after removing payloads:
{
"type": "assistant",
"message": {
"id": "...",
"stop_reason": "tool_use",
"content": [{"type": "thinking", "signature": "..."}]
}
}
The message declares a transition into tool use. The structured content needed for that transition is absent.
The sibling failure in #67307 is different. A tool call appears as text with broken markup, so the harness never executes it. The author of a retry hook deliberately handles that text-leak mode but excludes the missing-block mode. The former contains an unexecuted call in text; the latter leaves less evidence about what happened around the failed turn.
An automatic repair has to preserve this distinction. Treating the shared “could not be parsed” error as the state throws both failures back into one bucket. A retry loop then acts on an error label instead of the transcript it is supposed to recover.
I wrote the detector first. It identifies the contradiction without editing the session or guessing at recovery.
A JSONL row is not always a message
I built tool-call-transcript-check for the missing-block mode only.
A short jq expression is tempting, and it works for one known transcript layout. The problem is the unit of analysis. Claude Code can store blocks from one assistant API message on separate JSONL rows with the same message.id. A thinking row may look broken by itself even when a later row contains the promised tool call.
The checker groups assistant rows by file and message ID before testing the invariant. This synthetic pair is clean:
{"type":"assistant","message":{"id":"m1","stop_reason":"tool_use","content":[{"type":"thinking"}]}}
{"type":"assistant","message":{"id":"m1","stop_reason":"tool_use","content":[{"type":"tool_use"}]}}
A message becomes a finding only when its grouped rows contain stop_reason: "tool_use" and no structured tool_use block anywhere in that group.
The narrow rule avoids pretending to diagnose the cause. It does not classify malformed XML, infer whether a side effect happened, or decide that signed thinking caused the bug. The public reports show correlations with extended thinking and some Opus versions, but a transcript shape is not proof of a root cause.
Evidence without copying the conversation
Agent transcripts can contain source code, credentials, tool arguments, private paths, and the assistant’s thinking text. A scanner that prints the offending row would turn diagnosis into a data leak.
The report leaves those payloads out. A finding contains the input-relative filename, model when it matches a conservative identifier pattern, block-type names, exact source-line membership, signed-thinking presence, and a SHA-256 digest over those source lines. The digest lets someone confirm that two reports refer to the same bytes without publishing them.
This is the installed release running against its synthetic failing fixture:
{"findings":[{"block_types":["thinking"],"code":"missing_tool_use_block","evidence_sha256":"e2ba717ebaab0d2fce6b720da9229db809a491791b389e4568f4462c91eb569b","file":"thinking_only.jsonl","lines":[1,1],"model":"claude-public-test","signed_thinking":true,"source_lines":[1]}],"summary":{"assistant_messages_checked":1,"files_scanned":1,"findings":1}}
The tool does not scan ~/.claude by default. It requires an explicit file or directory, does not follow transcript symlinks, and labels multiple input roots without printing their parent paths. It also fails closed on malformed JSON, duplicate keys, invalid relevant structures, unreadable data, and non-UTF-8 input. A clean result includes the number of files and grouped assistant messages so an empty search cannot pass as a known scan.
The metadata is still metadata. Filenames, line positions, model identifiers, and evidence digests may be sensitive in some environments. The README says to inspect the report before sharing it. Payload-free output can still be sensitive.
Why it does not repair anything
The public issue includes a script that trims a poisoned transcript tail and resumes from an earlier point. Its author labels it a stopgap and warns that it edits an unsupported internal file. That may be a reasonable recovery for a person who has inspected one stuck session. It is too much authority for this checker.
The missing block establishes one fact: persisted history says a tool call should exist where no structured call was saved. It does not establish whether the model failed to emit the block, an API response lost it, the client serialized it incorrectly, or another action around the turn already changed external state.
The checker stops at evidence. It never modifies a transcript, retries a model call, or replays a tool. Recovery belongs in a layer that can account for side effects and choose a known clean boundary. If that layer cannot tell what executed, it should stop rather than manufacture certainty from a contradictory log.
I published version 0.1.0 as a Python 3.9+ wheel and source archive. The 28-test suite, Ruff lint and format checks, strict mypy, compileall, and package build pass locally. The same checks and build passed in four GitHub Actions jobs covering Linux and macOS on Python 3.9 and 3.13. I installed the public wheel in a clean Python 3.9 environment: the grouped clean fixture exited 0, and the contradiction above exited 1 with one payload-free finding. A final exact-HEAD Claude review returned no findings after earlier parser, privacy, and filesystem-boundary defects were fixed.
This is a diagnostic release, not evidence that anybody else has used it or that the Claude Code failure is fixed. It gives a contradictory transcript state a name and a reproducible report. Recovery is still an upstream problem.
Comments