Skip to main content
When the wire sends a frame the decoder cannot make sense of, the mobile app shows the refusal on the transcript instead of dropping it on the floor.

Quick Start

1

See a dropped row

The composition root already wires the sink, so a dropped row appears on its own whenever a frame is malformed — nothing to enable.
2

Toggle visibility

settings.showDiagnostics (labelled “Show dropped events”) is the switch that hides or shows dropped rows.
showDiagnostics is declared but not yet consumed: dropped rows currently render unconditionally. Do not build a feature off the flag until a settings screen reads it.

How It Works

A refusal travels from the engine, through a small port, onto the transcript. The engine and the controller cannot call each other — the engine is built at composition, the controller per app — so the DropSink is the seam between them.
drain() empties the queue, so the same refusal is never repainted on a later event.

The Nine Rejection Reasons

Every refusal carries a machine tag and a user-facing sentence. Seven come from the decoder; two more come from parseFrame, which reads the raw frame before decoding.
parseFrame replaced the old safeParse, which collapsed five distinct wire failures into one missing_msg_id. Keeping the real reason means a 502 page from a proxy no longer reads as an engine bug that does not exist.
A malformed number field — NaN, +Infinity, or -Infinity in versions, active, chars, or seconds — does not drop the frame. The field falls back to its default instead. See What The Decoder Refuses for the full field-level contract.

The Three Drain Points

The controller drains the sink at three points, and each is load-bearing.
The finally drain, not merely a catch drain, is what catches a last-frame refusal: the first version sat at the end of the catch and only ran when the turn had already failed. The tick drain closes a distinct failure mode: when every frame is refused — a proxy answering a stream with HTML, say — nothing is ever yielded, so the per-event drain never runs. Before this, 60 refused frames/second for a minute arrived as one synchronous burst of ~3,600 appends and ~440 ms of blocked main thread on a phone, at the exact moment the app was trying to paint the failure. With the tick drain the worst single publish gap falls from ~110 ms to ~2 ms for the same 3,600 refusals.
drainDrops accumulates: several refusals between two decoded events all reach the transcript, in order. Previously the last refusal of a batch overwrote the others, so the channel that exists to show “40% unparseable” divided the count by N.

Cross-Turn Behaviour

A refusal belongs to exactly one turn, and carry is what keeps one turn’s drops from staining the next.
apply(start) reads from state.carry — the drops noted while nothing was streaming — then clears it. It no longer carries the previous turn’s entire dropped list, which used to paint every later turn as damaged for the lifetime of the app.
after_terminal is the exception: a late frame from the run that just finished stays with that run. Moving it forward would blame the next answer for its predecessor’s mess.

Common Patterns

A dropped row is evidence, not noise. A stream that is 40% unparseable must be visible as a defect, so the count is worth reading.
Anyone hand-constructing the engine must pass onIgnored, or refusals are invisible again.

Best Practices

tool_result.ok is the only signal of success. A decoder refusal is the exact failure that used to hide this — a malformed tool_result made its tool vanish and the turn read as a clean answer.
showDiagnostics is declared but not yet consumed. Building a UI feature off a flag before it consumes anything is how a “planned” setting ships broken.
Dropped events on a clean turn are the mechanism that proves the transcript is not lying. Hiding them reintroduces the silent drop this whole channel exists to undo.
The composition root wires it through the DropSink. Without onIgnored, a truncated answer is reported as a clean success.

The 11 Events

How a rejection becomes a value with a reason.

Mobile Engines

The onIgnored option and the port it hangs off.

Capabilities & Gaps

The closed decode-rejection gap.