Stream wire (SSE frames, contract v2)
The SSE frame layer of the stream protocol (which documents the chunks these frames carry). Every Helix SSE surface that carries a run's stream — the CF Durable Object's /sse, the StreamServer DO's /sse, agent-server's /sse (the remote-agent protocol), core's ResumableStreamHandler — speaks one protocol. Core owns it (core/src/transport/stream-frame.ts): every writer encodes through encodeStreamFrame (or streamFrameToSSE, its unserialized fields) and every reader decodes through decodeStreamFrame and classifies the stream's end with classifyStreamTerminal. Introduced by WE MR C1; the user's rulings a–c (2026-10-09) and D-4 (2026-10-11).
No back-compat: deploy writers and readers together. A v1 reader misreads a v2 stream and a v2 reader reads a v1 failure as a drop (see the upgrade guide).
Frames
| Event | Data | Kind | Meaning |
|---|---|---|---|
chunk | { sequence, chunk }, SSE id: = the sequence | content | One stream chunk. The chunk payload is passed through opaquely (only the envelope is checked). |
end | { status, output?, state?, usage? } | terminal | The stream ended. status: completed, paused (suspended) or interrupted (stopped). |
fail | { error, errorDetail?, recoverable } | terminal | The stream failed. recoverable: true means re-attach from the cursor. |
truncated | { floor, errorDetail } (code TRUNCATED) | terminal | The cursor is below the retention floor: refetch the snapshot and resume from its sequence. |
rewound | { atStep, atSequence?, minSequence? } | marker | A cleanupToStep removed chunks of steps after atStep while the reader was attached. |
caught-up | { head } | marker | The replay reached head; live frames follow. The backlog boundary (catchUpHead). |
Comments (: heartbeat, the DO's : waiting park marker) are not frames. An event this version does not know is unknown (ignored) whatever its data; a known event whose JSON or envelope does not validate is malformed (each reader turns it into a retryable typed error, never a skip and never an end). The decoder reads LF and CRLF lines, and flush() delivers a last frame that has no closing blank line.
The v1 names are gone: error decodes to unknown; fail is always a failure (the v1 fail + code: 'TRUNCATED' overload is the truncated frame); the v1 non-terminal truncated cleanup marker is rewound; the DO's : connected comment is the caught-up frame.
The terminal state machine
Each connection ends with exactly one terminal frame, then the body closes. A reader maps the last frame — or the body's end — through classifyStreamTerminal(terminal | 'eof'):
| Reader saw | Action | What the reader does |
|---|---|---|
end | done | Ends (a non-completed status is not success: a remote dispatch recovers through /status). |
fail | fail | Surfaces the failure with its errorDetail (a stream reader: one error chunk, then done). |
truncated | truncated | Throws StreamTruncatedError. |
| EOF with no terminal frame | drop | Throws the typed, retryable StreamDropError — never a clean end (ruling a). |
A reader whose own caller aborted the read (CP-102: signal, close(), the chat transport's stop) ends quietly instead: an abort is not a drop. A bare close by the server is only an eviction (slow consumer, staleness, the DO's connection cap — typed by MR C2's evicted frame) or a crash, so every reader treats it as a drop and re-attaches.
Writers guarantee the terminal on every exit: the DO writes end with the persisted end status (a stopped run's interrupted included), end{paused} or fail for every non-active status; agent-server writes a recoverable transport_stream_drop fail when its source ends without one; ResumableStreamHandler writes the stream's own outcome from getStreamInfo (a reader that ended while the stream is still active is a drop).
The retention floor (decision 1)
truncated{floor} is answered only while a cleanupToStep marker (truncated_at_step) is set — a cleanup removed chunks inside the current run — and the cursor is below the floor (the highest sequence that is not replayable: MIN(sequence) - 1, or the counter when the log is empty). resetStream and reactivateStream clear the marker: a reset is a deliberate new run whose old chunks no reader expects, so a reader from 0 is served the next run, as the in-DO reader and the StreamManager contract do. The cross-service guarantee holds: a reconnect never silently replays only a cleanup's survivors. The DO and the StreamServer DO apply the same rule; StreamServer's /info reports the floor (oldestRetainedSequence).
rewound while attached
cleanupToStep broadcasts rewound{atStep, atSequence: head, minSequence?} on every cleanup (a no-op one included). A reader prunes the buffered chunks the cleanup removed (step after atStep, sequence above minSequence); once its buffer is drained, a reader still behind atSequence on a live stream cannot make progress and throws StreamTruncatedError (in-DO reader parity, contract G4.truncation-throws). A terminal frame pre-empts it. A connection that already closed gets no rewound, so a push-SSE reader of an ended stream delivers the chunks it was pushed and then ends (FU-G4-BINDING-AFTER-ENDSTREAM-RETURNS-DONE).
The remote protocol
A remote transport (HttpRemoteAgentTransport, the CF DO's DOStubTransport) decodes a /sse body with core decodeTransportEvents into TransportEvents — chunk, end, fail, truncated — and throws StreamDropError for a body with no terminal frame. Every runtime's remote dispatch maps a terminal event through core remoteTerminalOutcome (completed / stopped / retry / failed), so a remote failure keeps its errorDetail; a transport drop falls through to the dispatch's post-drop /status recovery. Entries (/start, /resume) are sent exactly once (CP-95): see the remote agents guide.
The chat reply (D-4)
@helix-agents/ai-sdk's transformer ends a reply that forwarded a run error with { type: 'finish', finishReason: 'error' } after the typed error chunk; the chat transport reads a fail frame that way, and errors its UI stream with StreamDropError when the body ends with no terminal frame.
Where it is tested
- Core:
core/src/__tests__/transport/stream-frame-codec.test.ts(round-trips, no back-compat),stream-terminal.test.ts,sse-frame-decoder.test.ts(flush, CRLF). - The StreamManager contract (
core/src/testing/stream-manager-contract.ts):W.eof-is-a-drop(SSE readers, via thecutConnectionhook), the G4 and RF cases on every manager. - Writers:
runtime-cloudflare/.../integration/do-sse-terminals.integ.test.ts,store-cloudflare/.../integration/stream-server-real.integ.test.ts(wire v2 block),agent-server/src/__tests__/agent-server-wire-v2.test.ts,core/src/__tests__/stream/endpoint.test.ts.