Dropped Events
How the mobile app surfaces frames the decoder refused, so a truncated answer is never reported as a clean success.
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
- 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.
ts import { createApp } from "praisonai-mobile/app/boot"; // createApp builds the DropSink and joins the engine to the controller. // A refused frame becomes a dropped row on the turn it arrived beside. const app = await createApp(deps);
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.
export interface DropSink {
note(reason: Dropped["reason"], detail: string): void;
drain(): readonly Dropped[];
}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.
| Reason | What it means | Typical cause | User-facing sentence |
|---|---|---|---|
unparseable_json | The frame body is not valid JSON | An HTML error page from a proxy, or a truncated body | the engine sent something that was not valid JSON |
not_an_object | The body is a JSON array, string, or null | A bare value where an event object was expected | the engine sent a value where an event was expected |
missing_type | The event has no type | A malformed frame with no discriminant | an event arrived with no type |
unknown_event | An event name this version does not know | A newer engine sending an added event | the engine sent an event this version does not know |
missing_msg_id | The event has no msg_id — or an empty-string one | A frame that names no message it belongs to, or names it with "" | an event arrived with no message it belongs to |
missing_required_field | A known event is missing a field it needs | A tool_result with no ok | an event was missing a field it needs |
empty_text | A delta/reasoning with empty text | An empty chunk, which is not the same as no answer | an empty piece of text, which is not the same as no answer |
before_start | An event arrived before the turn began | A late frame from a previous run | an event arrived before the turn began |
after_terminal | An event arrived after the turn ended | A frame from the finished run arriving late | an event arrived after the turn had already ended |
The error-before-start Exception
An error that arrives before the turn began is not dropped — it becomes the turn’s outcome.
Every other event before start is a stray frame and drops as before_start. An error is the exception, because a failure before the first token — a 401, a 403, a 500, a refused connection, no network, a wrong baseUrl — is still this turn’s real outcome. apply() promotes the idle turn to streaming and reprocesses the error, so the true reason survives.
// transcript.ts — an error while idle is promoted, not dropped.
if (state.phase === "idle") {
if (event.type === "error") {
return apply({ ...state, phase: "streaming", msgId: event.msgId }, event);
}
return drop(state, "before_start", event.type); // everything else
}Before this, every pre-first-token failure was dropped, then finish() re-labelled the empty turn kind: "empty" — so a 401 and a dead socket rendered identically as “the engine produced no output” plus a spurious dropped row. The auth → settings recovery branch never survived to the view.
The Three Drain Points
The controller drains the sink at three points, and each is load-bearing.
// After every event, so a refusal paints with the frame it arrived beside.
turn = apply(turn, event);
turn = drainDrops(turn);
// On every coalescer tick, so a stream where every frame is refused still
// paints refusals progressively rather than as one burst at the end.
turn = drainDrops(turn);
if (hadDrops) publish();
// In finally, so a refusal beside the LAST frame — or while the stream was
// dying — is still shown.
turn = drainDrops(turn);
turn = finish(turn);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.
Cross-Turn Behaviour
A refusal belongs to exactly one turn, and carry is what keeps one turn’s drops from staining the next.
// TurnState carries drops that arrived between turns, waiting for the next start.
readonly carry: readonly Dropped[];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.
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.
if (turn.dropped.length > 0) {
// The turn is not a clean success — some frames were refused.
console.log(`${turn.dropped.length} refused`);
}Anyone hand-constructing the engine must pass onIgnored, or refusals are invisible again.
import { createRemoteHttpEngine } from "praisonai-mobile/engines/remote-http";
const engine = createRemoteHttpEngine({
baseUrl,
http,
onIgnored: (reason, detail) => sink.note(reason, detail),
});Best Practices
Never infer tool success from output
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.
Treat a dropped row as evidence, not a warning to hide
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.
Always pass onIgnored when building the engine by hand
The composition root wires it through the DropSink. Without onIgnored, a truncated answer is reported as a clean success.