Skip to content

feat(persistence): generation run persistence (client + server) - #1011

Open
tombeckenham wants to merge 62 commits into
mainfrom
feat/generation-persistence-full
Open

feat(persistence): generation run persistence (client + server)#1011
tombeckenham wants to merge 62 commits into
mainfrom
feat/generation-persistence-full

Conversation

@tombeckenham

@tombeckenham tombeckenham commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🎯 Changes

Generation persistence — the media-generation parallel of chat persistence. A media run (image, video, audio, TTS, transcription, summarize) now survives a page reload or a dropped connection, restoring transparently into the normal hook fields, with optional durable storage of the generated bytes.

Split out of #987 and rebuilt on the current feat/persistence-core. The previously-stacked byte-storage half has been folded in, so this is now the whole feature in one PR.

Supersedes #997 (same change, renamed from feat/generation-persistence-client — the branch always contained the server half too) and folds in #998.

The shape

A generation run is recorded in its own store, keyed by its runId (the same AG-UI run id the client sends), with threadId an optional link — it no longer overloads the chat RunStore. On the client, the hook picks a mode exactly the way useChat does:

// Client-driven: the browser holds a lightweight snapshot under `generation:<id>`.
useGenerateImage({ id: 'hero', connection, persistence: localStoragePersistence() })

// Server-driven: nothing is cached; the last run for `threadId` is fetched on mount.
useGenerateImage({ threadId, connection, persistence: true })

Restore is invisible. There is no resumeSnapshot / pendingArtifacts / resultArtifacts hook field — a reload repaints result / status / error as if the run had just finished. resumeState carries the in-flight run identity only.

Crucially, this restores a record, not provider work: generation still only begins when generate(...) is called, and nothing is ever restarted. A run that is still streaming is re-attached and finished in place, via the connection's joinRun durability replay — the same mechanism useChat uses.

Server

  • withGenerationPersistence records each run in a dedicated generationRuns (GenerationRunStore) store: activity/provider/model, lifecycle status, result metadata, and — when byte storage is on — the durable artifact refs.
  • reconstructGeneration(persistence, request, options?) is the generation parallel of reconstructChat: it reads ?runId= (preferred) or the latest run linked to ?threadId=, authorizes via authorize, and returns { resumeSnapshot, activeRun } JSON for a server-authoritative client to hydrate from.
  • Byte storage (opt-in, layered on top): provide both an artifacts (ArtifactStore) and a blobs (BlobStore) store and the middleware writes each generated file's bytes under artifacts/<runId>/<artifactId>, records an ArtifactRecord, and attaches PersistedArtifactRefs to the result and the run record. The new artifactUrl option stamps a durable app-origin serve URL onto each ref and rewrites the live result's media URL to it, so live and restored results both render from your origin instead of the provider's expiring link. retrieveArtifact / retrieveBlob (and the shared artifactBlobKey) serve the bytes back; extraction is customizable via extractArtifacts / nameArtifact.
  • memoryPersistence() ships in-memory generationRuns / artifacts / blobs; defineGenerationRunStore / defineArtifactStore / defineBlobStore type a custom store inline the way defineMessageStore / defineRunStore already do.

Client

Generation hooks (useGenerateImage, useGenerateVideo, useGenerateAudio, useGenerateSpeech, useGeneration, useSummarize, useTranscription, and the Solid/Vue/Svelte/Angular equivalents) take the same persistence option chat does. The option reuses the ChatStorageAdapter contract, so localStoragePersistence / sessionStoragePersistence / indexedDBPersistence work for generations too with no type argument — a bare call is correct on either side. Untrusted snapshots are validated on read with the new parseGenerationResumeSnapshot.

With byte storage configured, a restored result is rebuilt whole, its media resolved to the durable serve URL and its refs on result.artifacts. Without it, status / error restore and result stays null — a client snapshot never holds bytes.

The base generation return types (UseGenerationReturn / CreateGenerationReturn / InjectGenerationResult) also gained a defaulted TInput generic across all five frameworks, so generate is precisely typed (input: TInput) => Promise<void>. That deleted the unsound internal narrow-to-wide casts and every wrapper-level generate as cast — twenty-five in total — with existing single-generic references unaffected.

