feat: resumable streams — reconnect to in-flight SSE responses via pluggable delivery durability#955
feat: resumable streams — reconnect to in-flight SSE responses via pluggable delivery durability#955tombeckenham wants to merge 22 commits into
Conversation
Add a transport-level StreamDurability seam to toServerSentEventsResponse: chunks are appended to an ordered log before delivery and each SSE event is tagged with an opaque adapter-owned id: offset. Reconnects (Last-Event-ID) and joins (?offset=-1&runId) replay from the log without re-running the provider. Ships memoryStream (in-core, dev/test) and the new @tanstack/ai-durable-stream package (Durable Streams protocol adapter). Client: fetchServerSentEvents now auto-resumes id-tagged streams, de-dupes replayed prefixes, exposes joinRun(runId), and throws DurableStreamIncompleteError when a durable run ends with no terminal event and no forward progress. Split out of #785 so state persistence and delivery durability land independently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6Arc9bWdLq1aRRnDRCLMt
🚀 Changeset Version Preview3 package(s) bumped directly, 43 bumped as dependents. 🟥 Major bumps
🟨 Minor bumps
🟩 Patch bumps
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis change adds durable, resumable SSE delivery with ordered logs, client reconnection and run joining, in-memory and HTTP durability adapters, bounded failure handling, unified stream errors, documentation, examples, and end-to-end coverage. ChangesResumable SSE delivery
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant SSEEndpoint
participant StreamDurability
participant DurableLog
Client->>SSEEndpoint: POST start stream
SSEEndpoint->>StreamDurability: append chunks
StreamDurability->>DurableLog: persist ordered records
DurableLog-->>SSEEndpoint: return offsets
SSEEndpoint-->>Client: SSE chunks with id offsets
Client->>SSEEndpoint: reconnect with Last-Event-ID
SSEEndpoint->>StreamDurability: read after offset
StreamDurability->>DurableLog: replay ordered records
DurableLog-->>Client: replay remaining SSE chunks
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
View your CI Pipeline Execution ↗ for commit ea387e5
☁️ Nx Cloud last updated this comment at |
@tanstack/ai
@tanstack/ai-acp
@tanstack/ai-angular
@tanstack/ai-anthropic
@tanstack/ai-bedrock
@tanstack/ai-claude-code
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-codex
@tanstack/ai-devtools-core
@tanstack/ai-durable-stream
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-grok-build
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-mcp
@tanstack/ai-mistral
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-opencode
@tanstack/ai-openrouter
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-sandbox
@tanstack/ai-sandbox-cloudflare
@tanstack/ai-sandbox-daytona
@tanstack/ai-sandbox-docker
@tanstack/ai-sandbox-local-process
@tanstack/ai-sandbox-sprites
@tanstack/ai-sandbox-vercel
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-utils
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/openai-base
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (1)
testing/e2e/tests/delivery-durability.spec.ts (1)
3-19: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDocument the aimock policy exception.
Because this spec is exempt from using
aimock(since theapi.durable-deliveryharness route never reaches the LLM provider HTTP layer), please document this policy exception explicitly in the spec's header comment. Based on learnings, exempt specs must explain why they do not reach the LLM provider HTTP layer.📝 Proposed update to the header comment
* (`waitUntil` / durable object / queue), which is a per-platform deployment * concern, not something this transport can guarantee. The client-side * auto-reconnect (Last-Event-ID resend + de-dupe) is covered by unit tests in * `@tanstack/ai-client`. + * + * Note: This spec is exempt from the aimock policy because the tested code path + * never reaches the LLM provider HTTP layer (it uses a fixed-sequence harness). */🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@testing/e2e/tests/delivery-durability.spec.ts` around lines 3 - 19, Update the header comment in the delivery durability spec to explicitly document its aimock exemption, stating that the api.durable-delivery harness route does not reach the LLM provider HTTP layer. Keep the existing producer-alive and producer-dead scope documentation unchanged.Source: Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ai-client/src/connection-adapters.ts`:
- Around line 208-210: Update the SSE ID parsing in the line-processing logic
around pendingId, including the corresponding handling at the second location,
to preserve the ID value exactly after the id: prefix without trimming opaque
offsets. When the field has an empty value, reset pendingId so it is not
retained as a durable empty cursor; preserve the existing continue flow.
In `@packages/ai-durable-stream/package.json`:
- Line 44: Update the `@tanstack/ai` dependency entry in package.json to use the
required workspace:* protocol instead of workspace:^, keeping it as an internal
workspace dependency.
In `@packages/ai-durable-stream/src/durable-stream.ts`:
- Around line 442-445: Update the durable stream HTTP calls in the create,
append, and close flows around fetchFn to use a configurable operation timeout
with an AbortSignal, ensuring the signal is applied to every request and each
append retry. Preserve existing request behavior while allowing stalled
durability operations to terminate.
- Around line 577-580: Update the record-processing loop around
parseDataRecords(event.data) to validate that raw record sequences are strictly
increasing within each response, rejecting duplicates or decreasing sequences
before applying deliveredThroughSeq replay de-duplication. Preserve
de-duplication only for valid records already delivered across responses, and
ensure invalid ordering propagates as an error rather than silently skipping
records.
In `@packages/ai/src/stream-durability.ts`:
- Line 130: Update the process-global memoryLogs storage and close() flow to
bound completed log retention: add configurable expiration and/or capacity
limits, track completion time, and evict completed runs only after their resume
window expires while preserving resumability during that window. Ensure cleanup
also covers the logic referenced around the close handling at lines 170-175.
In `@packages/ai/src/stream-to-response.ts`:
- Line 428: Update the parameter documentation for the stream-to-response
function’s init option to state that batch is nested under durability
(durability.batch), rather than implying it is a direct init property; retain
the existing documentation for abortController and durability.
- Line 388: Move the failure.error throw associated with the recorded failure
outside the finally block in the surrounding stream response flow. Preserve the
existing cancellation behavior by retaining the failure check and throwing
immediately after finally completes, avoiding any control-flow throw from within
finally.
In `@packages/ai/tests/stream-delivery-contract.test.ts`:
- Line 1: Move stream-delivery-contract.test.ts beside stream-to-response.ts,
stream-durability.test.ts beside stream-durability.ts, and
stream-to-response-durability.test.ts beside stream-to-response.ts; preserve
each test’s imports and behavior after relocation.
---
Nitpick comments:
In `@testing/e2e/tests/delivery-durability.spec.ts`:
- Around line 3-19: Update the header comment in the delivery durability spec to
explicitly document its aimock exemption, stating that the api.durable-delivery
harness route does not reach the LLM provider HTTP layer. Keep the existing
producer-alive and producer-dead scope documentation unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9bc2471e-67e2-4bc1-b812-1f6c5bab9267
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (25)
.changeset/resumable-streams.mddocs/chat/connection-adapters.mddocs/config.jsondocs/resumable-streams/overview.mdpackages/ai-client/src/connection-adapters.tspackages/ai-client/src/index.tspackages/ai-client/tests/connection-adapters-resumable.test.tspackages/ai-durable-stream/package.jsonpackages/ai-durable-stream/src/durable-stream.tspackages/ai-durable-stream/src/index.tspackages/ai-durable-stream/tests/durable-stream-types.test-d.tspackages/ai-durable-stream/tests/durable-stream.test.tspackages/ai-durable-stream/tsconfig.jsonpackages/ai-durable-stream/vite.config.tspackages/ai/skills/ai-core/chat-experience/SKILL.mdpackages/ai/src/index.tspackages/ai/src/stream-durability.tspackages/ai/src/stream-to-response.tspackages/ai/tests/stream-delivery-contract.test.tspackages/ai/tests/stream-durability-types.test-d.tspackages/ai/tests/stream-durability.test.tspackages/ai/tests/stream-to-response-durability.test.tstesting/e2e/src/routeTree.gen.tstesting/e2e/src/routes/api.durable-delivery.tstesting/e2e/tests/delivery-durability.spec.ts
| "test:types": "tsc" | ||
| }, | ||
| "peerDependencies": { | ||
| "@tanstack/ai": "workspace:^" |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Use the required internal workspace protocol.
- "`@tanstack/ai`": "workspace:^"
+ "`@tanstack/ai`": "workspace:*"As per coding guidelines, internal package dependencies must use workspace:*. <coding_guidelines>
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "@tanstack/ai": "workspace:^" | |
| "`@tanstack/ai`": "workspace:*" |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ai-durable-stream/package.json` at line 44, Update the `@tanstack/ai`
dependency entry in package.json to use the required workspace:* protocol
instead of workspace:^, keeping it as an internal workspace dependency.
Source: Coding guidelines
Remove the transport-level delivery-durability feature from this branch: the @tanstack/ai-durable-stream package, the StreamDurability seam and memoryStream in core, the resumable-SSE client machinery (Last-Event-ID reconnect, joinRun, DurableStreamIncompleteError), the durable-delivery e2e harness, and the Delivery Durability docs page. State persistence (middleware + stores + interrupt resume) is unchanged. stream-to-response.ts and the SSE chunk parser revert to main; docs and the persistence skill now point to the separate Resumable Streams feature instead of in-branch delivery-durability docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6Arc9bWdLq1aRRnDRCLMt
…ce binding Document running against the Durable Streams Cloudflare Workers + DO backend: same protocol, no new adapter — inject the service binding's fetch via the adapter's injectable fetch option, or point server at the deployed Worker URL. Note the DO alarm satisfies the lease/reaper needed for producer-death terminalization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A6Arc9bWdLq1aRRnDRCLMt
… optional when fetch is provided The durableStream adapter is a protocol client, so a Cloudflare Workers + Durable Objects backend that speaks the same protocol needs no new adapter — just the injected `fetch` seam. Over a service binding the host is irrelevant (dispatch routes to the bound Worker by path), so `server` is now optional whenever `fetch` is supplied and defaults to a reserved `.internal` base. Passing neither `server` nor `fetch` throws loudly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent failures Addresses review findings on the resumable-streams PR: #1 memoryStream never evicted its process-global log Map (unbounded growth). Completed logs are now swept after a grace window with a hard LRU cap; active runs are never evicted. Adds MemoryStreamOptions. #3 memoryStream join/resume of an unknown or evicted run parked forever. A concrete resume of a missing log now throws; a from-start join bounds the wait for the first chunk (firstChunkDeadlineMs) instead of hanging. #2 The client resumable-SSE reconnect loop and the durableStream read loop were unbounded and backoff-free. The client now throttles between attempts and caps the total (StreamReconnectLimitError); durableStream caps consecutive body-read-failure retries. Normal long-poll advancement is never throttled. Adds reconnect options to both. #4 Durability terminal-append / close failures are rethrown to the live consumer but invisible to a replaying joiner. toServerSentEventsResponse now accepts `debug` to record the real cause server-side via the library's logger. Also: durableStream `server` is optional when `fetch` is provided (service bindings). Docs + changeset updated; unit tests added for each path (timing- and eviction-based behavior is covered by unit tests rather than the aimock e2e harness, which can't exercise it deterministically). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/ai/src/stream-durability.ts (1)
245-310: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winEvict empty logs left by timed-out or aborted joins.
Each nonexistent from-start join creates an incomplete
MemoryLog. Timeout and abort only remove its waiter, while eviction explicitly skips incomplete logs, so arbitrary run IDs permanently growmemoryLogs.Delete the log after cleanup when it is still empty, incomplete, has no remaining waiters, and remains the map’s current value.
Proposed cleanup
+ const deleteEmptyLogIfUnused = () => { + if ( + memoryLogs.get(runId) === log && + log.entries.length === 0 && + !log.complete && + log.waiters.length === 0 + ) { + memoryLogs.delete(runId) + } + } + for (;;) { ... - if (log.complete || signal?.aborted) return + if (log.complete) return + if (signal?.aborted) { + deleteEmptyLogIfUnused() + return + } ... const onAbort = () => { cleanup() + deleteEmptyLogIfUnused() resolve() } ... timer = setTimeout(() => { cleanup() + deleteEmptyLogIfUnused() reject(🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai/src/stream-durability.ts` around lines 245 - 310, Update the first-chunk timeout and abort cleanup in the read generator to evict the corresponding memory log when it is still empty and incomplete, has no remaining waiters, and remains the current value in memoryLogs. Perform this check after removing the waiter, using the existing runId/log references, while preserving normal wake-up and completed-log behavior.
🧹 Nitpick comments (1)
packages/ai/tests/stream-durability.test.ts (1)
1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winColocate the new unit tests with their covered source modules.
packages/ai/tests/stream-durability.test.ts#L1-L1: move topackages/ai/src/stream-durability.test.ts.packages/ai-client/tests/connection-adapters-resumable.test.ts#L5-L5: move topackages/ai-client/src/connection-adapters-resumable.test.ts.As per coding guidelines, “Place unit tests in
*.test.tsfiles alongside the source they cover.”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai/tests/stream-durability.test.ts` at line 1, Move the stream durability tests from packages/ai/tests/stream-durability.test.ts to packages/ai/src/stream-durability.test.ts, preserving their contents and updating imports as needed. Also move the connection adapter resumable tests from packages/ai-client/tests/connection-adapters-resumable.test.ts to packages/ai-client/src/connection-adapters-resumable.test.ts, adjusting relative imports for the new location.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ai-client/src/connection-adapters.ts`:
- Around line 109-115: Update resolveReconnectOptions to validate explicitly
provided maxAttempts and delayMs before returning resolved values. Reject
non-finite values, including NaN and Infinity, so reconnect attempts remain
bounded and delays remain effective; preserve the existing defaults when options
are omitted.
In `@packages/ai-client/tests/connection-adapters-resumable.test.ts`:
- Around line 271-275: Update the abort synchronization in the resumable
connection test around controller.abort() to wait for a signal that the first
chunk has been received and the stream has entered the reconnect throttle,
rather than using the fixed 20ms setTimeout. Preserve the existing behavior of
aborting immediately after that state and awaiting done.
---
Outside diff comments:
In `@packages/ai/src/stream-durability.ts`:
- Around line 245-310: Update the first-chunk timeout and abort cleanup in the
read generator to evict the corresponding memory log when it is still empty and
incomplete, has no remaining waiters, and remains the current value in
memoryLogs. Perform this check after removing the waiter, using the existing
runId/log references, while preserving normal wake-up and completed-log
behavior.
---
Nitpick comments:
In `@packages/ai/tests/stream-durability.test.ts`:
- Line 1: Move the stream durability tests from
packages/ai/tests/stream-durability.test.ts to
packages/ai/src/stream-durability.test.ts, preserving their contents and
updating imports as needed. Also move the connection adapter resumable tests
from packages/ai-client/tests/connection-adapters-resumable.test.ts to
packages/ai-client/src/connection-adapters-resumable.test.ts, adjusting relative
imports for the new location.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ba0410e7-a289-4792-97ea-c4d67a49a2a8
📒 Files selected for processing (12)
.changeset/resumable-streams.mddocs/resumable-streams/overview.mdpackages/ai-client/src/connection-adapters.tspackages/ai-client/src/index.tspackages/ai-client/tests/connection-adapters-resumable.test.tspackages/ai-durable-stream/src/durable-stream.tspackages/ai-durable-stream/tests/durable-stream.test.tspackages/ai/src/index.tspackages/ai/src/stream-durability.tspackages/ai/src/stream-to-response.tspackages/ai/tests/stream-durability.test.tspackages/ai/tests/stream-to-response-durability.test.ts
🚧 Files skipped from review as they are similar to previous changes (8)
- packages/ai/src/index.ts
- packages/ai-client/src/index.ts
- .changeset/resumable-streams.md
- packages/ai/tests/stream-to-response-durability.test.ts
- packages/ai-durable-stream/tests/durable-stream.test.ts
- docs/resumable-streams/overview.md
- packages/ai-durable-stream/src/durable-stream.ts
- packages/ai/src/stream-to-response.ts
New /resumable route pair: api.resumable.ts (memoryStream-backed POST that appends+tags each SSE event, plus a GET joinRun replay endpoint) and resumable.tsx (start a run, then join it by run ID — in a second tab or after a reload — replaying from the durability log without re-running the model). Nav link added to Header. Kept the shared api.tanchat route untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clarify that durableStream works with any Durable Streams protocol server, and that other systems (a Postgres-backed log via Electric, Redis streams, a queue) can back durability by implementing the four-method StreamDurability interface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
examples/ts-react-chat/src/routes/resumable.tsx (1)
143-159: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider using Tailwind CSS classes instead of inline styles.
Since the project is built with Tailwind CSS (as seen in
Header.tsx), consider replacing these inline style objects with inline utility classes directly on the respective JSX elements to maintain a unified and consistent styling approach across the codebase.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@examples/ts-react-chat/src/routes/resumable.tsx` around lines 143 - 159, Replace the inline style objects panel, h2, and output in the resumable route with equivalent Tailwind utility classes on their corresponding JSX elements, matching the existing styling and the approach used in Header.tsx; remove the now-unused style definitions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@examples/ts-react-chat/src/routes/resumable.tsx`:
- Around line 143-159: Replace the inline style objects panel, h2, and output in
the resumable route with equivalent Tailwind utility classes on their
corresponding JSX elements, matching the existing styling and the approach used
in Header.tsx; remove the now-unused style definitions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 02e0294c-8ccf-486a-9917-2d59e3828012
📒 Files selected for processing (5)
docs/resumable-streams/overview.mdexamples/ts-react-chat/src/components/Header.tsxexamples/ts-react-chat/src/routeTree.gen.tsexamples/ts-react-chat/src/routes/api.resumable.tsexamples/ts-react-chat/src/routes/resumable.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- docs/resumable-streams/overview.md
Extend resumable streams (delivery durability) beyond SSE to NDJSON and
the XHR transports.
Server (@tanstack/ai):
- toHttpStream gains an optional getId; when present each NDJSON line is
emitted as an { id, chunk } envelope (NDJSON has no native event id).
Untagged streams stay bare lines, byte-identical to before.
- toHttpResponse gains durability/batch/debug, reusing the same
durableStreamSource as toServerSentEventsResponse.
Client (@tanstack/ai-client):
- Generalize the SSE-only reconnect loop into transport-agnostic
resumableStream(openEventSource, signal, reconnect). Shared line parsers
(linesToSSEEvents/linesToNdjsonEvents) feed fetch (fetchEventSource) and
XHR (xhrEventSource) thunks.
- fetchHttpStream, xhrServerSentEvents, xhrHttpStream are now resumable and
expose joinRun. XHR onerror surfaces StreamReadError so a durable XHR run
can reconnect; StreamReadError message is now transport-neutral.
Tests: NDJSON server durability suite, NDJSON+XHR resumable-transport client
suite, NDJSON arm on the delivery-durability e2e harness + spec.
Docs/skill/changeset updated for NDJSON + XHR.
… CRLF, [DONE] id parity
Round 1 review fixes (7-agent CR):
- HIGH: toServerSentEventsResponse / toHttpResponse constructed the durability
logger only when debug was passed, so terminal-append/close failures were
silently swallowed by default (fully lost on the client-disconnect path).
Now instantiate resolveDebugOption(debug) unconditionally, matching every
other activity (errors category is on by default).
- HIGH: memoryStream.read() called getOrCreateLog before the unknown-run
check, leaving a permanent empty log per unknown/evicted resume — unbounded
growth defeating the eviction logic. Peek with memoryLogs.get; a concrete
offset for an absent run throws without inserting; a from-start join creates
the log for the produce race but deletes it on the first-chunk deadline.
- readStreamLines (fetch) now strips a trailing CR, matching readXhrLines, so
CRLF SSE servers do not miss the [DONE] sentinel.
- Fetch SSE [DONE] synthesis now threads the run's ids (parity with the XHR
xhrSSEParser), so a [DONE]-terminating server that omits ids still yields a
correlated terminal.
- Clarified the ReconnectOptions.maxAttempts comment (counts total lifetime
reconnects, not consecutive no-progress ones).
- Softened the changeset claim: completion terminalizes when the source emits
its own terminal event.
- SKILL.md anti-pattern examples: gpt-4o -> gpt-5.5.
- Tests: fetch NDJSON reconnect test uses reconnect delayMs:0; parseNdjsonEvents
helper mirrors the production !('type' in value) envelope guard.
Call sites cleared:
- responseToSSEEvents: added optional 3rd param fallbackIds; all existing
callers (responseToSSEChunks, fetch connect/joinRun) pass <=3 args, backward
compatible.
- readStreamLines / memoryStream.read: signatures unchanged; behavior-preserving
except the removed phantom insertion (observable throw/reject paths unchanged,
already covered by stream-durability.test.ts).
Round 2 confirmation-round fixes:
- resumableStream: a transport drop (StreamTruncatedError/StreamReadError) now
retries whenever an offset is held, even if THAT attempt made no new progress
(a caught-up run whose parked long-poll socket drops, or a proxy that drops
right after replaying the de-duped overlap). The total-attempts ceiling still
bounds a genuine flapper; the per-attempt progress requirement only converted
recoverable drops into hard failures on flaky networks. The clean-end path
stays strict and now documents the invariant it relies on (a durable transport
must not surface an empty long-poll window as a clean end; both shipped
backends honor it).
- xhrServerSentEvents.joinRun now threads { runId } into the [DONE] fallback,
matching fetchServerSentEvents.joinRun (correlation parity).
- e2e parseNdjson + toHttpResponse @param prose aligned (envelope guard;
batch is nested under durability, debug documented).
- docs: durable sources must emit their own terminal; memoryStream is for
replaying completed runs (live mid-stream resume needs a backend whose
producer outlives the delivery socket); qualified producer-death headline as
backend-driven.
Covering test: a reconnect that replays only the de-duped overlap then drops is
retried, not surfaced as an error (connection-adapters-resumable.test.ts).
Call sites cleared: resumableStream catch condition — only relaxed the retry
guard (dropped '&& progressed'), kept StreamReadError/StreamTruncatedError type
gate + lastEventId gate, so a first-attempt failure with no offset still
rethrows (asserted by existing 'does not retry HTTP setup failures' test).
…JSON headers, docs Round 3 confirmation-round fixes (scope widened per request to cover the pre-existing durability-producer bugs). Producer (durableStreamSource): - Flush buffered-but-unflushed chunks to the log before terminalizing on the abort/disconnect path (previously up to batchSize-1 already-produced chunks were dropped, so a joiner replayed a truncated prefix). Matches the error path. - Prefer the real provider error over a generic AbortError when a run both fails and is aborted, so a joiner sees the true cause. - Do not rethrow a post-terminal close()/append failure to the live consumer once a terminal was already forwarded — rethrowing appended a contradictory RUN_ERROR after RUN_FINISHED on the wire. Late cleanup failures are recorded server-side via logger.errors instead. - validateOffset now also rejects offsets with surrounding whitespace (the SSE client .trim()s the id, so such an offset would not round-trip on reconnect). Client: - normalizeConnectionAdapter.send: guard terminal synthesis in the catch so a missing-id throw can't mask the original error. - fetchEventSource: wrap a fetch() rejection (offline/DNS/refused) as StreamReadError so a reconnect retries from the offset, matching XHR; a first-attempt failure with no offset still surfaces. - readStreamLines: final decoder.decode() flush so a cut mid-multibyte-char is reported as truncation. Server transport: - toHttpResponse defaults Content-Type to application/x-ndjson + no-cache (overridable), matching the SSE helper, so intermediaries don't buffer it. Docs/skill/harness: - JSDoc @examples use openaiText('gpt-5.5') (chat has no model field) and wrap durability examples in a POST handler; model ids normalized to gpt-5.5 across connection-adapters.md + SKILL.md; SKILL sources += resumable-streams; doc reconnection wording scoped to the clean-end path; e2e X-Run-Id no longer advertised on reconnect; harness + seen-set comments. Tests: flush-on-abort, double-terminal-suppression, whitespace-offset rejection, NDJSON Content-Type, and fetch-rejection retry (+ first-attempt surfacing). Deferred (low, out of delta subject): fetch body not cancelled on early terminal return (reverted — the reader-cancel broke mock-reader teardown in ~26 pre-existing tests; XHR-parity nit, durable backends close on terminal anyway).
…overage Round 4 confirmation-round fixes (docs/comments/tests + one defensive guard; no new production logic bugs were found this round). Docs (correcting inaccuracies introduced in earlier CR rounds): - Reconnection-bounding section now states the durable-vs-non-durable distinction accurately: a transport error retries while an offset is held; a durable clean end with no progress fails with DurableStreamIncompleteError; only a non-durable clean end is a completed run. Documents why the asymmetry is deliberate. - connection-adapters.md no longer groups xhrServerSentEvents (SSE) under the NDJSON/toHttpResponse sentence. - Added a reconnect-safety warning: the client auto-reconnects by re-POSTing, so non-idempotent POST-handler work must be guarded behind a resume check. - config.json: dropped the redundant updatedAt on the newly-added overview page. - changeset: reconnect option applies to all four HTTP adapters, not just fetchServerSentEvents. Code: - readXhrLines.finish() now guards status===0 like enqueueDelta (avoids a bogus 'status: 0' error if loadend fires before load/error/abort). - Comments: linesToSSEEvents one-id-per-data-event assumption; clarified the fetch-rejection wrap note. Tests: - XHR onerror→reconnect (proves StreamReadError from onerror drives a retry with Last-Event-ID) and NDJSON provider-throw terminal persistence — closing the highest-value coverage gaps on the XHR/NDJSON surface. - delayMs:0 on the reconnecting fetch-SSE tests (speed/consistency). Deferred (pre-existing / by-design / out-of-delta-subject): reconnect clean-end asymmetry (correct + documented), abortableIterable listener cleanup, fetch body cancel on early exit (reverted — broke mock-reader teardown), pump finally-throw surfacing, SSE persistent-id interop.
…leness + one silent-swallow
Round 5 confirmation-round fixes. No genuine code-logic defects surfaced this
round; the items below are (a) one silent failure introduced by the R3
double-terminal guard and (b) doc/comment staleness introduced by earlier
rounds, plus a pre-existing doc-example hang bug.
Code:
- durableStreamSource: a producer error thrown AFTER a terminal was forwarded
was suppressed by the !terminalForwarded rethrow guard (correct — avoids a
contradictory second terminal) but never logged, so it vanished. Now logged
via logger.errors like the close/terminal-append failures. Covering test added.
Docs/comments (correcting staleness from earlier rounds):
- debug JSDoc (both response helpers) + overview.md prose no longer imply
server-side logging requires ; the errors category is on by default
(R1 change), and debug only routes/raises verbosity.
- toServerSentEventsStream JSDoc documents its getId param (parity w/ toHttpStream).
- overview.md GET join example guards a missing offset and its comment no longer
over-claims 'never iterates the provider' for a bodyless produce path.
- Removed review-artifact comments ('Finding 6', 'the R1 comment claims').
Docs (pre-existing example bug, flagged twice):
- WebSocket subscribe() example drains the queue before honoring (a
burst + close in one macrotask previously dropped queued chunks, incl. a
trailing RUN_FINISHED → client hang) and registers the abort listener once.
Deferred to a follow-up (pre-existing / off NDJSON-XHR subject / documented
design): SSE heartbeat/empty-data frame tolerance; abortableIterable orphan-
promise .catch; joinRun offset=-1 + Last-Event-ID precedence; reconnect
lifetime-ceiling on healthy socket-per-event runs; fetch body-cancel on early
terminal; assorted test-hygiene (shared FakeXhr, delayMs).
…efault 5); custom-adapter guide Addressing review feedback: - Reconnect ceiling: maxAttempts now bounds CONSECUTIVE reconnects that deliver no new events (default lowered 1000 -> 5); forward progress resets the counter. A healthy long run (even a socket-per-event proxy) never approaches it; it fires only when the run is genuinely stuck. This also resolves the CR finding that the old total-lifetime ceiling could fail a healthy progressing run. Ceiling test split into a no-progress-flapper (hits it) + a progress-resets test (does not). - stream-to-response: terminalForwarded lint fix (scoped no-unnecessary-condition disable; the flag is only assigned inside the flush() closure that TS CFA cannot observe). Docs: - New guide docs/resumable-streams/custom-adapter.md: implement the four-method StreamDurability contract over your own store, the offset/park/terminalize rules, wiring, and offset branding. Registered in config.json, cross-linked from the overview. - chat/connection-adapters: show the GET handler (joinRun) alongside POST. - overview reconnection-bounding section updated to the new semantics + default. NOTE: did NOT make memoryStream a silent default (explored per request, then reverted on review) - durability stays opt-in to avoid shipping an in-process, single-process-only, per-run-buffering backend to production by default. advertiseRunId is a local var in the e2e harness route, not public API.
# Conflicts: # examples/ts-react-chat/src/components/Header.tsx
- SSE id parsing: preserve the opaque offset verbatim (strip only a single leading space per the SSE spec, no trim, which would mangle a valid offset), and treat an empty id: as a resume-cursor reset (drop lastEventId + clear the de-dupe set) rather than a durable empty offset. - resolveReconnectOptions: reject non-finite / negative maxAttempts and delayMs up front so a NaN/Infinity ceiling cannot cause unbounded reconnects. - durable-stream read: throw on non-strictly-increasing record sequences within a response instead of silently dropping later records. - durable-stream: new operationTimeoutMs (default 30000) bounds create/append/ close via an AbortSignal so a stalled backend cannot hang delivery or terminalization; long-poll reads are intentionally excluded. - e2e delivery-durability spec: document the aimock-policy exemption. Tests added: empty-id reset + invalid-reconnect-bounds (ai-client), non-monotonic seq rejection + operation timeout (ai-durable-stream). Not applied (verified against the code): peer-dep workspace:^ is consistent with all sibling packages (changing to * would break sherif); memory retention is already bounded (sweepMemoryLogs + TTL + cap); the throw-in-finally is intentional aggregation and ESLint-suppressed (repo does not use Biome); batch is already documented as nested; tests already follow the package tests/ dir convention.
…dvanced out - overview.md: rewritten as the 3-step common case (pick an adapter, wrap the response with POST+GET, client is automatic). Removed em dashes. No longer makes it look harder than it is. - advanced.md (new): moved the deep material here — durableStream options, joinRun (attach-by-id), completion/stop/errors, memoryStream-in-production, reconnection bounding, offset ownership, Cloudflare, process death, and delivery-is-not-state. - joinRun is now documented under Advanced (it is a manual, opt-in API; the common reconnect-on-drop path needs no client code). - Scrubbed em dashes from custom-adapter.md and the resumable sections I added to connection-adapters.md; fixed the custom-adapter process-death link to point at the advanced page. - config.json: registered the Advanced page.
… handler The resume path serves entirely from the durability log and never iterates the source stream, so the chat() call and its replay: threadId were dead code. Replace with an empty stream and guard that offset is present so a bare GET does not fall through to the produce path.
…pers A resume GET is served entirely from the durability log and never iterates a producer stream, so the response helpers previously forced callers to fabricate an empty stream in every GET handler. These helpers take just the durability adapter, do the replay, and return a 400 when the request carries no resume offset. Dogfood them in the e2e harness and the example app, and simplify the docs GET handler to a one-liner.
…ters GET example The resumable-SSE server example still constructed a dead chat() with a replay: threadId in its GET handler. Replace it with the resume helper.
🎯 Changes
Adds resumable streams: a client can reconnect to an in-flight (or finished) SSE response — after a refresh, dropped connection, or from a second tab — without re-running the provider.
Split out of #785 so state persistence and delivery durability can land independently. The two features share no code: this is purely transport-layer.
Server (
@tanstack/ai)toServerSentEventsResponse(stream, { durability: { adapter, batch } })— chunks are appended to an ordered log before delivery and each SSE event is tagged with an opaque, adapter-ownedid:offset.StreamDurability<TOffset>contract:resumeFrom/append/read/close. The adapter owns the offset format; core never derives or stamps offsets.RUN_ERRORappend + awaitedclose()), so readers are never parked on a dead run.memoryStream(request): zero-infrastructure in-process adapter for dev/tests.New package:
@tanstack/ai-durable-streamA
StreamDurabilityadapter speaking the Durable Streams protocol for production backends: static or async-resolved auth headers, strictStream-Next-Offsetvalidation (never guesses an offset), abort-aware long-poll reads.Client (
@tanstack/ai-client)fetchServerSentEventsis now resumable: tracks SSEid:values, auto-reconnects withLast-Event-ID, de-duplicates the replayed prefix. Untagged (non-durable) streams behave exactly as before — single plain fetch.joinRun(runId): read-only GET withoffset=-1to attach to an in-flight or finished run from the start (second tab, reload).DurableStreamIncompleteErrorwhen a durable run ends with no terminal event and no forward progress, instead of hanging or silently stopping.Docs / tests
api.durable-deliveryharness route +delivery-durability.spec.ts(disconnect → reconnect exact-once resume; second-tab join).✅ Checklist
pnpm run test:pr.🚀 Release Impact
🤖 Generated with Claude Code
https://claude.ai/code/session_01A6Arc9bWdLq1aRRnDRCLMt
Summary by CodeRabbit
New Features
Last-Event-ID, server-issued opaque event offsets, replay, and de-duplication.joinRun(runId)plus a newdurabilityoption for durable SSE responses.Bug Fixes
Documentation
Tests