Skip to content

feat: resumable streams β€” reconnect to in-flight SSE responses via pluggable delivery durability - #955

Merged
AlemTuzlak merged 27 commits into
mainfrom
feat/resumable-streams
Jul 20, 2026
Merged

feat: resumable streams β€” reconnect to in-flight SSE responses via pluggable delivery durability#955
AlemTuzlak merged 27 commits into
mainfrom
feat/resumable-streams

Conversation

@tombeckenham

@tombeckenham tombeckenham commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🎯 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-owned id: offset.
  • New StreamDurability<TOffset> contract: resumeFrom / append / read / close. The adapter owns the offset format; core never derives or stamps offsets.
  • Resume requests replay strictly after the supplied offset from the log; the lazy provider stream is never iterated.
  • Producers terminalize the log on completion, cancellation, and provider failure (terminal RUN_ERROR append + awaited close()), so readers are never parked on a dead run.
  • memoryStream(request): zero-infrastructure in-process adapter for dev/tests.

New package: @tanstack/ai-durable-stream

A StreamDurability adapter speaking the Durable Streams protocol for production backends: static or async-resolved auth headers, strict Stream-Next-Offset validation (never guesses an offset), abort-aware long-poll reads.

Client (@tanstack/ai-client)

  • fetchServerSentEvents is now resumable: tracks SSE id: values, auto-reconnects with Last-Event-ID, de-duplicates the replayed prefix. Untagged (non-durable) streams behave exactly as before β€” single plain fetch.
  • joinRun(runId): read-only GET with offset=-1 to attach to an in-flight or finished run from the start (second tab, reload).
  • New DurableStreamIncompleteError when a durable run ends with no terminal event and no forward progress, instead of hanging or silently stopping.

Docs / tests

  • New Resumable Streams docs section + Resumable SSE subsection in Connection Adapters; chat-experience skill updated.
  • Unit coverage: durability contract, batching/flush boundaries, terminalization, memory adapter, durable-stream adapter (offset validation, auth, failure modes), client reconnect/de-dupe/joinRun.
  • E2E: provider-free api.durable-delivery harness route + delivery-durability.spec.ts (disconnect β†’ reconnect exact-once resume; second-tab join).

βœ… Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

πŸš€ Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01A6Arc9bWdLq1aRRnDRCLMt

Summary by CodeRabbit

  • New Features

    • Added resumable SSE delivery with automatic reconnect using Last-Event-ID, server-issued opaque event offsets, replay, and de-duplication.
    • Added joinRun(runId) plus a new durability option for durable SSE responses.
    • Introduced durable adapters: in-memory for dev/testing and an external durable-stream adapter for production.
  • Bug Fixes

    • Prevented hangs by surfacing explicit errors for incomplete runs and reconnect/read limits.
  • Documentation

    • Added β€œResumable Streams” documentation and expanded SSE connection adapter guidance.
  • Tests

    • Added unit, contract, type, and end-to-end coverage for reconnect/replay correctness and terminal/error handling.

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
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

πŸš€ Changeset Version Preview

3 package(s) bumped directly, 44 bumped as dependents.

πŸŸ₯ Major bumps

Package Version Reason
@tanstack/ai-durable-stream 0.0.0 β†’ 1.0.0 Changeset
@tanstack/ai-acp 0.2.3 β†’ 1.0.0 Dependent
@tanstack/ai-angular 0.3.1 β†’ 1.0.0 Dependent
@tanstack/ai-anthropic 0.16.3 β†’ 1.0.0 Dependent
@tanstack/ai-bedrock 0.1.4 β†’ 1.0.0 Dependent
@tanstack/ai-claude-code 0.2.3 β†’ 1.0.0 Dependent
@tanstack/ai-code-mode 0.3.8 β†’ 1.0.0 Dependent
@tanstack/ai-code-mode-skills 0.3.11 β†’ 1.0.0 Dependent
@tanstack/ai-codex 0.2.3 β†’ 1.0.0 Dependent
@tanstack/ai-elevenlabs 0.2.34 β†’ 1.0.0 Dependent
@tanstack/ai-fal 0.9.12 β†’ 1.0.0 Dependent
@tanstack/ai-gemini 0.20.1 β†’ 1.0.0 Dependent
@tanstack/ai-grok 0.14.9 β†’ 1.0.0 Dependent
@tanstack/ai-grok-build 0.2.3 β†’ 1.0.0 Dependent
@tanstack/ai-groq 0.5.3 β†’ 1.0.0 Dependent
@tanstack/ai-isolate-node 0.1.47 β†’ 1.0.0 Dependent
@tanstack/ai-isolate-quickjs 0.1.47 β†’ 1.0.0 Dependent
@tanstack/ai-mistral 0.2.3 β†’ 1.0.0 Dependent
@tanstack/ai-ollama 0.8.16 β†’ 1.0.0 Dependent
@tanstack/ai-openai 0.17.1 β†’ 1.0.0 Dependent
@tanstack/ai-opencode 0.2.3 β†’ 1.0.0 Dependent
@tanstack/ai-openrouter 0.15.10 β†’ 1.0.0 Dependent
@tanstack/ai-preact 0.11.1 β†’ 1.0.0 Dependent
@tanstack/ai-react 0.18.1 β†’ 1.0.0 Dependent
@tanstack/ai-react-ui 0.8.15 β†’ 1.0.0 Dependent
@tanstack/ai-sandbox 0.2.4 β†’ 1.0.0 Dependent
@tanstack/ai-sandbox-cloudflare 0.2.4 β†’ 1.0.0 Dependent
@tanstack/ai-sandbox-daytona 0.2.0 β†’ 1.0.0 Dependent
@tanstack/ai-sandbox-docker 0.2.0 β†’ 1.0.0 Dependent
@tanstack/ai-sandbox-local-process 0.2.0 β†’ 1.0.0 Dependent
@tanstack/ai-sandbox-sprites 0.2.1 β†’ 1.0.0 Dependent
@tanstack/ai-sandbox-vercel 0.2.0 β†’ 1.0.0 Dependent
@tanstack/ai-solid 0.15.1 β†’ 1.0.0 Dependent
@tanstack/ai-solid-ui 0.7.14 β†’ 1.0.0 Dependent
@tanstack/ai-svelte 0.15.1 β†’ 1.0.0 Dependent
@tanstack/ai-vue 0.15.1 β†’ 1.0.0 Dependent
@tanstack/openai-base 0.9.9 β†’ 1.0.0 Dependent

🟨 Minor bumps

Package Version Reason
@tanstack/ai 0.42.0 β†’ 0.43.0 Changeset
@tanstack/ai-client 0.22.1 β†’ 0.23.0 Changeset

🟩 Patch bumps

Package Version Reason
@tanstack/ai-devtools-core 0.4.24 β†’ 0.4.25 Dependent
@tanstack/ai-isolate-cloudflare 0.2.38 β†’ 0.2.39 Dependent
@tanstack/ai-mcp 0.2.5 β†’ 0.2.6 Dependent
@tanstack/ai-vue-ui 0.2.34 β†’ 0.2.35 Dependent
@tanstack/preact-ai-devtools 0.1.67 β†’ 0.1.68 Dependent
@tanstack/react-ai-devtools 0.2.67 β†’ 0.2.68 Dependent
@tanstack/solid-ai-devtools 0.2.67 β†’ 0.2.68 Dependent
ag-ui 0.0.2 β†’ 0.0.3 Dependent

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▢️ Resume reviews
  • πŸ” Trigger review
πŸ“ Walkthrough

Walkthrough

This 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.

Changes

Resumable SSE delivery

