Skip to content

feat(claudecode): an unmodelled line cannot fail the run, and an unreadable one is an event - #8

Merged
pedromvgomes merged 2 commits into
mainfrom
fix/an-unmodelled-line-cannot-fail-the-run
Sep 7, 2026
Merged

feat(claudecode): an unmodelled line cannot fail the run, and an unreadable one is an event#8
pedromvgomes merged 2 commits into
mainfrom
fix/an-unmodelled-line-cannot-fail-the-run

Conversation

@pedromvgomes

@pedromvgomes pedromvgomes commented Sep 7, 2026

Copy link
Copy Markdown
Owner

v0.4.0's fix for message.content was incomplete. It corrected one shape; the defect is structural and one level up.

Decode unmarshalled the whole event before reading its type, so every field's shape was load-bearing on every line — including lines this package never reads. A bookkeeping event whose message is a string rather than an object failed the run before the type switch could skip it, which makes the tolerance documented on Decode unreachable for precisely the lines it was written for:

A line whose type this package does not model returns the zero Event, which the driver skips. Claude Code emits bookkeeping events — rate-limit notices, an init banner — that a caller consuming text has no use for, and a release adding another one is not a reason to fail a run that is working.

That promise could not hold, because the unmarshal ran first.

Why it looked intermittent

Claude Code emits a system line on context compaction:

{"type":"system","subtype":"compact_boundary","message":"context compacted"}

message is a string there. Long runs compact; short ones don't. agtk memory curate died twice against a store large enough to compact, while ordinary runs were fine — I captured live streams from a plain prompt, a tool-using prompt and a subagent run, and none of them reproduced it.

The change

The type is read on its own, from a shape nothing else on the line can break. Then:

  • Unmodelled type — never decoded further, so no field it carries can fail the run.
  • Modelled type, unreadable shape — skipped, not fatal. These events drive progress display; the outcome comes off the terminal line. Losing one tool call a caller was going to watch is not worth failing a run that produced a result.
  • result — stays strict. Reporting success off a result nobody could parse would invent a verdict.

Tests

Each was confirmed to fail before the change, except the strict-result one, which passed already and is a guard: a fix that made everything tolerant would have silently taken the verdict with it.

Note on the previous PR

The messageContent type from #7 is still doing useful work — it extracts assistant text from the string form rather than discarding it — but it should not have been the only line of defence. I fixed the shape I could reproduce instead of establishing why an unreadable line could fail a run at all.

An unreadable line is an event, not silence

Returning the zero event for a shape the provider cannot read is itself a form of suppression: EventKindUnknown is what the driver skips, so a caller never learns the line existed. That is indistinguishable from a turn where nothing happened — which blocks anyone building a UI on this driver, and hides exactly the signal that says a CLI's output has moved.

EventKindUnreadable carries the undecoded line in Raw and the decode error in Text. It is:

  • distinct from EventKindUnknown, which stays skipped — a line nobody models is noise; one the provider should have understood is a signal;
  • not an error, because the outcome arrives on the terminal line regardless, and failing a run over a line that only drives display would trade the result for the progress bar.

A driver-level test proves it reaches the caller rather than asserting the constant differs; mutating Stream to filter it alongside EventKindUnknown makes that test fail.

Decode unmarshalled the whole event before reading its type, which made every
field's shape load-bearing on every line — including lines nothing here reads.
A bookkeeping event whose `message` is a string rather than an object failed
the run before the type switch could skip it, so the tolerance documented on
Decode was unreachable for exactly the lines it was written for.

Claude Code emits such a line on context compaction, which is why long runs
were the ones that died, and why the failure looked intermittent.

The type is now read on its own, from a shape nothing else on the line can
break. A modelled line whose shape is unreadable is skipped rather than fatal:
these events drive progress display, while the outcome comes off the terminal
line, so an unreadable one costs a caller a tool call it was going to watch
rather than the result it was waiting for. The terminal line stays strict —
reporting success off a result nobody could parse would invent a verdict.
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

bulwark

  • scan — no findings

  • coverage — go: 83.5% (baseline 83.4%); go patch: 100.0% (15/15 new lines; baseline 83.4%)

📦 Full bulwark output — complete scan and coverage logs.

A line whose type the provider models but whose shape it cannot read was
returned as the zero event, which the driver skips. That is indistinguishable
from a turn where nothing happened: a caller rendering the run has nothing to
show, and one diagnosing a CLI whose output has moved has nothing to look at.

EventKindUnreadable carries the undecoded line in Raw and the reason in Text.
It is distinct from EventKindUnknown, which stays skipped, because the two
mean different things — a line nobody models is noise, while one the provider
should have understood is a signal. It is not an error, because the run's
outcome arrives on the terminal line regardless, and failing a run over a line
that only drives display would trade the result for the progress bar.
@pedromvgomes pedromvgomes changed the title fix(claudecode): a line this package does not model cannot fail the run feat(claudecode): an unmodelled line cannot fail the run, and an unreadable one is an event Sep 7, 2026
@pedromvgomes
pedromvgomes merged commit 2154ab5 into main Sep 7, 2026
15 of 17 checks passed
@pedromvgomes
pedromvgomes deleted the fix/an-unmodelled-line-cannot-fail-the-run branch September 7, 2026 10:14
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