v0.11.0 — empty replies, cancellation, and stops that read as failures
Some messages came back empty. Retrying gave another empty reply; something else
later worked. The shape that made it reproducible: a previous turn that ended
while a background shell task was still running.
One invocation, more than one turn
A claude -p --resume does not necessarily answer the prompt it was given
first. When a background task outlives its turn, the CLI queues a
<task-notification> for itself, answers that, and only then dequeues the
message the bridge sent — and each of those turns ends with a result frame of
its own.
This adapter treated the first result as the end of the turn. The
notification's arrives about 70ms in, with zero usage and no text. So the turn
ended before the answer had started: the server was told the turn cost nothing,
and the real reply was dropped frame by frame while the CLI wrote it. The retry
hit the same queued notification, which is why it failed twice and then stopped
failing.
The CLI distinguishes them. A turn it queued for itself carries origin
({"kind":"task-notification"}); the one answering our prompt carries none.
That was captured from a real 2.1.x session rather than guessed at, and the
fixture these tests replay is that capture spliced in front of a real two-tool
turn — neither half written by hand.
A stamped result is held rather than acted on. If the CLI exits having produced
only held frames, the turn settles from the last one instead of reporting "the
AI returned no response", so an origin nobody has seen before costs the turn
the ~600ms until the process exits rather than the turn itself.
cancel works, for the first time
The server package has sent {"type":"cancel"} for as long as it has had a stop
button. The bridge logged "unknown message type received" and let the turn run
to its end on the operator's machine, while the server sat waiting for a
cancelled frame that was never coming.
It now ends the turn the way a bound does — SIGINT, escalating only if ignored,
so the session stays resumable — keeps what the turn produced, and answers
cancelled after the turn's own events. Replying on receipt would cut off
the partial answer, since the server treats cancelled as terminal.
A stopped turn is not a failed turn
SIGINT landing mid-tool commonly leaves a non-zero exit, and the finalizer read
it like any other: a cancelled turn arrived as the answer it had written,
followed by provider_error — "claude CLI exited with code 143". Somebody who
pressed stop was shown an error they had caused and could do nothing about. An
aborted turn now ends with done and nothing else. A CLI that failed on its own
is still reported.
Also
donecarriessubtype— how the CLI classified the ending (success,
error_during_execution,error_max_turns). It is the only thing worth
showing about an empty answer, sincestop_reasonis null on several of those
paths. Pair withtetrixdev/laravel-ai-bridgev0.14.0, which forwards it.- Every
resultis logged with its turn count, subtype, duration and
whether it ended the turn, plus one count of everything dropped after the turn
ended. The per-frame warnings said which frames were lost, never how many.
Codex and Gemini are unaffected by the queued-turn fault: neither has a queue of
its own work, and one invocation answers one prompt.
Verification
671 tests, both type-check projects clean, 36/36 end to end against a real
Claude — including the incident itself, reproduced by leaving a background task
running, and an assertion that the queued notification really was in that turn
(without it, the check would pass against the bug it names). Every guard proved
by mutation: the guard removed, its one covering test confirmed to fail.