Packages

  • @tanstack/ai-persistenceGenerationRunStore, reconstructGeneration, the byte-storage half (ArtifactStore / BlobStore / retrieveArtifact / retrieveBlob / artifactUrl), and the define* store helpers.
  • @tanstack/ai-client — the resume-snapshot reducer, parseGenerationResumeSnapshot, mount hydration (client store and server GET), result reconstruction from refs.
  • @tanstack/ai-utilsbase64ToUint8Array.
  • framework hooks — react / solid / vue / svelte / angular.
  • @tanstack/ai — the generation activities gained threadId / runId options.

Docs, skills, examples, tests

  • Docs — new docs/persistence/generation-persistence.md and docs/persistence/keep-generated-files.md, plus updates across client-persistence, overview, controls, build-your-own-adapter, internals, the docs/media/* pages, docs/chat/streaming.md and docs/interrupts/overview.md (threads / runs / turns are now explained consistently). All kiira-verified.
  • Skillsai-core/client-persistence, ai-core/media-generation, ai-persistence, and a new ai-persistence/build-cloudflare-artifact-store.
  • Exampleexamples/ts-react-chat wires generation persistence end to end (image + video via Grok Imagine).
  • E2Egeneration-persistence.spec.ts (client-driven: stream → reload → transparent restore → reset) and generation-persistence-server.spec.ts (server-driven: persistence: true, restore from a ?threadId= GET, localStorage stays empty, no run auto-starts). Plus hydration/restore unit coverage in all five framework packages and the persistence package.

✅ 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).

Summary by CodeRabbit

  • New Features
    • Added end-to-end generation persistence across reloads/dropped connections, including resumable generation identity via threadId + ephemeral runId.
    • Durably restore generated media using application-served artifact URLs (optional artifact metadata + durable byte storage).
    • Generation hooks now expose resumeState and correctly repaint status/result/error when persistence is enabled.
    • Introduced server-driven generation hydration keyed by threadId, plus secure URL input fetching protections.
    • Generation events now include threadId/runId for better correlation.
  • Documentation
    • Expanded threads/runs, generation persistence, durable media, adapter/store contracts, and server route examples.
  • Tests
    • Added extensive resume/persistence coverage across client/server modes, plus new type-level and unit/integration tests.

AlemTuzlak and others added 30 commits July 29, 2026 06:39
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.
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.
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.
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.
…e 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
…t 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
…hots

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().
…ration 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)
…enerate 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.
…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.
…Value = 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.
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.
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.
…te 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.
…al 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.
…o 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.
…at 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.
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.
…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.
…re, 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.
…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.
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.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/ai-solid/src/use-generation.ts (2)

259-265: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the previous body when the option becomes undefined.

The conditional spread on Line 263 skips the update when currentBody is removed, so the client keeps sending the previous body on later generations. Explicitly clear it using the supported empty/unset representation.

Proposed fix
   createEffect(() => {
     const currentBody = options.body
-    client.updateOptions({
-      ...(currentBody !== undefined && { body: currentBody }),
-    })
+    client.updateOptions({ body: currentBody ?? {} })
   })
🤖 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-solid/src/use-generation.ts` around lines 259 - 265, Update the
body synchronization effect around currentBody and client.updateOptions so it
explicitly clears the client’s previous body when options.body becomes
undefined, using the client’s supported empty or unset representation; preserve
passing the current body when it is defined.

186-201: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Use the conditional body spread described by this implementation comment.

options.body may be absent while GenerationClientOptions.body is a strict optional, so this direct assignment can fail under exactOptionalPropertyTypes. Match the similar Svelte/React use-generation handling and omit the key when it is undefined.

Proposed fix
-      body: options.body,
+      ...(options.body !== undefined && { body: options.body }),
🤖 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-solid/src/use-generation.ts` around lines 186 - 201, Update the
clientOptions construction in useGeneration to remove the direct body assignment
and conditionally spread body only when options.body is defined. Preserve the
existing body value when present and omit the property when absent, matching the
handling in the Svelte/React use-generation implementations.
♻️ Duplicate comments (1)
packages/ai/skills/ai-core/client-persistence/SKILL.md (1)

198-210: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make every persisted generation route require and propagate threadId.

  • packages/ai/skills/ai-core/client-persistence/SKILL.md#L198-L210: pass the validated client threadId into generateImage.
  • packages/ai/skills/ai-core/media-generation/SKILL.md#L589-L595: reject missing threadId instead of documenting it as optional.
🤖 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/skills/ai-core/client-persistence/SKILL.md` around lines 198 -
210, Make every persisted generation route require a validated client threadId
and propagate it through the generation call. In
packages/ai/skills/ai-core/client-persistence/SKILL.md lines 198-210, update the
POST handler and generateImage invocation to pass threadId; in
packages/ai/skills/ai-core/media-generation/SKILL.md lines 589-595, change the
documented contract to reject missing threadId rather than treating it as
optional.
🧹 Nitpick comments (1)
packages/ai-react/tests/use-generation-persistence-types.test.ts (1)

18-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Place these tests alongside their covered hooks.

This shared test is under tests/ while it covers modules in src/. Split or relocate the assertions into colocated *.test.ts files. As per coding guidelines, **/*.test.ts: “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-react/tests/use-generation-persistence-types.test.ts` around
lines 18 - 24, The shared persistence type tests in
use-generation-persistence-types.test.ts should be colocated with the hooks they
cover. Split the assertions into *.test.ts files alongside useGenerateImage,
useGenerateVideo, useGeneration, useSummarize, and useTranscription, preserving
the existing coverage and removing the centralized tests/placement.

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.

Outside diff comments:
In `@packages/ai-solid/src/use-generation.ts`:
- Around line 259-265: Update the body synchronization effect around currentBody
and client.updateOptions so it explicitly clears the client’s previous body when
options.body becomes undefined, using the client’s supported empty or unset
representation; preserve passing the current body when it is defined.
- Around line 186-201: Update the clientOptions construction in useGeneration to
remove the direct body assignment and conditionally spread body only when
options.body is defined. Preserve the existing body value when present and omit
the property when absent, matching the handling in the Svelte/React
use-generation implementations.

---

Duplicate comments:
In `@packages/ai/skills/ai-core/client-persistence/SKILL.md`:
- Around line 198-210: Make every persisted generation route require a validated
client threadId and propagate it through the generation call. In
packages/ai/skills/ai-core/client-persistence/SKILL.md lines 198-210, update the
POST handler and generateImage invocation to pass threadId; in
packages/ai/skills/ai-core/media-generation/SKILL.md lines 589-595, change the
documented contract to reject missing threadId rather than treating it as
optional.

---

Nitpick comments:
In `@packages/ai-react/tests/use-generation-persistence-types.test.ts`:
- Around line 18-24: The shared persistence type tests in
use-generation-persistence-types.test.ts should be colocated with the hooks they
cover. Split the assertions into *.test.ts files alongside useGenerateImage,
useGenerateVideo, useGeneration, useSummarize, and useTranscription, preserving
the existing coverage and removing the centralized tests/placement.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c3d1d0d8-9105-4d15-9bf4-26d4ae4a53f1

📥 Commits

Reviewing files that changed from the base of the PR and between 951030f and 14b8d00.

📒 Files selected for processing (65)
  • .changeset/generation-persistence.md
  • docs/persistence/generation-persistence.md
  • docs/persistence/keep-generated-files.md
  • examples/ts-react-chat/src/routes/generation-hooks.tsx
  • examples/ts-react-chat/src/routes/generations.audio.tsx
  • examples/ts-react-chat/src/routes/generations.image.tsx
  • examples/ts-react-chat/src/routes/generations.speech.tsx
  • examples/ts-react-chat/src/routes/generations.summarize.tsx
  • examples/ts-react-chat/src/routes/generations.transcription.tsx
  • examples/ts-react-chat/src/routes/generations.video.tsx
  • packages/ai-angular/src/inject-generate-audio.ts
  • packages/ai-angular/src/inject-generate-image.ts
  • packages/ai-angular/src/inject-generate-speech.ts
  • packages/ai-angular/src/inject-generate-video.ts
  • packages/ai-angular/src/inject-generation.ts
  • packages/ai-angular/src/inject-summarize.ts
  • packages/ai-angular/src/inject-transcription.ts
  • packages/ai-angular/tests/inject-generation.test.ts
  • packages/ai-client/src/generation-client.ts
  • packages/ai-client/src/generation-types.ts
  • packages/ai-client/src/index.ts
  • packages/ai-persistence/skills/ai-persistence/SKILL.md
  • packages/ai-persistence/src/index.ts
  • packages/ai-persistence/src/middleware.ts
  • packages/ai-persistence/src/reconstruct-generation.ts
  • packages/ai-persistence/src/retrieve.ts
  • packages/ai-persistence/src/types.ts
  • packages/ai-persistence/tests/generation-artifacts.test.ts
  • packages/ai-persistence/tests/reconstruct-generation.test.ts
  • packages/ai-react/src/use-generate-audio.ts
  • packages/ai-react/src/use-generate-image.ts
  • packages/ai-react/src/use-generate-speech.ts
  • packages/ai-react/src/use-generate-video.ts
  • packages/ai-react/src/use-generation.ts
  • packages/ai-react/src/use-summarize.ts
  • packages/ai-react/src/use-transcription.ts
  • packages/ai-react/tests/use-generation-persistence-types.test.ts
  • packages/ai-react/tests/use-generation.test.ts
  • packages/ai-solid/src/use-generate-audio.ts
  • packages/ai-solid/src/use-generate-image.ts
  • packages/ai-solid/src/use-generate-speech.ts
  • packages/ai-solid/src/use-generate-video.ts
  • packages/ai-solid/src/use-generation.ts
  • packages/ai-solid/src/use-summarize.ts
  • packages/ai-solid/src/use-transcription.ts
  • packages/ai-solid/tests/use-generation.test.ts
  • packages/ai-svelte/src/create-generate-audio.svelte.ts
  • packages/ai-svelte/src/create-generate-image.svelte.ts
  • packages/ai-svelte/src/create-generate-speech.svelte.ts
  • packages/ai-svelte/src/create-generate-video.svelte.ts
  • packages/ai-svelte/src/create-generation.svelte.ts
  • packages/ai-svelte/src/create-summarize.svelte.ts
  • packages/ai-svelte/src/create-transcription.svelte.ts
  • packages/ai-svelte/tests/create-generation.test.ts
  • packages/ai-vue/src/use-generate-audio.ts
  • packages/ai-vue/src/use-generate-image.ts
  • packages/ai-vue/src/use-generate-speech.ts
  • packages/ai-vue/src/use-generate-video.ts
  • packages/ai-vue/src/use-generation.ts
  • packages/ai-vue/src/use-summarize.ts
  • packages/ai-vue/src/use-transcription.ts
  • packages/ai-vue/tests/use-generation.test.ts
  • packages/ai/skills/ai-core/client-persistence/SKILL.md
  • packages/ai/skills/ai-core/media-generation/SKILL.md
  • testing/e2e/src/routes/generation-persistence.tsx
🚧 Files skipped from review as they are similar to previous changes (55)
  • examples/ts-react-chat/src/routes/generations.audio.tsx
  • packages/ai-persistence/src/retrieve.ts
  • examples/ts-react-chat/src/routes/generations.speech.tsx
  • packages/ai-client/src/index.ts
  • examples/ts-react-chat/src/routes/generation-hooks.tsx
  • docs/persistence/generation-persistence.md
  • examples/ts-react-chat/src/routes/generations.image.tsx
  • examples/ts-react-chat/src/routes/generations.summarize.tsx
  • packages/ai-persistence/tests/reconstruct-generation.test.ts
  • packages/ai-persistence/src/index.ts
  • examples/ts-react-chat/src/routes/generations.video.tsx
  • packages/ai-react/src/use-generate-speech.ts
  • packages/ai-react/src/use-summarize.ts
  • packages/ai-react/src/use-transcription.ts
  • packages/ai-persistence/src/reconstruct-generation.ts
  • packages/ai-solid/src/use-generate-audio.ts
  • packages/ai-vue/src/use-generate-video.ts
  • packages/ai-angular/src/inject-generate-video.ts
  • packages/ai-vue/src/use-transcription.ts
  • examples/ts-react-chat/src/routes/generations.transcription.tsx
  • docs/persistence/keep-generated-files.md
  • packages/ai-react/src/use-generate-audio.ts
  • testing/e2e/src/routes/generation-persistence.tsx
  • packages/ai-angular/src/inject-generate-image.ts
  • packages/ai-react/src/use-generate-image.ts
  • packages/ai-vue/src/use-generate-speech.ts
  • packages/ai-solid/src/use-summarize.ts
  • packages/ai-solid/src/use-generate-image.ts
  • packages/ai-vue/src/use-summarize.ts
  • packages/ai-svelte/src/create-generate-audio.svelte.ts
  • packages/ai-solid/src/use-generate-video.ts
  • packages/ai-persistence/tests/generation-artifacts.test.ts
  • packages/ai-svelte/src/create-generate-video.svelte.ts
  • packages/ai-angular/src/inject-generation.ts
  • packages/ai-vue/src/use-generate-audio.ts
  • packages/ai-angular/src/inject-generate-speech.ts
  • packages/ai-solid/src/use-generate-speech.ts
  • packages/ai-angular/src/inject-summarize.ts
  • packages/ai-svelte/src/create-summarize.svelte.ts
  • packages/ai-vue/src/use-generate-image.ts
  • packages/ai-angular/src/inject-transcription.ts
  • packages/ai-react/src/use-generation.ts
  • packages/ai-vue/tests/use-generation.test.ts
  • packages/ai-svelte/tests/create-generation.test.ts
  • packages/ai-solid/src/use-transcription.ts
  • packages/ai-solid/tests/use-generation.test.ts
  • packages/ai-react/tests/use-generation.test.ts
  • packages/ai-svelte/src/create-generation.svelte.ts
  • packages/ai-client/src/generation-types.ts
  • packages/ai-react/src/use-generate-video.ts
  • packages/ai-svelte/src/create-transcription.svelte.ts
  • packages/ai-persistence/src/middleware.ts
  • packages/ai-client/src/generation-client.ts
  • packages/ai-vue/src/use-generation.ts
  • packages/ai-persistence/src/types.ts

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

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
examples/ts-react-chat/src/routes/generations.image.tsx (1)

186-195: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid rendering a broken data URL after client-side restoration.

The documented direct/server-function restore path has no image bytes. When img.url and img.b64Json are both absent, this renders data:image/png;base64,undefined instead of a meaningful unavailable state.

Proposed fix
       {result && (
         <div className="space-y-4">
-          {result.images.map((img, i) => (
+          {result.images
+            .filter((img) => img.url || img.b64Json)
+            .map((img, i) => (
             <img
               key={i}
               src={img.url || `data:image/png;base64,${img.b64Json}`}
               alt={img.revisedPrompt || prompt}
               className="w-full rounded-lg border border-gray-700"
             />
-          ))}
+            ))}
         </div>
       )}
🤖 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/generations.image.tsx` around lines 186 -
195, Update the image rendering in the result.images map to handle entries where
both img.url and img.b64Json are absent: avoid constructing a data URL with
undefined and render a meaningful unavailable state instead. Preserve the
existing URL and base64 image rendering for entries that contain image data.
🤖 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 `@examples/ts-react-chat/src/routes/api.generate.image.ts`:
- Around line 41-64: Authorize image-generation persistence using a
server-derived per-session identity rather than trusting supplied opaque IDs. In
examples/ts-react-chat/src/routes/api.generate.image.ts:41-64, validate
ownership of supplied threadId/runId before passing them to generateImage and
withGenerationPersistence; in
examples/ts-react-chat/src/routes/api.generate.image.ts:71-96, provide the
authorization callback during reconstruction and verify the artifact record’s
threadId before returning bytes. In
examples/ts-react-chat/src/routes/generations.image.tsx:29-38, replace the
shared "image:streaming" identifier with a per-user/per-thread identifier, while
continuing to authorize it at the route boundary.

---

Outside diff comments:
In `@examples/ts-react-chat/src/routes/generations.image.tsx`:
- Around line 186-195: Update the image rendering in the result.images map to
handle entries where both img.url and img.b64Json are absent: avoid constructing
a data URL with undefined and render a meaningful unavailable state instead.
Preserve the existing URL and base64 image rendering for entries that contain
image data.
🪄 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 Plus

Run ID: be02f6ef-0654-4d85-b07c-48aa8fc75311

📥 Commits

Reviewing files that changed from the base of the PR and between 14b8d00 and 4c0a9b4.

📒 Files selected for processing (11)
  • docs/persistence/build-your-own-adapter.md
  • examples/ts-react-chat/src/lib/generation-persistence.ts
  • examples/ts-react-chat/src/lib/generation-server-store.ts
  • examples/ts-react-chat/src/routes/api.generate.image.ts
  • examples/ts-react-chat/src/routes/api.interrupts.test.ts
  • examples/ts-react-chat/src/routes/generations.image.tsx
  • packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md
  • packages/ai-persistence/src/reconstruct-generation.ts
  • packages/ai-persistence/src/types.ts
  • packages/ai-persistence/tests/persistence-fixtures.ts
  • packages/ai-persistence/tests/reconstruct-generation.test.ts
💤 Files with no reviewable changes (2)
  • examples/ts-react-chat/src/routes/api.interrupts.test.ts
  • packages/ai-persistence/tests/reconstruct-generation.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • examples/ts-react-chat/src/lib/generation-persistence.ts
  • packages/ai-persistence/tests/persistence-fixtures.ts
  • packages/ai-persistence/src/reconstruct-generation.ts
  • packages/ai-persistence/src/types.ts
  • docs/persistence/build-your-own-adapter.md

Comment thread examples/ts-react-chat/src/routes/api.generate.image.ts
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.
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.
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.
`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.
@tombeckenham
tombeckenham requested a review from a team as a code owner July 29, 2026 11:06
…unctions

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.
@tombeckenham
tombeckenham force-pushed the feat/generation-persistence-full branch from 0fa9d8a to 420a9da Compare July 29, 2026 11:09
tombeckenham and others added 6 commits July 29, 2026 21:12
…rateVideo

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.
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.
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
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
Rewrite the disconnect/stop/error and memoryStream-in-production sections to describe current behavior: a durable run's producer is decoupled from the delivery socket, so a client disconnect cancels only the reader and the run finishes into the log for a later rejoin; only a genuine abort or provider failure terminalizes. Bump advanced page updatedAt.

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

Copy link
Copy Markdown
Contributor

Pushed: mid-run reload resumability + persistent-generation demo

Three commits land the generation-persistence resumability work and a demo page that exercises every surface.

What's in:

  • Durable runs survive a client disconnect. toServerSentEventsResponse(stream, { durability }) now decouples the producer from the HTTP response: a reload cancels only the reader, the run keeps draining into the log, and a mount-time joinRun tails it to completion. Persistence off (no durability) still aborts on disconnect. Additive, no public API change.
  • One-shot activities are resumableRUN_STARTED is now a durability flush boundary, so image/speech/transcription/summarize populate the log at the start of the run instead of only at the terminal (which was fast-failing the rejoin as "run gone").
  • Summarize resumes mid-runsummarize() threads runId/threadId into the wrapped chat, and @tanstack/openai-base's Responses chatStream honors options.runId for RUN_STARTED, so its delivery log keys align with the client's rejoin.
  • TTS restore via new reconstructSpeechResult (surfaces the durable artifact URL, since TTSResult.audio has no URL slot).
  • Failed rejoin settles to error instead of hanging on generating; dispose() no longer wipes the resumable snapshot (that's stop()'s job).
  • OpenAI reasoning models drop unsupported temperature/top_p (fixes gpt-5.5 summarize/chat 400s).
  • Example: new /generations/persistent-generation page; server routes fall back to reconstructGeneration on GET; the video route drops the hand-rolled startDetachedGeneration/tailGenerationResponse and uses the plain library path (the whole point — the library owns run lifetime now).
  • Unit tests + changesets for all of the above. Targeted suites green: ai 1293, ai-client 587, openai-base 136, ai-openai 242; example type-checks + builds; docs links pass.

Verified in-browser: image/video/speech/transcription/summarize all resume a mid-run reload and restore on a done-refresh; a gone/interrupted run shows a clean error.

Still TODO (not in this push)

  • E2E tests (mandatory). Playwright specs in testing/e2e/ for: mid-run reload rejoins + completes; done-refresh restores; durable-run-survives-disconnect; failed-rejoin -> error.
  • Full pnpm test:pr gate. Ran targeted suites + example types/build + docs links, but not the whole affected gate (sherif/knip/oxlint across all).
  • Kiira: 1 pre-existing failure at docs/persistence/generation-persistence.md:263 (the createServerFn + getGenerationHydration server-fn snippet). Unrelated to this change and in a file it doesn't touch, but it will keep the docs gate red until fixed.
  • Code-review pass over the full diff.
  • Chat cleanup: api.persistent-chat.ts still hand-rolls startDetachedRun; now redundant since the library survives disconnect. Simplify + verify chat mid-reload separately.
  • Duplicate artifact-serve setup in the example: generation-server-store.ts + api.artifacts.ts (used by persistent-generation) vs generation-server-persistence.ts + api.generate.image.artifact.ts (used by generations.image). Worth consolidating to one.
  • Audio provider in the demo defaults to fal-audio, which 403s without a music-capable key (env-dependent; not a code bug).

autofix-ci Bot and others added 11 commits July 29, 2026 19:14
… passes

`createServerFn().handler()` rejects a `GenerationHydrationResult` return
because its `result?: unknown` field isn't assignable to Start's serializable
return constraint. Return the hydration in a `Response` (it crosses the wire as
JSON anyway) and `.json()` it on the client, mirroring the reconstructGeneration
GET the example ships. Fixes the lone kiira failure at generation-persistence.md.

Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh
Adds a provider-free harness proving the guarantee a plain `persistence: true`
restore can't: a one-shot generation whose client disconnects while still
producing keeps running to its terminal and is tailed to completion by a
mount-time `joinRun`.

The harness run pauses between RUN_STARTED and its result; the spec reloads
during that pause, cancelling the live response mid-run. The run finishes only
because the durability producer is decoupled from the delivery socket (Fix B)
and RUN_STARTED is flushed to the log immediately so the mount joinRun finds a
cursor to tail (Fix A). Without either, the reload strands the run on
`generating` or settles it to `error` — the spec asserts `success`.

Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh
Addresses a review finding: the rejoin and done-restore paths converged on an
identical end-state, so the test could pass for the wrong reason if the run
finished before the remount probe.

- Gate the run's result on the client disconnect (request.signal, with a
  fallback + short settle) so the result lands strictly AFTER the reload. The
  run can no longer complete as a done-restore before the reload, so the mount
  probe reliably observes `running` and the client takes the joinRun path.
- Give the done-restore snapshot a distinct result id (`image-restored`). The
  streamed run keeps `image-1`, so a green `result-id = image-1` can only come
  from tailing the live run — a degraded done-restore now fails loudly.

Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh
Every chat hook (useChat / createChat / injectChat) and every generation hook
now returns `runId: string | null` in place of `resumeState`.

`resumeState` was a `{ threadId, runId }` pair whose threadId half was always
the id the caller had just passed in, so the only new information it carried
was the run id, wrapped in an object that had to be unwrapped and null-checked.
`runId` is what callers actually reach for: the handle you send to your own
endpoint to cancel or poll a provider job, since `stop()` only aborts the local
stream and does not stop work already running on the provider.

On chat it also reports more than resumeState did. resumeState was only ever
populated for a run that was interrupted or being rejoined, so it stayed null
through an ordinary streaming turn. Backing the field with the new
ChatClient.getCurrentRunId() plus an onRunIdChange callback -- fired where
currentRunId is assigned and cleared (send, joinRun rejoin, stream teardown) --
makes it track every run the client owns. A run another client started, arriving
over a live subscription, is not reported: it is not yours to cancel.

injectChat (Angular) exposed no equivalent field before and now returns runId
alongside the other frameworks.

ChatResumeState and GenerationResumeState remain exported. They still describe
the persisted resume snapshot, and resumeInterruptsUnsafe still takes a
ChatResumeState; they are simply no longer part of a hook's return shape.

Covered by a client-level test (the id is reported in flight, cleared on
settle) and a React test that holds a stream open mid-flight to prove the hook
plumbing fires. The e2e app reads the renamed field.
Two things readers were left to infer.

What the ids mean. A new `persistence/id-map` page covers both: `threadId` is
the key every durable record is filed under (the conversation on chat, a slot
successive jobs fill on generation, where restore returns the newest), and
`runId` is one execution (on chat a turn, where a tool loop stays inside one run
but an interrupt resumes under a new id, so one message can span several; on
generation exactly one job per `generate` call). Includes how to choose a thread
id, why restore never keys on a run id, and what to check when restore does
nothing. Linked from overview, chat-persistence, client-persistence,
generation-persistence, streaming, and generation-hooks.

Also corrects the stale claim that `threadId` is "only an optional link" on
generation persistence, drops a reference to a hook field that is not part of
the artifact surface, and documents `runId` on the framework API pages.

Legibility. Paragraphs that named several stores, options, or contracts and
explained each in one long sentence are now bulleted lists, one item per bullet.
Readers scan a list in a second; in prose they have to read the whole thing to
learn it does not apply to them. 21 paragraphs across nine pages, including the
store roll-call in build-your-own-adapter and the method contracts for every
store.
The docs skill forbids em and en dashes outright. I used them anyway, on the
reasoning that the surrounding pages are full of them and the skill also says to
match your neighbors. That was wrong: matching neighbors covers voice and
structure, not a rule the skill states, and most of these dashes were in prose
written from scratch where there was no neighbor to match.

Rewritten with colons in list items and commas or periods in prose, across the
30 lines this branch added. The pre-existing dashes elsewhere in docs/ are left
alone; bringing the whole set in line is its own cleanup.
The guide went straight into implementing stores without answering the first
question a reader has: which of the seven do I actually need? That was
recoverable only by reading the whole page, or by cross-referencing Controls.

Adds a scannable matrix at the top. Rows are the stores, columns are what the
reader is building (transcript, live-run rejoin, durable approvals, app
key/value, generation runs, generated files), cells are a checkmark or an X.
Find your column, implement the ticked rows.

Followed by the rules the table cannot show on its own: columns stack, the chat
columns all need `messages`, the generation columns feed
`withGenerationPersistence` and need no chat stores, and the two pairs that
cannot be split (`interrupts` needs `runs`, `artifacts` needs `blobs`).
`GenerationRunStatus` is now `RunStatus`.

The two enums named the same four lifecycle states differently, `complete`
against `completed` and `error` against `failed`, for no reason either side
could point at. An adapter storing both kinds of run had to keep two
vocabularies straight, and a shared status column needed two sets of checks.
One type now covers both.

The client-facing resume-snapshot status is untouched
(`idle | running | complete | error`). That is a separate vocabulary with its own
`idle` state, mapped from the store status by `reconstructGeneration`, the same
way chat maps `RunStatus` to `ChatClientState`. Nothing on the wire moves, so the
stored client snapshot and the e2e fixtures are unaffected.

Also corrects the `GenerationRunRecord.threadId` docs, in the type and in the
guide. It was still described as an "optional link to the chat conversation that
triggered this generation", the framing that predates the slot semantics: it is
the stable app-chosen key `findLatestForThread` hydrates by, and
`withGenerationPersistence` requires it. Updates the ER diagram label, the store
reference, and the two skills that repeated the old wording.
…ware

Four streaming activities clobbered the caller's `threadId`:

    - (resolved) => runGenerateImage({ ...options, ...resolved })
    + (resolved) => runGenerateImage({ ...options, runId: resolved.runId })

`streamGenerationResult` mints a thread id for the `RUN_*` chunks when the caller
passes none, so spreading the resolved identity over the options overwrote a real
`threadId` with an id known to nobody. `generateImage`, `generateAudio`,
`generateSpeech`, and `generateTranscription` were all affected; `generateVideo`
already did this correctly and its comment explains why.

That bug is the only reason `withGenerationPersistence` required its own
`threadId`: middleware reading `ctx.threadId` on those four could not tell a
fabricated id from a real one. With the context trustworthy, the option becomes
an override. The scope resolves to `opts.threadId ?? ctx.threadId`, and a run
with neither throws a named error at `onStart` rather than being filed where
nothing can hydrate it from.

The redundancy was also a trap: passing different values to the activity and the
middleware split one slot in two, the wire using one id and the record filed
under the other.
`persistence` on the generation hooks is now a boolean. The storage-adapter mode
is gone, along with the read/write path behind it.

    - useGenerateImage({ threadId, connection, persistence: localStoragePersistence() })
    + useGenerateImage({ threadId, connection, persistence: true })

A generation is one job with one result, not a growing transcript, so a browser
copy of its record bought nothing the server record does not already provide and
cost a second source of truth to keep in step. The two modes also restored
differently: a client snapshot can never hold the generated bytes, so `result`
came back `null` from storage but whole from the server. One mode removes that
split.

Removed from `@tanstack/ai-client`: the `GenerationPersistence` type, the
`getItem`/`setItem`/`removeItem` path in `GenerationClient` and
`VideoGenerationClient` (about 11k characters between them), and the adapter arm
of `GenerationPersistenceOption`. `initialResumeSnapshot` stays, so an app that
wants to manage its own storage can still seed the client.

Tests that covered restore through storage now cover it through server hydration,
which is the mechanism that survives. Worth knowing for anyone reading those
diffs: `initialResumeSnapshot` seeds the snapshot but does not repaint
`status`/`result`; only a hydration path does. The two storage-only tests (reset
removes the persisted record) are deleted rather than converted, since they
asserted semantics that no longer exist.

The example app moves to `persistence: true` throughout, which meant giving
`/api/summarize` the server half it never had: it previously leaned entirely on
the client adapter for state and only had delivery durability.

`useChat` is untouched. It keeps both modes, and `localStoragePersistence` /
`sessionStoragePersistence` / `indexedDBPersistence` still work for
conversations.

Also folds the unreleased changesets that described the removed mode back to
what actually ships.
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