Quick Start
1
Decode a frame
decodeEvent never throws. Every rejection is a value with a reason, so a client can count them.2
Stop at the terminal event
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 carriesmsgId. Fields listed are the ones beyond that.
What The Decoder Refuses
decodeEvent takes unknown, so the validation below is the wire contract — not a suggestion a buggy engine adapter can skip.
Approval choices
The decoder exportsdecodeApprovalChoice, and the set is exhaustive — every unknown string returns null, never a silent default.
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:
type: "error" in its payload decoded as an ill-formed error, was dropped silently, and text vanished from the answer.
How A Turn Streams
Fields That Trap Readers
Terminal events are mutually exclusive and final. After
end, cancelled, or error, nothing else may follow. A cancelled turn is never persisted, so no end and no usage follow it.chat_id on the wire is per-conversation. Each request carries the id of the conversation it belongs to, minted at New chat via controller.setChat(mintChatId()). In a shipped app it is never the literal "unassigned" — that placeholder only appears when setChat is never called, which keys every conversation to one server-side thread against an engine that stores history by chat_id. See New chat semantics.Unknown
ErrorKind degrades to "internal". decode.ts maps any kind it does not recognise to "internal" rather than dropping the event, so a newer engine’s category still reaches the user.Large
tool_result payloads no longer block the main thread. A big file arriving as one frame over many small chunks is read in linear time — see Frame buffering below.Error Kinds
ErrorKind selects the recovery the UI offers.
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.
A CRLF, LF, or bare-CR stream is handled identically.
createSseReader tracks a pending \r across chunk boundaries, so a \r\n split between two chunks is still one terminator, and a lone-CR stream still completes its last frame. Mixed-terminator streams (a CRLF followed by a bare LF, legal per the SSE spec) are also handled correctly — the LF that follows a swallowed CRLF is still recognised as its own line break, so an event boundary written two ways in one stream is not lost. Before this, a chunk ending in \r followed by a chunk beginning \n synthesised a spurious frame boundary and half the answer could vanish; before #4589 the reverse could also happen — a CRLF-then-LF pair inside one stream joined two frames into one, so an event boundary was lost. Pinned by the differential-probe test in sse.test.ts — cutting event: delta\r\n\ndata: x\n\n at [13,14] yields two frames when correct and one when the flag is inverted.The
\n\n boundary that straddles two chunks is handled explicitly. The reader searches for the frame terminator inside each arriving chunk alone rather than over the concatenated buffer. When the buffered text ends with \n and the next chunk begins with \n, that \n\n exists only across the join and is invisible to the per-chunk search — the reader detects that case up front and completes the frame. Pinned by the differential-probe test in sse.test.ts — 932 (stream, chunking) pairs, every split index for LF, CRLF, bare CR, mixed terminators, comments, folded data, id/retry, no-colon lines, blank runs, and unterminated tails, plus each with an empty chunk wedged in.Frame buffering is linear in frame length, not quadratic.
createSseReader keeps arriving chunks as an unjoined array and searches for the \n\n boundary inside each chunk alone; the pieces are joined only when a frame completes, over that frame’s own length. A single large frame arriving over many small chunks — a tool_result carrying a big file over a cellular link is exactly that shape — is O(n) in its length. Measured for one 1 MB frame over 512 B chunks: 2.4 / 10.7 / 40.7 / 163.1 ms at 128 / 256 / 512 / 1024 kB before the fix (4.01× per doubling) → 0.3 / 0.5 / 0.8 / 1.4 ms after (1.73× per doubling). At 1 / 2 / 4 MB over 1 kB chunks: 100 / 288 / 1185 ms → 1.3 / 2.0 / 2.8 ms. Many small frames — the common case — are unchanged.The SSE reader strips exactly one leading space from a field value, per the SSE spec — never more. A payload that legitimately begins with whitespace (indented JSON, or text whose first token is deliberately padded) is preserved verbatim after the single-space strip:
" {" on the wire becomes " {" in frame.data, not "{". The rule is if (value.startsWith(" ")) value = value.slice(1) — never value.trimStart(), which would strip all leading whitespace and corrupt those payloads silently, with neither the reader nor the decoder reporting the corruption. Pinned by "the SSE reader strips exactly one leading space, per the SSE spec".The SSE reader recognises four fields:
event, data, id, and retry. id and retry are read and discarded today (the desktop engine sends neither), but they still count as “recognised”: a retry-only frame is a real frame the reader deliberately ignored, not a null that vanishes from the stream. The paired rule: a block carrying no recognised field at all (blank lines, comments only, or only unknown fields) returns null — otherwise every SSE heartbeat would inject a junk { event: "", data: "" } frame. This is a reader contract, upstream of the decoder. Pinned by "a retry-only frame is read and discarded, not dropped" and "a block with no recognised field is not a frame".An
id:-only frame is read-and-discarded, the same way retry: is. In protocol/src/sse.ts, id is the field that would become the resume cursor if the engine ever sent one; it is not consumed today, but the reader must not drop the frame around it. Same rule as retry, different field — the recognised-but-unused set is {id, retry}, not {retry} alone.A line without a colon is a legal SSE field. Per the SSE spec, a bare
data (no colon, no value) is an empty data field, not garbage — the reader must not drop the frame that contains it. This is subtle because most implementations parse field:value only; the spec requires the field-only form to work, so protocol/src/sse.ts treats a colonless line as field with an empty value rather than discarding it. The paired positive case remains the ordinary field: value line, which still parses as before.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.
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:
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
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
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
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
Approvals & Cancellation
The human-in-the-loop flow on mobile.
Capabilities & Gaps
Which events each engine emits today.
Dropped Events
Where a refused frame goes instead of the floor.
Errors & Recovery
How each
ErrorKind maps to a recovery affordance.
