Stream wire contract v2 (WE MR C1)
This guide covers the release in which every Helix SSE surface (the CF Durable Object's /sse, the StreamServer DO's /sse, agent-server's /sse, core's ResumableStreamHandler) speaks one protocol, encoded and decoded by core, and a stream that ends without a terminal frame is a typed, retryable failure instead of a clean end. It is a major release of @helix-agents/runtime-cloudflare, @helix-agents/agent-server and @helix-agents/ai-sdk, and a minor (0.x) release of @helix-agents/core and @helix-agents/store-cloudflare. Nothing is deprecated: there is no back-compat.
Protocol reference: Stream wire. The user's rulings: a–c (2026-10-09), D-4 (2026-10-11).
Deploy writers and readers together
A v1 producer and a v2 consumer (or the reverse) do not interoperate: a v2 reader ignores the v1 error frame and then reads the body's end as a drop (StreamDropError, retried); a v1 reader does not know truncated, rewound or caught-up and treats a v2 end without its old fields as before. Roll out the packages that write a stream (runtime-cloudflare, store-cloudflare, agent-server, core's ResumableStreamHandler) and the ones that read it (runtime-cloudflare's clients and transports, store-cloudflare's DurableObjectStreamManager, core's HttpRemoteAgentTransport, ai-sdk's chat transport) in one deploy. For cross-service remote agents, upgrade the producer and the consumer services together.
Frame renames
| v1 | v2 |
|---|---|
event: error { error, errorDetail?, recoverable? } | event: fail { error, errorDetail?, recoverable } |
fail / error with code: 'TRUNCATED', floor | event: truncated { floor, errorDetail } (terminal) |
non-terminal truncated { truncatedAtStep, … } | event: rewound { atStep, atSequence?, minSequence? } |
the DO's : connected comment | event: caught-up { head } |
StreamServer end { finalOutput } | end { status, output?, state?, usage? } |
ResumableStreamHandler end { streamId }, error | end { status } / fail; chunk data { sequence, chunk } |
A hand-written SSE client should decode with core's createSSEFrameDecoder + decodeStreamFrame (or iterate parseSSEFrames) and classify the end with classifyStreamTerminal. ResumableStreamHandler keeps a caller-set eventName / serializeChunk for its chunk frames; its terminal frames are always the protocol's.
A cut stream is a drop, never a clean end (ruling a)
Every reader now throws the retryable StreamDropError when a body ends with no terminal frame (an eviction, a proxy dropping the connection, a crash), unless its own caller aborted the read (CP-102). Before, these readers ended cleanly and a consumer could not tell a drop from a finished run.
| Reader | Before | Now |
|---|---|---|
DOStreamManagerClient.createResumableReader | done | throws StreamDropError (re-attach) |
DOFrontendExecutor handle stream() | done | throws StreamDropError |
DurableObjectStreamManager readers | done | throws StreamDropError |
HttpRemoteAgentTransport / DOStubTransport.stream | the iterator ended | throws StreamDropError |
ai-sdk createHelixChatTransport | a clean finish | the UI stream errors with StreamDropError |
agent-server streamAgent on an active stream | a synthesized end{completed} | a recoverable transport_stream_drop fail |
Retry by re-attaching from your cursor (fromSequence / the snapshot's streamSequence); never re-send the request that started the run. A rewound frame (a cleanup while you were attached) that leaves the reader behind the head throws StreamTruncatedError, as the in-process readers always did.
Reads that failed no longer read as "absent"
DOStreamManagerClient.getStreamInfo / getChunks* (CP-28) and DurableObjectStreamManager.createReader / createResumableReader / getChunks* return null / [] only when the stream does not exist (a 404, exists: false). Any other failure throws (DOPeerError / StreamConnectionError, retryable on a 5xx). DOStreamManager.getStreamInfo (the in-DO manager) returns null for a stream never created and for an id that is not the DO's own stream (RM-71).
Remote agents
TransportEvent'serrorvariant isfail, andtruncated{floor, errorDetail}is new. ARemoteAgentTransportyou implement should decode with coredecodeTransportEvents.RemoteAgentFailedErrorcarries the remote failure'serrorDetail(new optional third argument), kept on every runtime's failed sub-agent result.HttpRemoteAgentTransportandDOStubTransportsend/startand/resumeexactly once (CP-95, interim until D-2's idempotency key). A thrown request, or a 5xx that is not the host's typed retryable answer, rejects with the new non-retryableRemoteEntryOutcomeUnknownError(transport_remote_entry_outcome_unknown); check the remote session's status before acting.HttpTransportConfig.maxRetriesnow applies to the reads and stop requests only.- A remote dispatch re-attaches to a child it already started (any
/statusanswer, or the sub-session ref'sremote.streamId) and never re-POSTs/start(the D-2 guard); aninterruptedchild is a typed failure. After a/startwhose outcome is unknown, it reads the child's status once (corereadChildAfterUnknownStart) and attaches when the child answers; otherwise the call fails withtransport_remote_entry_outcome_unknown.
Chat replies end with finishReason: 'error' (D-4)
A reply that forwarded a run error now ends with { type: 'finish', finishReason: 'error' } after the error chunk (AISDKFinishEvent.finishReason). Every supported ai version (6.0.0 and later) accepts it. If you assert on the exact chunk sequence of an errored reply, expect the reason.
Removed
parseDOSSEStreamand itsParsedStreamChunk/ParsedStreamEnd/ParsedStreamFail/ParsedStreamTruncated/ParsedSSEItem/ParseDOSSEStreamOptionstypes andisParsed*guards (@helix-agents/ai-sdk/cloudflare): decode with coredecodeStreamFrame.- Core
parseNamedSSEEvent: usedecodeStreamFrame.