Layer / File(s) Summary
Durable server-side SSE delivery
packages/ai/src/stream-to-response.ts, packages/ai/tests/*durability*
Server responses persist chunks, emit opaque SSE offsets, replay durable logs without re-iterating providers, and terminalize streams on completion, cancellation, or failure.
Process-local memory durability
packages/ai/src/stream-durability.ts, packages/ai/tests/stream-durability.test.ts
memoryStream stores per-run chunks, supports replay and live tailing, bounds first-chunk waits, and evicts completed logs.
Resumable client connection
packages/ai-client/src/connection-adapters.ts, packages/ai-client/tests/connection-adapters-resumable.test.ts
fetchServerSentEvents reconnects with Last-Event-ID, de-duplicates replayed events, supports joinRun, and bounds reconnect failures.
HTTP durable-stream adapter
packages/ai-durable-stream/*
The new package implements validated cursors, HTTP create/append/read/close operations, SSE control handling, retries, and exact-once resume behavior.
Documentation and delivery integration
docs/*, examples/ts-react-chat/*, testing/e2e/*, .changeset/*
Documentation, a resumable chat example, deterministic routes, and Playwright tests cover setup, reconnect, replay, and run joining.

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
Loading

Suggested reviewers: alemtuzlak

πŸš₯ Pre-merge checks | βœ… 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
βœ… Passed checks (4 passed)
Check name Status Explanation
Title check βœ… Passed The title clearly summarizes the main change: resumable SSE streams with pluggable delivery durability.
Description check βœ… Passed The description follows the template and includes the required changes, checklist, and release impact sections.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/resumable-streams

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.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jul 17, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution β†— for commit 55087a4

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... βœ… Succeeded 2m 3s View β†—

☁️ Nx Cloud last updated this comment at 2026-07-20 11:16:20 UTC

@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@tanstack/ai

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai@955

@tanstack/ai-acp

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-acp@955

@tanstack/ai-angular

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-angular@955

@tanstack/ai-anthropic

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-anthropic@955

@tanstack/ai-bedrock

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-bedrock@955

@tanstack/ai-claude-code

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-claude-code@955

@tanstack/ai-client

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-client@955

@tanstack/ai-code-mode

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-code-mode@955

@tanstack/ai-code-mode-skills

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-code-mode-skills@955

@tanstack/ai-codex

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-codex@955

@tanstack/ai-devtools-core

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-devtools-core@955

@tanstack/ai-durable-stream

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-durable-stream@955

@tanstack/ai-elevenlabs

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-elevenlabs@955

@tanstack/ai-event-client

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-event-client@955

@tanstack/ai-fal

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-fal@955

@tanstack/ai-gemini

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-gemini@955

@tanstack/ai-grok

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-grok@955

@tanstack/ai-grok-build

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-grok-build@955

@tanstack/ai-groq

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-groq@955

@tanstack/ai-isolate-cloudflare

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-isolate-cloudflare@955

@tanstack/ai-isolate-node

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-isolate-node@955

@tanstack/ai-isolate-quickjs

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-isolate-quickjs@955

@tanstack/ai-mcp

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-mcp@955

@tanstack/ai-mistral

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-mistral@955

@tanstack/ai-ollama

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-ollama@955

@tanstack/ai-openai

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-openai@955

@tanstack/ai-opencode

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-opencode@955

@tanstack/ai-openrouter

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-openrouter@955

@tanstack/ai-preact

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-preact@955

@tanstack/ai-react

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-react@955

@tanstack/ai-react-ui

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-react-ui@955

@tanstack/ai-sandbox

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox@955

@tanstack/ai-sandbox-cloudflare

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-cloudflare@955

@tanstack/ai-sandbox-daytona

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-daytona@955

@tanstack/ai-sandbox-docker

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-docker@955

@tanstack/ai-sandbox-local-process

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-local-process@955

@tanstack/ai-sandbox-sprites

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-sprites@955

@tanstack/ai-sandbox-vercel

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-sandbox-vercel@955

@tanstack/ai-solid

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-solid@955

@tanstack/ai-solid-ui

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-solid-ui@955

@tanstack/ai-svelte

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-svelte@955

@tanstack/ai-utils

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-utils@955

@tanstack/ai-vue

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-vue@955

@tanstack/ai-vue-ui

npm i https://pkg.pr.new/TanStack/ai/@tanstack/ai-vue-ui@955

@tanstack/openai-base

npm i https://pkg.pr.new/TanStack/ai/@tanstack/openai-base@955

@tanstack/preact-ai-devtools

npm i https://pkg.pr.new/TanStack/ai/@tanstack/preact-ai-devtools@955

@tanstack/react-ai-devtools

npm i https://pkg.pr.new/TanStack/ai/@tanstack/react-ai-devtools@955

@tanstack/solid-ai-devtools

npm i https://pkg.pr.new/TanStack/ai/@tanstack/solid-ai-devtools@955

commit: 55087a4

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (1)
testing/e2e/tests/delivery-durability.spec.ts (1)

3-19: πŸ“ Maintainability & Code Quality | πŸ”΅ Trivial | πŸ’€ Low value

Document the aimock policy exception.

Because this spec is exempt from using aimock (since the api.durable-delivery harness 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

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between f6ed5fe and 3a55e29.

β›” Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
πŸ“’ Files selected for processing (25)
  • .changeset/resumable-streams.md
  • docs/chat/connection-adapters.md
  • docs/config.json
  • docs/resumable-streams/overview.md
  • packages/ai-client/src/connection-adapters.ts
  • packages/ai-client/src/index.ts
  • packages/ai-client/tests/connection-adapters-resumable.test.ts
  • packages/ai-durable-stream/package.json
  • packages/ai-durable-stream/src/durable-stream.ts
  • packages/ai-durable-stream/src/index.ts
  • packages/ai-durable-stream/tests/durable-stream-types.test-d.ts
  • packages/ai-durable-stream/tests/durable-stream.test.ts
  • packages/ai-durable-stream/tsconfig.json
  • packages/ai-durable-stream/vite.config.ts
  • packages/ai/skills/ai-core/chat-experience/SKILL.md
  • packages/ai/src/index.ts
  • packages/ai/src/stream-durability.ts
  • packages/ai/src/stream-to-response.ts
  • packages/ai/tests/stream-delivery-contract.test.ts
  • packages/ai/tests/stream-durability-types.test-d.ts
  • packages/ai/tests/stream-durability.test.ts
  • packages/ai/tests/stream-to-response-durability.test.ts
  • testing/e2e/src/routeTree.gen.ts
  • testing/e2e/src/routes/api.durable-delivery.ts
  • testing/e2e/tests/delivery-durability.spec.ts

Comment thread packages/ai-client/src/connection-adapters.ts Outdated
"test:types": "tsc"
},
"peerDependencies": {
"@tanstack/ai": "workspace:^"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ 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.

Suggested change
"@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

Comment thread packages/ai-durable-stream/src/durable-stream.ts Outdated
Comment thread packages/ai-durable-stream/src/durable-stream.ts
Comment thread packages/ai/src/stream-durability.ts
Comment thread packages/ai/src/stream-to-response.ts Outdated
Comment thread packages/ai/src/stream-to-response.ts Outdated
Comment thread packages/ai/tests/stream-delivery-contract.test.ts
tombeckenham added a commit that referenced this pull request Jul 17, 2026
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
tombeckenham and others added 2 commits July 17, 2026 17:06
…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>
@tombeckenham
tombeckenham requested a review from AlemTuzlak July 17, 2026 07:17
tombeckenham and others added 2 commits July 17, 2026 17:43
…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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Evict 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 grow memoryLogs.

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 win

Colocate the new unit tests with their covered source modules.

  • packages/ai/tests/stream-durability.test.ts#L1-L1: move to packages/ai/src/stream-durability.test.ts.
  • packages/ai-client/tests/connection-adapters-resumable.test.ts#L5-L5: move to packages/ai-client/src/connection-adapters-resumable.test.ts.

As per coding guidelines, β€œPlace unit tests in *.test.ts files 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

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 7031541 and cc295c0.

πŸ“’ Files selected for processing (12)
  • .changeset/resumable-streams.md
  • docs/resumable-streams/overview.md
  • packages/ai-client/src/connection-adapters.ts
  • packages/ai-client/src/index.ts
  • packages/ai-client/tests/connection-adapters-resumable.test.ts
  • packages/ai-durable-stream/src/durable-stream.ts
  • packages/ai-durable-stream/tests/durable-stream.test.ts
  • packages/ai/src/index.ts
  • packages/ai/src/stream-durability.ts
  • packages/ai/src/stream-to-response.ts
  • packages/ai/tests/stream-durability.test.ts
  • packages/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

Comment thread packages/ai-client/src/connection-adapters.ts
Comment thread packages/ai-client/tests/connection-adapters-resumable.test.ts
tombeckenham and others added 2 commits July 17, 2026 18:15
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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
examples/ts-react-chat/src/routes/resumable.tsx (1)

143-159: πŸ“ Maintainability & Code Quality | πŸ”΅ Trivial | πŸ’€ Low value

Consider 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

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between cc295c0 and 8f60efe.

πŸ“’ Files selected for processing (5)
  • docs/resumable-streams/overview.md
  • examples/ts-react-chat/src/components/Header.tsx
  • examples/ts-react-chat/src/routeTree.gen.ts
  • examples/ts-react-chat/src/routes/api.resumable.ts
  • examples/ts-react-chat/src/routes/resumable.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/resumable-streams/overview.md

AlemTuzlak and others added 13 commits July 17, 2026 12:14
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.
autofix-ci Bot and others added 2 commits July 17, 2026 16:47
…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.
tombeckenham added a commit that referenced this pull request Jul 18, 2026
- Remove leftover present-tense references to resumable streams (split to
  #955), including the phantom StreamDurability guide pointers
- Fix phantom autoResume/resume() options and add the missing
  pendingInterrupts/resumeInterrupts/resumeState returns in the five
  framework API reference pages
- Correct the persistence SKILL.md capability table (artifacts + blobs are
  required together) and align library_version with sibling skills
- Bump updatedAt for content-changed pages in docs/config.json
- Make test:kiira pass (was 59 errors, red in CI): hoist
  @cloudflare/workers-types to root devDependencies so kiira can resolve
  the CF ambient types the persistence-cloudflare source uses, add scoped
  triple-slash refs to Env-declaring snippets, group continuation
  snippets, and fix two genuine snippet type bugs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A6Arc9bWdLq1aRRnDRCLMt
AlemTuzlak and others added 2 commits July 20, 2026 11:24
…ct side-effect guard

Rewrite the ts-react-chat resumable route from hand-rolled connection driving
(useState/useRef/drainInto + manual connect/joinRun) to a plain useChat page,
matching the overview doc: the durable route makes reconnect automatic with no
client code. Expand the overview reconnect gotcha into a runnable example that
guards one-time side effects behind durability.resumeFrom().
AlemTuzlak and others added 3 commits July 20, 2026 12:26
The resumable stream engine returned on the first RUN_FINISHED/RUN_ERROR.
An agent loop emits one RUN_STARTED/RUN_FINISHED pair per turn, so a
tool-calling run carries several terminals in a single response β€” returning
on the first dropped the tool result and the final answer. This engine drives
every stream (durable and non-durable alike), so it regressed existing
non-durable clients: all tool/agentic/custom-event/structured E2E tests hung
after the first turn while plain single-turn streams passed.

Drain the event source to its natural end (the server closes the response only
when the run is truly complete) and use the terminal flag post-loop to decide
done-vs-reconnect. Restores the pre-durability read-to-close behavior for
non-durable streams; durable behavior is unchanged (single-terminal responses
end right after the terminal, so every resumable unit test still holds).
…y param

The resumable adapters appended `?runId=<id>` to every POST (useChat always
supplies a runId), rewriting the request URL for all existing clients β€” not
just durable ones. That broke callers/tests that match the bare endpoint URL
and violated the invariant that a non-durable request is byte-identical to a
plain fetch.

Send the client-chosen run id in an `X-Run-Id` request header instead. The
POST URL is now untouched, so existing clients are unaffected, while a
durability sink still keys its log by the client's run id (memoryStream's
readRunId reads the header first, then falls back to the `?runId` query the
GET join path still uses). Durability remains a transparent add-on.
@AlemTuzlak
AlemTuzlak merged commit 7c7aa09 into main Jul 20, 2026
10 checks passed
@AlemTuzlak
AlemTuzlak deleted the feat/resumable-streams branch July 20, 2026 12:06
@github-actions github-actions Bot mentioned this pull request Jul 20, 2026
AlemTuzlak added a commit that referenced this pull request Jul 23, 2026
PR #955 (resumable streams) is merged, so delivery durability is available
today β€” it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.
AlemTuzlak added a commit that referenced this pull request Jul 23, 2026
Layer a lightweight, read-only resume snapshot onto media generation.
As a run streams, the client builds a GenerationResumeSnapshot (run
identity, status, errors, result metadata + artifact refs β€” never media
bytes) and writes it to an optional GenerationServerPersistence store.

- ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot
  reducer; GenerationClient/VideoGenerationClient observe chunks, persist
  snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot();
  disposed guard. No resume() action (stream re-attach is PR #955).
- ai-event-client: optional threadId/runId on generation events.
- react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot
  options; expose resumeSnapshot/resumeState (+ pending/result artifacts).
- example: Persisted mode on the image generation route.
- docs: persistence/generation-persistence.md + nav entry.

Pairs with the existing withGenerationPersistence server middleware.
AlemTuzlak added a commit that referenced this pull request Jul 23, 2026
PR #955 (resumable streams) is merged, so delivery durability is available
today β€” it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.
AlemTuzlak added a commit that referenced this pull request Jul 27, 2026
Layer a lightweight, read-only resume snapshot onto media generation.
As a run streams, the client builds a GenerationResumeSnapshot (run
identity, status, errors, result metadata + artifact refs β€” never media
bytes) and writes it to an optional GenerationServerPersistence store.

- ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot
  reducer; GenerationClient/VideoGenerationClient observe chunks, persist
  snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot();
  disposed guard. No resume() action (stream re-attach is PR #955).
- ai-event-client: optional threadId/runId on generation events.
- react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot
  options; expose resumeSnapshot/resumeState (+ pending/result artifacts).
- example: Persisted mode on the image generation route.
- docs: persistence/generation-persistence.md + nav entry.

Pairs with the existing withGenerationPersistence server middleware.
AlemTuzlak added a commit that referenced this pull request Jul 27, 2026
PR #955 (resumable streams) is merged, so delivery durability is available
today β€” it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.
AlemTuzlak added a commit that referenced this pull request Jul 27, 2026
Layer a lightweight, read-only resume snapshot onto media generation.
As a run streams, the client builds a GenerationResumeSnapshot (run
identity, status, errors, result metadata + artifact refs β€” never media
bytes) and writes it to an optional GenerationServerPersistence store.

- ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot
  reducer; GenerationClient/VideoGenerationClient observe chunks, persist
  snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot();
  disposed guard. No resume() action (stream re-attach is PR #955).
- ai-event-client: optional threadId/runId on generation events.
- react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot
  options; expose resumeSnapshot/resumeState (+ pending/result artifacts).
- example: Persisted mode on the image generation route.
- docs: persistence/generation-persistence.md + nav entry.

Pairs with the existing withGenerationPersistence server middleware.
AlemTuzlak added a commit that referenced this pull request Jul 27, 2026
PR #955 (resumable streams) is merged, so delivery durability is available
today β€” it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.
tombeckenham pushed a commit that referenced this pull request Jul 28, 2026
Layer a lightweight, read-only resume snapshot onto media generation.
As a run streams, the client builds a GenerationResumeSnapshot (run
identity, status, errors, result metadata + artifact refs β€” never media
bytes) and writes it to an optional GenerationServerPersistence store.

- ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot
  reducer; GenerationClient/VideoGenerationClient observe chunks, persist
  snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot();
  disposed guard. No resume() action (stream re-attach is PR #955).
- ai-event-client: optional threadId/runId on generation events.
- react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot
  options; expose resumeSnapshot/resumeState (+ pending/result artifacts).
- example: Persisted mode on the image generation route.
- docs: persistence/generation-persistence.md + nav entry.

Pairs with the existing withGenerationPersistence server middleware.
tombeckenham pushed a commit that referenced this pull request Jul 28, 2026
PR #955 (resumable streams) is merged, so delivery durability is available
today β€” it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.
tombeckenham pushed a commit that referenced this pull request Jul 28, 2026
Layer a lightweight, read-only resume snapshot onto media generation.
As a run streams, the client builds a GenerationResumeSnapshot (run
identity, status, errors, result metadata + artifact refs β€” never media
bytes) and writes it to an optional GenerationServerPersistence store.

- ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot
  reducer; GenerationClient/VideoGenerationClient observe chunks, persist
  snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot();
  disposed guard. No resume() action (stream re-attach is PR #955).
- ai-event-client: optional threadId/runId on generation events.
- react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot
  options; expose resumeSnapshot/resumeState (+ pending/result artifacts).
- example: Persisted mode on the image generation route.
- docs: persistence/generation-persistence.md + nav entry.

Pairs with the existing withGenerationPersistence server middleware.
tombeckenham pushed a commit that referenced this pull request Jul 28, 2026
PR #955 (resumable streams) is merged, so delivery durability is available
today β€” it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.
tombeckenham added a commit that referenced this pull request Jul 31, 2026
* feat(persistence): client-side generation persistence

Layer a lightweight, read-only resume snapshot onto media generation.
As a run streams, the client builds a GenerationResumeSnapshot (run
identity, status, errors, result metadata + artifact refs β€” never media
bytes) and writes it to an optional GenerationServerPersistence store.

- ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot
  reducer; GenerationClient/VideoGenerationClient observe chunks, persist
  snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot();
  disposed guard. No resume() action (stream re-attach is PR #955).
- ai-event-client: optional threadId/runId on generation events.
- react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot
  options; expose resumeSnapshot/resumeState (+ pending/result artifacts).
- example: Persisted mode on the image generation route.
- docs: persistence/generation-persistence.md + nav entry.

Pairs with the existing withGenerationPersistence server middleware.

* docs(persistence): drop generic from generation snapshot store example

* refactor(persistence): align generation persistence API with chat

Drop the bespoke `GenerationServerPersistence` type and the `{ server }`
option wrapper. The `persistence` option is now a bare storage adapter
reusing the shared `ChatStorageAdapter` contract (aliased as
`GenerationPersistence`), so `localStoragePersistence` /
`sessionStoragePersistence` / `indexedDBPersistence` work for generations
exactly as they do for chat β€” matching main's ergonomics.

* refactor(persistence): infer generation store type via GenerationPersistence (no call-site generic)

* refactor(persistence): value-agnostic web-storage adapter defaults

Default `localStoragePersistence` / `sessionStoragePersistence` /
`indexedDBPersistence` to a value-agnostic `TValue` so a bare, unannotated
call works for BOTH chat and generation persistence β€” the consuming
`persistence` option constrains the stored value. Generation docs/example now
use `localStoragePersistence({ keyPrefix })` with no type declaration.

* docs(persistence): fix stale generation-persistence delivery guidance

PR #955 (resumable streams) is merged, so delivery durability is available
today β€” it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.

* docs(persistence): rewrite generation-persistence for clarity + when-to-use

* docs(persistence): drop redundant storage-adapter comment

* fix(persistence): generation snapshot lifecycle, hydration, StrictMode revival

- kiira: replace phantom @tanstack/ai-persistence-drizzle import with the
  hand-rolled adapter from build-your-own-adapter (CI was red on this)
- hydrate the resume snapshot from persistence.getItem on construction,
  validated via new parseGenerationResumeSnapshot(unknown) export;
  initialResumeSnapshot seed takes precedence
- namespace storage keys as generation:<id> so chat and generation clients
  sharing an id and adapter no longer collide
- write terminal snapshots on stop() (idle) and transport-level errors
  (error); reset() clears memory + removeItem; RUN_STARTED drops stale
  result/error/pendingArtifacts from the previous run; plain-fetcher runs
  now record a complete snapshot built from the fetcher result
- capture video jobId into the snapshot from video:job:created
- add schemaVersion: 1 to persisted snapshots
- gate persistence writes on material change (ignore lastEvent-only churn),
  warn once per failure transition, clear resumePersistenceError on success
- mountDevtools() revives a disposed client (React StrictMode replay);
  generate() checks disposed before mounting devtools
- onResumeSnapshotChange now receives undefined when reset() clears
- fix mojibake em dashes in 12 hook files

* fix(persistence): docs, example, changeset, React hooks, and real test coverage

- rewrite docs/persistence/generation-persistence.md around the implemented
  behavior: hydration on mount, generation:<id> keys, resumeState vs
  resumeSnapshot semantics, honest reconnect story, no media-URL claim;
  drop the inert threadId/runId spreads from the server sample
- fix the example's Persisted panel: distinguish in-flight run from last-run
  outcome; reload now actually shows the persisted record
- revert ai-event-client: BaseEventContext already carries threadId/runId,
  the 36 added lines were redundant redeclarations; changeset no longer
  bumps that package and now describes hydration + lifecycle accurately
- normalize wrong hook JSDoc (Server-side β†’ client-side storage; read-only
  seed claims; run/cursor wording) and mark artifact fields dormant
- React hooks: post-dispose guards on callbacks/setters, StrictMode revive
  via mount effect, stable empty artifact arrays, re-export persistence
  types (+ PersistedArtifactRef)
- tests: replace the two vacuous reducer tests with real externalUrl
  positive/negative and stop coverage; add reducer seed-merge, RUN_STARTED
  stale-field-drop, video jobId capture, parseGenerationResumeSnapshot
  suite; add client lifecycle suite (hydration, seed precedence, corrupt
  storage, stop/reset/transport-error, write gating, StrictMode revive);
  add React hydration/StrictMode/artifact-exposure hook tests

* test(persistence): E2E reload-and-rehydrate spec for generation snapshots

Provider-free harness (api.generation-persistence streams a fixed AG-UI
sequence; aimock-exempt) + page using useGenerateImage with
localStoragePersistence. Proves: snapshot written under
tanstack-ai:generation:<id> with no media bytes, hydrated after reload with
no auto-run, and removed by reset().

* docs(skills): cover generation resume snapshots in client-persistence + media-generation skills

* fix(persistence): framework sweeps for Solid/Vue/Svelte/Angular + hydration ordering

- solid: build the client outside reactive tracking (untrack) β€” the old
  createMemo second-arg was a seed, not deps, so option reads were tracked
  and a change orphaned an undisposed client; stable empty artifact arrays
- svelte: explicit generate() now revives a disposed client (mountDevtools)
  since Svelte has no remount effect; reactive bindings revive with it
- vue: stable empty artifact array constants (shallowRef identity)
- angular: JSDoc for persistence/initialResumeSnapshot on inject-generate-video
- all four: re-export GenerationPersistence/GenerationResumeSnapshot/
  GenerationResumeState/GenerationResumeStatus/GenerationPendingArtifact +
  PersistedArtifactRef from package index; hydration + reset()/removeItem
  tests against Map-backed adapters
- ai-client: kick off snapshot hydration only after callbacksRef is
  assigned (removes a sync-adapter ordering hazard)

* ci: apply automated fixes

* refactor(ai-react): thread TInput through UseGenerationReturn, drop generate casts

UseGenerationReturn gains a defaulted second generic
(TInput extends Record<string, any> = Record<string, any>) so generate is
typed (input: TInput) => Promise<void>. useGeneration returns the type it
actually builds β€” the unsound internal narrow-to-wide cast and the five
wrapper-level casts back down to the concrete input type all disappear,
and direct useGeneration consumers get a precisely typed generate.
Existing UseGenerationReturn<MyOutput> references keep compiling via the
default.

* refactor: thread TInput through generation return types in solid/vue/svelte/angular

Same fix as fbc3dc3 for the remaining four frameworks: the base return
interface (UseGenerationReturn / CreateGenerationReturn /
InjectGenerationResult) gains a defaulted second generic
(TInput extends Record<string, any> = Record<string, any>) so generate is
typed (input: TInput) => Promise<void>. The base hooks return the type
they actually build and the internal narrow-to-wide casts plus every
wrapper-level 'generate as' cast are deleted. Video hooks were already
cast-free (they build their own client). Defaults keep existing
single-generic references compiling.

* refactor(persistence): restore typed storage-adapter defaults (drop TValue = any)

Revert the web-storage factory defaults to TValue = ChatPersistedState, as
shipped in #984. The any default erased type safety on every direct
adapter use (getItem returned any; a store built for one domain assigned
silently to the other's hook) and carried three oxlint suppressions β€”
while buying nothing for inline usage, where contextual typing infers the
value type from the persistence option regardless of the default. The one
affected pattern, a standalone store for generations, now states its type:
localStoragePersistence<GenerationResumeSnapshot>(). Doc, example, and e2e
call sites updated; runtime behavior unchanged.

* feat(persistence): durable generation media-byte storage

Layer server-side artifact + blob storage onto the client generation snapshot.
When the persistence backend provides both an artifacts (ArtifactStore) and a
blobs (BlobStore) store, withGenerationPersistence writes each generated file's
bytes to the blob store (key artifacts/<runId>/<artifactId>), records an
ArtifactRecord, attaches PersistedArtifactRefs to the result, and emits
generation:artifacts (which the client reducer already consumes).

- @tanstack/ai: result-transform machinery (resultTransforms/artifactInputs on
  GenerationMiddlewareContext, applyGenerationResultTransforms), threadId/runId
  on the image/audio/speech/transcription activities, generation:artifacts
  emission from streamGenerationResult.
- @tanstack/ai-utils: base64ToUint8Array.
- @tanstack/ai-persistence: ArtifactStore + BlobStore contracts + in-memory
  impls in memoryPersistence(); byte persistence in withGenerationPersistence
  (extractArtifacts/nameArtifact); retrieveArtifact/retrieveBlob/artifactBlobKey
  serve helpers.
- @tanstack/ai-event-client: optional threadId/runId on generation events.
- docs + changeset updated for byte storage.

* feat(persistence): two-mode generation persistence + GenerationJobStore

Give media generation the same two persistence modes useChat has, driven
by the `persistence` option:

- server-driven (`persistence: true` + a stable `threadId`): the client
  keeps no local store and hydrates the last generation job from the
  server on mount via a read-only `hydrateGeneration` GET, answered by the
  new `reconstructGeneration` helper.
- client-driven (a storage adapter): unchanged.

Server: reshape `withGenerationPersistence` off the flagged stopgap that
faked `threadId = requestId` on the chat RunStore onto a dedicated
`GenerationJobStore` keyed by `jobId` (threadId only an optional link).
Add `defineGenerationJobStore` / `defineArtifactStore` / `defineBlobStore`
and `reconstructGeneration`; durable byte storage (artifacts + blobs)
stays an optional layer on top.

Client: widen `persistence` to `boolean | adapter`, add `threadId`, and
thread both through every generation hook across react/solid/vue/svelte/
angular. `hydrateFromServer` validates the untrusted server snapshot and
only adopts it when nothing was observed locally first; a live generate()
always wins and no run is ever auto-started.

Docs (two modes + BYO job/artifact/blob stores), a Cloudflare R2
artifact/blob skill, unit tests, and a server-driven e2e spec included.

* ci: apply automated fixes

* docs(persistence): route readers to generation persistence + split byte storage

Generation persistence shipped, but nothing pointed readers to it. Fix the
discovery paths:

- Split "keep the generated files" out of generation-persistence into its own
  Keep Generated Files page (server-only byte storage is a distinct journey).
- Point the media docs at it: a callout on the generation-hooks hub and
  video-generation (minutes-long runs), lighter pointers on image/audio/
  transcription.
- Give the persistence overview a Generation persistence sibling section, add
  the jobs/artifacts/blobs stores to the store-contract table, and link the
  generation pages from "Where to go next".
- Note in client-persistence that generation hooks share the same
  true/adapter modes.

* refactor(generation-persistence): restore transparently into the normal hook fields

Generation persistence exposed a bolt-on client surface: `resumeSnapshot`,
`resumeState`, `pendingArtifacts`, `resultArtifacts`, and on restore it
repainted only `resumeSnapshot`, leaving `result`/`status`/`error` idle. Make
it invisible like chat, which restores straight into `messages`.

Client (@tanstack/ai-client + 5 frameworks):
- Hooks now return only `generate`, `result`, `isLoading`, `error`, `status`,
  `stop`, `reset`, `resumeState`. `resumeSnapshot` / `pendingArtifacts` /
  `resultArtifacts` are gone; final artifact refs live on `result.artifacts`,
  in-flight ones on `resumeState.pendingArtifacts`.
- On restore (client store or server hydrate) the client repaints
  `result` / `status` / `error` and emits `resumeState`, so a reload looks like
  a just-finished run. A per-activity `reconstructResult` mapper (image / audio
  / transcription / summarize; video built into the video client) rebuilds a
  typed result, with media resolved to the durable serve URL. Live `generate()`
  still wins over a slow restore; no run is auto-started.
- `localStoragePersistence()` / `sessionStoragePersistence()` /
  `indexedDBPersistence()` now work on a generation hook with no type argument.

Server (@tanstack/ai + @tanstack/ai-persistence):
- `PersistedArtifactRef.url` (durable app-origin serve URL). New
  `withGenerationPersistence({ artifactUrl })` stamps it onto each ref and
  rewrites the live result's media URL to it, so live and restored results both
  render media from your own origin, not the provider's expiring link.
- Text results (transcription / summarize) persist their text + usage so they
  restore too.

Docs, skills, the example, and both e2e specs updated to the transparent
surface; the e2e now asserts the restored image renders from the durable URL.

* ci: apply automated fixes

* docs(persistence): slim the generation page, move advanced material to its own page

The generation-persistence page had grown to cover everything: the two modes,
reconnecting a live stream, resumeState semantics, seeding state, securing the
hydration endpoint, and the record internals. Keep the main page a focused
two-mode quickstart (choose a mode, server-driven, client-driven) and move the
deeper material to a new "Generation Persistence: Advanced" page.

* feat(generation-persistence): rejoin an in-flight run on mount (useChat parity)

When a generation run was still streaming at reload, the client only repainted
the record; it did not re-attach to the live stream. Now it does, mirroring
useChat: on mount, when hydration reports a run still generating, the client
tails it through the durability log and finishes it in place.

- Expose the connection's `joinRun` on the generation `ConnectConnectionAdapter`
  (the SSE/HTTP adapters already implement it for chat).
- `rejoinInFlight(runId)` in the generation + video clients, reusing
  `processStream`. Triggered from the server hydrate's `activeRun` and from a
  client-driven `running` snapshot's `resumeState.runId`. A live `generate()`
  wins; each run rejoins once; the loading/abort reset is guarded so a
  stop-then-generate race can't clear a fresh run's loading flag.
- Docs: drop the "cannot re-attach on reload" caveat; the main page now states a
  dropped connection or reload rejoins automatically.

* ci: apply automated fixes

* docs(persistence): remove the generation-persistence advanced page

Its reconnect section became false once in-flight runs rejoin automatically, and
the rest (resumeState, seeding, record internals) is already covered on the main
page. Fold the one load-bearing bit β€” the reconstructGeneration `authorize`
tenancy note β€” inline into the server example and drop the page + its nav entry.

* feat(examples): shared generation run history + fix stale generation-persistence docs

Example app: every generation route now wires its hook through
`generationRunPersistence()`, which delegates to `localStoragePersistence()`
and layers a shared run-history list on top of the storage-adapter seam. The
new `GenerationRunHistory` component renders that list, so each page shows its
previous runs β€” run history is an app concern, and the adapter seam is where
you build it.

Docs/comments: correct three stale claims that predate the dedicated
`GenerationJobStore`.

- `internals.md` still said generation "reuses chat `RunStore` and dual-keys
  `(runId, threadId)` both to `requestId`" as a stopgap, and called artifact
  persistence a follow-up. Both shipped; replaced with what the middleware
  actually does and how the optional `threadId` link works.
- `controls.md` and `internals.md` both listed `withGenerationPersistence` as
  requiring `runs`; it requires `jobs`.
- `RunRecord`'s JSDoc glossed a run as "one agent turn within a conversation",
  contradicting every other use of "turn" in the package. A run is one
  AG-UI `RUN_STARTED` β†’ `RUN_FINISHED` cycle: it contains many agent-loop
  turns, and one user turn may span several runs across interrupt-resume.

* refactor(persistence): rename generation job to run (GenerationRunStore, runId, providerJobId)

One generation id previously wore three names: minted as runId on the wire
(AG-UI), stored as jobId in the generation store, and handed back as runId on
hydration. 'jobId' also collided with the provider's async video job handle
sitting one field away in the same snapshot. Converge on 'run' for the AG-UI
id and reserve 'job' for provider async jobs:

- GenerationJobStore/Record/Status -> GenerationRunStore/Record/Status;
  defineGenerationJobStore -> defineGenerationRunStore; record field
  jobId -> runId (matches chat's RunStore/RunRecord.runId)
- stores.jobs -> stores.generationRuns (bundle key, validators, memory store)
- reconstructGeneration reads ?runId= (option jobParam -> runParam)
- GenerationResultSnapshot.jobId -> providerJobId (ditto
  GenerationRestoredResult); parser accepts both spellings since live
  provider results still carry jobId
- provider surfaces unchanged: VideoGenerateResult.jobId, getVideoJobStatus,
  useGenerateVideo jobId state, PersistedArtifactRef.source.jobId,
  video:job:created payload
- docs (6 persistence pages + config dates), 4 skills, changeset updated

All unreleased surface (none of it is on main), so no migration needed.

* docs: explain threads, runs, and turns across streaming, interrupts, and persistence

Add a 'Threads, runs, and turns' section to the streaming guide defining
threadId vs runId and why a turn can span multiple runs, then cross-link
it from interrupts, resumable streams, and the persistence docs. Add
mermaid diagrams for the run/interrupt/generation state lifecycles, the
persistence ER schema, and the reconnect sequences.

* docs: narrow streaming guide to threads and runs, cross-link from persistence

Rename the streaming section to 'Threads and runs': just the two id
definitions, a note that tool calls stream inside the same run, and a
mermaid diagram of one thread with three runs. Update the inbound links
from interrupts, resumable streams, and the persistence docs to the new
anchor.

* refactor(examples): drop generation run history, switch image/video to Grok Imagine

Each generation page now shows only its last run, restored from the
shared localStorage snapshot adapter (lib/generation-persistence.ts) β€”
the shared history list, GenerationRunHistory component, and
label/preview recording are gone.

Image and video generation move from OpenAI (gpt-image-1, sora-2) to
xAI Grok Imagine (grok-imagine-image, grok-imagine-video) in the API
routes and server functions.

* ci: apply automated fixes

* fix(persistence): don't fetch caller-supplied prompt URLs + review fixes

Byte storage had one fetch path serving two purposes: `descriptorBody`
branched on `descriptor.url` alone and never looked at `descriptor.role`,
so a prompt part with `source: { type: 'url' }` was fetched server-side and
stored, readable back through the artifact GET route. Fetching an expiring
provider result URL is the point of the feature; mirroring a caller-supplied
URL is not, and the bytes are redundant since the client already had them.

Input URLs are no longer fetched. Opting back in is `allowInputUrl`, a
predicate rather than a boolean so the check can't be skipped. Every artifact
fetch is now http/https-only, timed out (`artifactFetchTimeoutMs`) and
size-capped during the drain (`maxArtifactBytes`); input fetches also block
loopback/private/link-local hosts and refuse redirects. Output fetches skip
the host block on purpose β€” a self-hosted provider legitimately returns a
localhost URL. `artifactFetch` injects the fetch for egress-proxy routing.

Also from review:

- gate `emitResumeState` on a signature, so a per-chunk snapshot rebuild no
  longer re-renders every framework hook on every stream event
- guard an invalid Date before `toISOString()` in the resume snapshot reducer
- fall back to the literal payload when a data URL has a bad percent escape
- treat a non-object hydration body as a miss instead of reading `.activeRun`
  off null
- docs: authorize artifact reads by `ArtifactRecord.threadId` (404, not 403),
  drop auto-resume language for snapshot hydration, add the Mode B server
  snippet, honour `limit: 0` in the R2 sample

* refactor(persistence): rename artifact externalUrl to sourceUrl

`externalUrl` sat directly above `url` on `PersistedArtifactRef` and read
backwards: `externalUrl` is the provider's original expiring link, kept for
provenance, while the plain `url` is the durable app-origin URL that actually
serves the bytes publicly. The field named "external" was the internal one.

`sourceUrl` says what it is β€” where the bytes came from. It also covers the
case `providerUrl` would miss: with `allowInputUrl`, an input artifact's
source is a caller-supplied URL, not a provider's.

Straight rename, no alias: `PersistedArtifactRef` is not in the published
@tanstack/ai@0.42.0, so nothing downstream can be depending on the old name.

* feat(generation-persistence): require threadId when persistence is on

`threadId` was introduced as an optional "link to the chat conversation that
triggered this generation". It is not that β€” it is the generation's own scope,
the stable slot successive runs are filed under, and a workflow generating (say)
a video's start frame has no conversation anywhere near it.

Presenting it as optional produced three concrete defects:

- The fallback chain `threadId ?? id ?? generated` ends in Date.now()+random,
  rebuilt on every construction. With neither supplied, client-driven wrote a
  new localStorage key every reload (restoring nothing, orphaning the last one)
  and server-driven asked for a threadId that had never existed. Both failed
  silently.
- The two modes keyed on DIFFERENT values β€” client-driven on `id`, server-driven
  on `threadId` β€” so `id: 'a'` + `threadId: 'b'` wrote slot a and read slot b.
- `id` did double duty as devtools label and persistence key, so relabelling in
  devtools silently relocated persisted data.

`threadId` is now required whenever `persistence` is set, via a union
(`GenerationPersistenceOptions`) intersected onto each hook's parameter. It stays
optional for ephemeral generations, so the published no-persistence signature is
untouched β€” adding an unconditional required option would have broken every
existing call site.

Persistence now keys on the explicit `threadId` in both modes. The `?? id`
fallback survives only for the AG-UI wire thread id, which the protocol requires
even when nothing is persisted; a runtime warning covers JS callers who bypass
the type.

The union is fragile in one specific way β€” a plain `Omit` over it collapses the
union and the requirement silently disappears β€” so the options interfaces stay
non-union (keeping Pick/Omit composition working in vue/solid/svelte/angular)
and `use-generation-persistence-types.test.ts` pins the behaviour.

* fix(persistence): fail loudly when a threadId lookup needs findLatestForThread

`findLatestForThread` is optional on GenerationRunStore and was called through
`?.`, so an adapter that does not implement it produced `undefined ?? null` β€”
indistinguishable from an ordinary 'no run found'. A server-driven client would
therefore restore nothing, forever, with no error anywhere to explain why.

Throw instead, and only on the path that actually needs the method: an explicit
`?runId=` lookup never calls it and keeps working on a minimal adapter.

* feat(persistence): storageKey for blob paths, and blobKey on the record

Generated bytes were written to a hardcoded `artifacts/<runId>/<artifactId>`
with no way to influence it, so "keep my generated files in my own R2 folder
structure" was not expressible. `withGenerationPersistence` now takes a
`storageKey` mapper receiving the artifact's identity, role, activity, mime type
and resolved name.

Server-side only, deliberately: a key supplied by the browser would be a
path-traversal and cross-tenant-write vector, the same class as the two issues
already fixed on this branch.

This forces a companion change. `retrieveBlob` RECOMPUTED the path from runId +
artifactId, which only works while the derivation is a fixed constant β€” the
moment it is user-supplied the read looks in the wrong place. The resolved key is
therefore recorded on the new `ArtifactRecord.blobKey`, and reads go through
`resolveArtifactBlobKey`, which falls back to the old convention for records
written before the field existed. That fallback is what makes this a
non-breaking addition, and also why the default convention can never be changed
retroactively.

Worth having independently of `storageKey`: with the key recomputed rather than
remembered, the default convention was effectively frozen forever β€” changing
`artifactBlobKey` would have orphaned every blob already written.

Also threads the required `threadId` through the docs, skills, E2E harness and
example call sites, and documents both new capabilities in the changeset.

* feat(persistence): server-side generation persistence in the example; require store methods

The example demonstrated only the client-driven half of generation persistence.
`withGenerationPersistence` and `reconstructGeneration` had never been run
against each other over HTTP anywhere β€” each was unit-tested in isolation, and
the e2e harness deliberately hand-builds the hydration JSON rather than pull in
`@tanstack/ai-persistence`. That left the join between them, which this branch
just changed the key of, as the least-covered part of the feature.

`/api/generate/image` now runs the real thing: `withGenerationPersistence` with
byte storage and an `artifactUrl`, plus a GET that serves artifact bytes by id
or answers mount hydration. The Streaming variant switches to `persistence:
true`; Direct and Server Fn keep the client adapter because server functions
have no GET path for server-driven restore to use.

Make three store methods required, per this file's own evolution policy:

- `GenerationRunStore.findLatestForThread` was optional and feature-detected β€”
  the exact anti-pattern the policy documents, and the exact bug it records
  `findActiveRun` causing for a release cycle. Server-driven hydration calls it
  on every mount, so an adapter without it was indistinguishable from a thread
  with no runs: `persistence: true` silently restored nothing, forever. The
  runtime guard added earlier on this branch is deleted β€” the compiler enforces
  it now, and the cases those tests covered are unrepresentable.
- `ArtifactStore.delete` / `deleteForRun` were optional while their pair
  `BlobStore.delete` is required, so a backend could drop the bytes but keep the
  record. An app calling `stores.artifacts.delete?.(id)` for an erasure request
  would silently no-op.

Also documents `blobKey` in the adapter guide's reference record and ER diagram,
where `ARTIFACT ||--|| BLOB` was a derived convention and is now a real key, and
deletes `api.interrupts.test.ts` β€” 19 assertions no runner has ever executed
(the example's vitest config scopes to `src/lib/**`), which also cost a
route-scanner warning on every dev start.

* fix(example): keep generation persistence across HMR re-evaluation

The module-level `memoryPersistence()` was rebuilt every time Vite
re-evaluated this module, so any artifact URL already stamped into a rendered
result 404'd on the next file save β€” the image broke even though its b64Json
was still present, because the UI prefers `img.url`. Stash the instance on
globalThis so one dev session keeps one store.

* feat(persistence): sqlite generation stores + conformance coverage

Finish the example's `node:sqlite` adapter for generations: the schema and
row types were in place, the store implementations were not.

- `GenerationRunStore`: idempotent `createOrResume` via ON CONFLICT DO
  NOTHING, dynamic-SET `update` over the JSON columns, `findLatestForThread`
  on the (thread_id, started_at DESC) index.
- `ArtifactStore`: upsert `save` persisting `blobKey`/`sourceUrl`, run-scoped
  `list` / `deleteForRun`.
- `BlobStore`: bytes in a BLOB column, keyset-cursor `list`. Prefix matching
  uses `substr(key, 1, length(?)) = ?` rather than LIKE β€” SQLite's LIKE is
  case-insensitive for ASCII and treats %/_ as wildcards, both of which break
  the contract's literal, case-sensitive prefix rule.

The factory returns a fully-spelled seven-store `AIPersistence`, so one
instance backs both `withPersistence` and `withGenerationPersistence`, and
the example's generation route now runs on it instead of `memoryPersistence()`
β€” generated images survive a dev-server restart, which is what the reverted
HMR workaround was standing in for.

Extend `runPersistenceConformance` to `generationRuns` / `artifacts` / `blobs`
so the generation half is held to the same gate as chat. Because the suite
fails loudly on an undeclared missing store, a chat-only adapter now passes
`skip: ['generationRuns', 'artifacts', 'blobs']`; the adapter-building skills
and the build-your-own-adapter guide are updated to match.

Also fixes the pre-`blobKey` artifact schema still shown in the docs and the
Cloudflare artifact-store skill (`external_url`, no `blob_key`) β€” copying it
made any artifact written with a custom `storageKey` unreadable, since the
key can no longer be recomputed.

* feat(example): server-side generation persistence on every activity

Image was the only route running `withGenerationPersistence`; video, audio,
speech and transcription streamed straight through, so their media lived only
at the provider's expiring URL and a restored run had nothing to render.

All five now persist. Bytes are served by ONE shared route β€” `/api/artifacts`
β€” instead of a per-route `?artifact=` branch: artifacts are addressed by id
and carry their own `mimeType`, so nothing about serving them is
activity-specific, and the authorization check a real deployment needs lives
in one place. `artifactServeUrl` points there and every route passes it as
`artifactUrl`, so results are rewritten to our origin.

The image route's GET is now purely `reconstructGeneration` mount hydration.

Audio/speech/transcription keep their zod validation and typed 400s; they gain
`generationParamsFromBody` to lift `threadId` / `runId` off the AG-UI envelope
so runs are filed under the scope the client hydrates by. Video reads its
adapter arguments off `data` as before β€” `size`/`model` are adapter-specific
unions the provider-agnostic video input widens to `string` β€” and uses the
helper for identity only.

Transcription produces text, not media: what it persists is the run record
plus the input audio artifact.

* fix(example): make generation routes resumable so a refresh can rejoin

Refreshing mid-generation surfaced "Stream response body read failed".

Resumability is automatic on the CLIENT and opt-in on the SERVER. On mount the
client re-attaches to a run it believes is still going by issuing
`GET <route>?offset=-1&runId=…`. None of the generation routes had a GET, so
Start's catch-all answered with the SPA's HTML shell, which the client then
failed to parse as SSE β€” surfacing a raw transport error (StreamReadError) in
place of anything actionable.

Every streaming generation route now opts in, per the resumable-streams guide:
chunks are logged and id-tagged through `memoryStream` on the response, and a
GET replays the log. An unknown or aged-out run now answers with a RUN_ERROR
event on a real `text/event-stream` instead of HTML.

Video additionally detaches its run from the request (`startDetachedGeneration`
in the new lib/generation-durability), so a reload cannot kill a multi-minute
job β€” the producer keeps going and the reader is what gets cancelled. That is
the persistent-chat route's policy and it is deliberately NOT applied to the
short activities: a detached run keeps billing after the user leaves, and an
image or a speech clip is cheaper to re-run than to keep alive.

The image GET now serves two jobs in order, like the chat route: delivery
replay when the request carries a resume offset, otherwise `reconstructGeneration`
mount hydration.

* fix(example): let nitro's dev middleware serve /api to subresources

`nitro/dist/_build/vite.dev.mjs` classifies a request as a static asset from
`Sec-Fetch-Dest`: anything that isn't `document`/`iframe`/`frame` falls through
to vite's static middleware, which has no file and 404s with connect's
`Cannot GET` page. The extension branch only applies when the header is absent
or `empty`, so renaming the route doesn't help.

That makes every artifact URL unloadable in dev: `<img src="/api/artifacts?id=…">`
sends `Sec-Fetch-Dest: image` and 404s, while the same URL fetched from JS
(`empty`) returns the bytes. It only bites routes served under Start's
catch-all `/**`, which is all of them here.

A pre-plugin presents `empty` for our own `/api/` paths, routing them back to
the server without changing what the browser sends. Dev-only β€” this middleware
does not exist in a production build.

* feat(persistence): server-driven generation persistence over server functions

Server-driven persistence (`persistence: true`) previously required an HTTP
endpoint, because the hydrate/rejoin handlers lived on the connection adapter
and only the fetch/XHR adapters implemented them. A TanStack Start server
function had no way to participate, so `persistence: true` silently restored
nothing there.

Persistence handlers are now supplied independently of the transport:

- `stream()` takes an optional second argument of `{ hydrate,
  hydrateGeneration, joinRun }`, spread onto the adapter.
- The generation client accepts `hydrateGeneration` / `joinRun` as options,
  used when the connection carries none. The connection's handlers win when
  both exist, and `persistence: true` with no handler from either source warns
  instead of silently no-opping.
- `memoryStream` accepts an explicit `{ runId, offset }` alongside a `Request`,
  and the new `replayRunStream` replays a run's delivery log as a bare chunk
  stream β€” what a server function needs to serve `joinRun` without an HTTP
  `Response`.

A restored snapshot that reports a run still in flight is now repainted through
one path: tail it via `joinRun` when a handler exists, otherwise repaint it as
an interrupted error rather than a `generating` status that would never settle.

The generation hooks across React, Solid, Vue, Svelte and Angular forward the
new options.

* Merge remote-tracking branch 'origin/main' into feat/generation-persistence-full

* fix(ai): apply result transforms and carry identity in streaming generateVideo

A persisted video restored as nothing on reload. The run record showed
`status: 'complete'` and nothing else β€” no result metadata, no artifact refs,
no stored bytes, and `thread_id` NULL.

Streaming video was the only media activity that never called
`applyGenerationResultTransforms`, and never put the caller's `threadId` /
`runId` on the middleware context. `withGenerationPersistence` registers BOTH
its artifact capture and its run-record `result` write as result transforms,
pushed onto an OPTIONAL `ctx.resultTransforms` β€” so both silently no-opped,
and the run was filed under the internal `requestId` with no thread link. The
client rebuilds a restored video from an output artifact carrying a durable
url, found none, and restored nothing.

Video now applies the transforms to its terminal result before yielding it, so
the `generation:result` chunk and the stored record carry the same urls
(including the app-origin one `artifactUrl` stamps), and passes `threadId` /
`runId` / `artifactInputs` into the context like `generateImage`.

`threadId` is now a documented option on `generateVideo`. It previously had
none, so callers passing one through an object spread type-checked and were
silently ignored β€” which is how the example's route looked correct while
recording NULL. When omitted, an id is still minted for the RUN_* wire chunks,
but the middleware context gets `undefined` instead: a fabricated thread id is
a slot no client can hydrate by, which is worse than no link at all.

Both regression tests fail against the previous behaviour.

* feat(persistence)!: require threadId on withGenerationPersistence

The client hooks require `threadId` whenever `persistence` is set; the server
middleware did not. That asymmetry hid a class of silent failure: a run filed
under no scope cannot be hydrated by one, so `persistence: true` restored
nothing, forever, with no error to explain why. The example's video route hit
exactly this β€” its runs recorded `thread_id: NULL`.

`withGenerationPersistence(persistence, { threadId, ... })` now takes a
required `threadId` via the new `WithGenerationPersistenceOptions`, mirroring
the client's discriminated union.

The option is also the AUTHORITY for the run record's and artifacts' scope, in
preference to `ctx.threadId`. An activity mints a throwaway thread id for its
RUN_* wire chunks when the caller passes none, and persisting that fabricated
id filed runs in a slot nothing could look up β€” worse than recording no link,
because it looks like one. A test that asserted the old fallback (wire id ==
persisted id) now asserts they deliberately diverge.

Call sites updated across the example routes, docs and skills. The example
routes reject a request carrying no `threadId` with a 400 rather than inventing
one, which is the pattern the docs now show.

Note: `docs/persistence/generation-persistence.md` has one remaining kiira
failure in the `getImageHydrationFn` snippet (a `ReconstructedGeneration` /
Start `ServerFn` return-type mismatch). It predates this commit β€” verified by
stashing these changes β€” and is left alone.

* fix(generation): make runs survive client disconnect and resume mid-run

Durability decouples the producer from the HTTP response so a durable run keeps draining to the log after a reload; RUN_STARTED flushes immediately so one-shot activities are resumable from the start; summarize threads runId through chat (openai-base honors options.runId) so its delivery log aligns with the client's rejoin; TTS restores via reconstructSpeechResult; a failed rejoin settles to error instead of stuck-generating; dispose keeps the run resumable; OpenAI reasoning models drop unsupported temperature/top_p.

Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh

* feat(example): persistent-generation route; rely on library durability

New /generations/persistent-generation page wiring all six generation hooks (server-driven for the five media, client-driven for summarize). Server routes now fall back to reconstructGeneration on GET, and the video route drops the hand-rolled startDetachedGeneration/tailGenerationResponse in favor of the plain toServerSentEventsResponse(stream, { durability }) path now that the library owns run lifetime. Summarize route adds delivery durability + a resume GET and threads runId.

Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants