A stream end always records the run's status (CP-87)
This release makes the end status of every agent stream explicit. Before it, StreamManager.endStream's terminal argument was optional, and every reader that found no recorded status guessed completed. A run that was interrupted (or, on DBOS, a cancelled workflow's failed run) could therefore reach a cross-service parent as end{completed} with null output, and the parent folded it as a successful child.
It is a minor release (a 0.x package: minor marks an observable behaviour change) of @helix-agents/core, @helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-cloudflare, @helix-agents/runtime-js, @helix-agents/runtime-cloudflare, @helix-agents/runtime-temporal, @helix-agents/runtime-dbos and @helix-agents/agent-server.
endStream requires the run's status
// Before
endStream(streamId: string, finalOutput?: unknown, terminal?: {...}, fence?: StreamRunFence): Promise<void>;
// After
endStream(
streamId: string,
finalOutput: unknown,
terminal: StreamEndTerminal, // { status: 'completed' | 'interrupted'; state?; usage? }
fence?: StreamRunFence
): Promise<void>;- If you call
endStreamyourself, pass the ended run's own status:endStream(streamId, output, { status: 'completed' }), or{ status: 'interrupted' }for a stopped run. A run that failed is never ended: callfailStreamwith its error detail. - If you implement a custom
StreamManager, refuse an end with no valid status (assertStreamEndTerminalfrom@helix-agents/corethrows a typedvalidation_error), persistterminal.statusasStreamMetadata.endStatuson every end, and return it fromgetStreamInfo. The store contract (@helix-agents/core/testing) checks both (ES.end-status-required,ES.end-status-recorded). An untyped caller that omits the status now gets thatvalidation_errorfrom every built-in manager instead of a stream with no recorded status. stateandusagestay optional: a caller attaches them only when its own terminal persist committed.
Readers never guess completed
Core's resumable stream handler (the wire v2 terminal frame), AgentServer.streamAgent, the Cloudflare Durable Object's own /sse frame and @helix-agents/store-cloudflare's stream Durable Object read the recorded status. A stream that ended with no recorded status is a contract violation. It is answered as a retryable transport_stream_drop (core.streamDropTerminal) and logged at error as core.stream.end_status_missing ({ streamId }), so a StreamManager that drops the status shows up in your logs instead of reporting success. createResumableStreamHandler takes an optional logger for that line.
Behaviour changes by runtime
| Where | Before | After |
|---|---|---|
| Temporal, CF Workflows: an interrupted run's stream | no status (read as completed) | interrupted |
| DBOS: an interrupted run's stream (settle, entry restore, workflow) | no status (read as completed) | interrupted |
DBOS settleCancelledRun(…, 'failed') | ended cleanly (read as completed) | failed with a framework_cancelled error detail |
| runtime-js: a completion whose terminal persist did not commit | no status (read as completed by luck) | completed, with no state / usage |
| Core stop / reconcile / revived-stream ends (every runtime) | no status | the run's own status |
CF DO /sse late joiner, store-cloudflare stream DO /end, /info | a missing status was written / read as completed | /end without a valid status is refused 400 validation_error; a missing one reads as none |
The Cloudflare DO's reconcile wrapper keeps the caller's status and still adds the DO's committed state and usage to a status-only end, so a wake-finished run's end frame carries the same payload as a driven run's.