The 11 Events

One discriminated union of 11 events — every token, tool call, and result is typed.

Every turn is a stream of typed events, decoded once so nothing above has to parse prose.

ts
import { decodeEvent, isDecoded } from "praisonai-mobile/protocol/decode";

const outcome = decodeEvent(rawFrame);
if (isDecoded(outcome) && outcome.event.type === "delta") {
  render(outcome.event.text);
}
Diagram

📡 Wire frame

🔍 decodeEvent

📋 Typed RunEvent

⚠️ Explained no-op

Quick Start

  1. Decode a frame
    ts
    const outcome = decodeEvent(rawFrame);

    decodeEvent never throws. Every rejection is a value with a reason, so a client can count them.

  2. Stop at the terminal event
    ts
    import { isTerminal } from "praisonai-mobile/protocol/events";
    
    if (isTerminal(event)) return;

    After a terminal event nothing else follows.


What Happens To A Rejection

A rejection is not silent — it lands on the transcript as a dropped row.

parseFrame adds two reasons the decoder alone could not report — unparseable_json (an HTML error page or truncated body) and not_an_object (a JSON array, string, or null) — so five distinct wire failures are no longer collapsed to one missing_msg_id.


The 11 Events

Every event carries msgId. Fields listed are the ones beyond that.

EventPurposeKey fieldsTerminal
startA turn beginsrunIdNo
deltaAssistant texttext (never empty)No
reasoningIntermediate thinkingtextNo
tool_draftingA tool is being drafted (a status, not a row)nameNo
tool_callA tool call is issuedcallId, name, argsNo
tool_resultA tool call returnscallId, name, ok, output, secondsNo
approval_requestThe agent asks permissionapprovalId, callId, name, argsNo
usageCost/timing reportchars, seconds, ttftSecondsNo
cancelledThe turn was stoppedrunIdYes
errorThe turn failedkind, messageYes
endThe turn completeduserIndex, assistantIndex, versions, activeYes

What The Decoder Refuses

decodeEvent takes unknown, so the validation below is the wire contract — not a suggestion a buggy engine adapter can skip.

RuleBehaviourWhy it matters
Empty-string msg_id, type, call_id, approval_idFrame is refused (surfaces as a dropped row)An empty msg_id is accepted-then-orphaned: every later event mismatches it and is dropped as wrong_msg_id — the UI reports “engine said nothing”.
NaN / +Infinity / -Infinity in versions, active, chars, secondsNot a finite number, so the field falls back to its default (1, 0, undefined, null respectively)JSON cannot carry these, but decodeEvent takes unknown and a buggy engine adapter can.
versionsClamped to Math.max(1, versions) — never 0 or negativeversions: 0 reaching the UI = a message that exists in zero versions.
activeClamped into [0, versions - 1]An out-of-range active renders the message bubble blank.
assistant_index absentDerived as user_index + 1 (never equal to user_index)Pointing at the user’s own index would render the prompt back as the answer.
assistant_index: undefined (present but undefined)Malformed → resolves to null (Fork/Delete withheld), NOT derivedPresent-but-undefined is a serializer bug; deriving would fabricate an index the disk never wrote.
end.active absent when versions is setDefaults to 0 (first version), NEVER 1Silently selecting the second version of every message is invisible with a single version but wrong the moment there are two.
tool_call.args = JSON arrayRefused; consumers see {}Args render to the user as the command being approved; [1, 2] becoming {"0":1,"1":2} misrepresents what they are authorising.

Approval choices

The decoder exports decodeApprovalChoice, and the set is exhaustive — every unknown string returns null, never a silent default.

ts
import { decodeApprovalChoice } from "praisonai-mobile/protocol/decode";

decodeApprovalChoice("allow");   // "allow"
decodeApprovalChoice("always");  // "always"  — do NOT omit; the button is real
decodeApprovalChoice("deny");    // "deny"
decodeApprovalChoice("maybe");   // null      — never a silent default
decodeApprovalChoice("ALLOW");   // null      — case-sensitive

Event type comes from the SSE event: line, never the data payload

The event’s type is read from the SSE event: line, not from any type field inside the frame’s data object.

The engine builds the decoded event as decodeEvent({ ...parsed.value, type: frame.event }) — the event: name overwrites any type the payload carried. A delta frame whose data object happens to include type: "error" is still a delta:

ts
// A delta frame that carries its own conflicting `type` in the payload.
// event: delta
// data: { "msg_id": "m1", "text": "kept", "type": "error" }
// -> decoded as a delta with text "kept". The `event:` line wins.

This is the shape assumption the whole 11-event contract stands on. Before #4579 the two were transposed, so a delta with type: "error" in its payload decoded as an ill-formed error, was dropped silently, and text vanished from the answer.


How A Turn Streams

Diagram
EngineUserEngineUserpromptstartdelta*tool_draftingtool_callapproval_requestdecide(“allow”)tool_resultdelta*end

Fields That Trap Readers


Error Kinds

ErrorKind selects the recovery the UI offers.

KindMeaning
authThe provider rejected the credential (HTTP 401 or 403) — send the user to settings.
rate_limitRetrying is meaningful.
emptyThe engine produced no output at all.
transportThe stream broke; the engine may be fine.
protocolClient and engine disagree about the contract.
internalAnything else, including unrecognised kinds.

Before this, a 403 — what an engine behind a proxy or a scoped key actually returns — was classified transport, so the UI offered Retry forever instead of sending the user to credentials.


Protocol version reporting

checkProtocol in protocol/src/version.ts compares the engine’s stated version against what this client requires, and shapes a diagnostic a user can paste into a bug report. Two rules keep that diagnostic honest — they are the same absent-vs-present discipline the decoder uses for tool_result.seconds.

The engine statedcheckProtocol reports engine:
A valid non-negative integerthat number
undefined, null, non-integer, negative, NaN, or an objectnull — never 0

engine: 0 would read as “the engine answered 0” instead of “the engine did not answer”, so a version that was never stated is reported as null, not zero — a present number and an absent one are different facts. Pinned by "an engine that cannot state its version reports ABSENT, not zero".

A refusal carries one of four reasons:

reasonWhen
too_oldThe engine’s version is below PROTOCOL_VERSION.
too_newThe engine’s version is above PROTOCOL_VERSION.
unreadableThe engine did not state a readable version (engine: null).
version_mismatchThe versions differ in a way not captured by the more specific reasons above.

The paired positive case: an engine stating exactly PROTOCOL_VERSION passes, so the check does not degenerate to “refuse everything”.


Best Practices

Trust tool_result.ok, never the output

ok is the only signal of tool success. Inferring success from a non-empty output is the exact defect the field prevents.

Route approvals by approvalId

Bind a decision to a row by approvalId, not by position — position holds only while exactly one approval is outstanding.

Treat null as a real value

For end.userIndex and tool_result.seconds, null means “unknown / not persisted”, which is different from 0 and from absent.

For tool_result.seconds the decoder distinguishes three states: the field is absent on the wire (seconds key missing) → decoded as null (unknown); the field is present and null → decoded as null; the field is present and 0 → decoded as 0 (a measured zero — a call the engine timed that returned instantly). The rule is seconds === undefined ? null : seconds — decoding absent as 0 would present a measurement as fact. formatElapsedLocalised (see format-intl.ts) keeps them apart: null → strings.unknownValue, 0 → "0.0s". The same absent/null/zero distinction holds for end.userIndex and usage.ttftSeconds. Pinned by "a tool_result with no seconds decodes as unknown, not as zero" and its pair "a tool_result WITH seconds keeps the number it was given".


Related