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.

Diagram

🌐 remote-http engine

📥 DropSink

⚙️ Run Controller

⚠️ Dropped row

✅ Visible transcript

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.

    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.

Diagram
TranscriptControllerDropSinkremote-httpTranscriptControllerDropSinkremote-httpparseFrame / decodeEvent refusesonIgnored(reason, detail)note() queues itdrain() after each eventthe queued dropsnoteDropped(reason, detail)a Dropped entry

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.

ts
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.

ReasonWhat it meansTypical causeUser-facing sentence
unparseable_jsonThe frame body is not valid JSONAn HTML error page from a proxy, or a truncated bodythe engine sent something that was not valid JSON
not_an_objectThe body is a JSON array, string, or nullA bare value where an event object was expectedthe engine sent a value where an event was expected
missing_typeThe event has no typeA malformed frame with no discriminantan event arrived with no type
unknown_eventAn event name this version does not knowA newer engine sending an added eventthe engine sent an event this version does not know
missing_msg_idThe event has no msg_id — or an empty-string oneA frame that names no message it belongs to, or names it with ""an event arrived with no message it belongs to
missing_required_fieldA known event is missing a field it needsA tool_result with no okan event was missing a field it needs
empty_textA delta/reasoning with empty textAn empty chunk, which is not the same as no answeran empty piece of text, which is not the same as no answer
before_startAn event arrived before the turn beganA late frame from a previous runan event arrived before the turn began
after_terminalAn event arrived after the turn endedA frame from the finished run arriving latean 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.

ts
// 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.

ts
// 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.

ts
// 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.

Diagram

streaming

between turns

after_terminal

Drop arrives

⚠️ This turn’s dropped

📦 carry → next turn


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.

ts
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.

ts
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.


Related