feat(persistence): generation run persistence (client + server) - #1011
Merged
Conversation
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.
…istence (no call-site generic)
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().
… + media-generation skills
…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.
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
Contributor
Pushed: mid-run reload resumability + persistent-generation demoThree commits land the generation-persistence resumability work and a demo page that exercises every surface. What's in:
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 Still TODO (not in this push)
|
… 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.
Wire withGenerationPersistence + hydrate/join handlers so Direct and Server Fn image paths save and restore artifacts like Streaming. Make threadId the single generation identity (id is never when threadId is set), and prefer threadId in TanStack AI Devtools labels.
`GenerationRunRecord.threadId` and `GenerationRunStore.createOrResume`'s
input now require it, and the `resumeState` cursor on both hydration
shapes narrows to `{ threadId: string; runId: string }`.
The optional field described a record no code path could produce and no
client would accept. `withGenerationPersistence` already refused to start
a run without a scope, so every record the library writes has one;
`findLatestForThread` — the only query that hydrates a generation — keys
on it, so a record without one could be written and then never read back;
and the client discarded any snapshot arriving without one.
That last disagreement was a silent failure. `runToSnapshot` legitimately
omitted `threadId` for a record that had none, and
`parseGenerationResumeSnapshot` responded by dropping the entire snapshot
— status, result and error along with the cursor — leaving a blank idle
panel with no diagnostic while the provider kept billing. Requiring the
field removes the disagreement by construction rather than patching one
side of it.
`findLatestForThread` was already required in the type; the adapter guide
still declared it optional and feature-detected, which was wrong twice
over since `getGenerationHydration` calls it unguarded. Corrected, along
with the skill passages calling `threadId` "only an optional link to a
chat" and a client-persistence sample that installed generation
persistence without ever passing a `threadId`.
The conformance suite now asserts `threadId` round-trips exactly and that
an idempotent `createOrResume` does not let a late scope overwrite the
one the run was filed under. The example SQLite adapter makes the column
NOT NULL to match.
… and hooks CI was red on four defects and two silent data-loss paths survived it. **Red on CI.** The generation run status rename landed in the source but the assertions were changed backwards, including in `src/testkit/conformance.ts`, which ships to third-party adapter authors and asserted the pre-rename value it had just written. JSDoc examples containing a dotted `handler(` call survive into `dist`, and TanStack Start's server-fn plugin selects modules by scanning code for exactly that, so importing `@tanstack/ai` pulled a non-React Start app into a transform that resolves a framework package it does not have — `ts-solid-chat` could not build. Two type tests still pinned an invariant a later commit deliberately reversed, and four call sites passed both `id` and `threadId`, which the options union forbids. **Not caught by CI.** A stream that ended without a terminal chunk left the client `generating` forever: no catch fired, `onError` never ran, and the snapshot stayed `running`, so the next reload rejoined the dead run and repeated. It now settles to an error and clears the cursor. Non-streaming `generateVideo` never applied its result transforms, so persistence marked a run `completed` with no result and no blob; the run now opens on provider acceptance and is completed by the poll that observes a terminal job state. **Video submit and poll are one run, correlated by the provider job id.** The run id derives from `provider` + `jobId` on both halves, so the poll finds the run the submit opened without the caller storing or threading anything, even from another process. `VideoJobResult.runId` is gone. Also: `summarize()` accepts generation middleware, so `persistence: true` is no longer a promise the server cannot keep; `resultTransforms` is required, since an absent array silently no-opped every registration; the artifact options no longer share a name with the chat options, which declaration-merged them into each other; `reconstructSpeechResult` is wired in all five frameworks rather than React alone; hydration failures are distinguishable from a genuine miss; and a declined reconstruction reports instead of painting a blank success. `onMount` never ran in the Solid test suite at all — vitest resolved solid-js's server build, where it is a no-op — so no Solid hook's mount path had ever been under test. Fixed, and Solid brought to parity with React and Vue. Restores `api.interrupts.test.ts`, deleted in an unrelated commit, which had left `resolveToolChoice` with no coverage anywhere.
tombeckenham
commented
Jul 31, 2026
tombeckenham
left a comment
Contributor
Author
There was a problem hiding this comment.
I've tested this thoroughly. Everything through to the skills themselves in other projects. It works beautifully.
4 tasks
This was referenced Jul 31, 2026
AlemTuzlak
added a commit
that referenced
this pull request
Jul 31, 2026
main squash-merged the two PRs this branch was stacked on (#988 sandbox instance durability, #1011 generation run persistence), so this branch's own copy of that foundation collided with the squashes. 17 files conflicted. Notable resolutions, beyond taking the union: - #1004 made `RunStore.findActiveRun` REQUIRED, and main added an explicit store-contract evolution policy naming that exact regression. This branch had relocated `RunStore` into `@tanstack/ai` with `findActiveRun?` optional, and `run-store.ts` merged CLEANLY -- so keeping our side would have silently reverted #1004. `findActiveRun` is now required in core too, dropped from the conformance suite's `skipMethods` union, and `fenceRunStore` forwards it unconditionally. `listByThread`/`listReclaimable` stay optional. - Generation persistence moved to main's `generationRuns` store, but main writes `status: 'interrupted'` with a `finishedAt` on abort. This branch made `interrupted` non-terminal ("parked, waiting for a human"), so that pairing would leave an aborted generation looking permanently active. Now writes `'aborted'`. - `snapshotStatus` in `reconstruct-generation.ts` switched exhaustively over the old 4-member `RunStatus`; ours adds `aborted`, so an aborted generation fell through and the function returned `undefined`. Now maps to `'error'`. - `chat-persistence.md`: kept main's new lifecycle mermaid diagram, corrected to the current semantics (completed/failed/aborted terminal, interrupted parked, detached stays running). - `docs/sandbox/durability.md`: kept our real `import` over main's `declare const`, per the repo's kiira snippet rule. Verified: 17 typechecks green (including examples/ts-react-chat and testing/e2e), oxlint green, kiira 911/911, test:docs, sherif, knip and oxfmt all clean. Unit: ai 1409, ai-persistence 150, ai-client 585, ai-react 176, ai-durable-stream 45, ai-sandbox 602/603 (pre-existing Windows path case). E2E not run: port 4010 is held by an unrelated showcase-aimock container and another worktree's in-flight Playwright run.
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🎯 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), withthreadIdan optional link — it no longer overloads the chatRunStore. On the client, the hook picks a mode exactly the wayuseChatdoes:Restore is invisible. There is no
resumeSnapshot/pendingArtifacts/resultArtifactshook field — a reload repaintsresult/status/erroras if the run had just finished.resumeStatecarries 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'sjoinRundurability replay — the same mechanismuseChatuses.Server
withGenerationPersistencerecords each run in a dedicatedgenerationRuns(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 ofreconstructChat: it reads?runId=(preferred) or the latest run linked to?threadId=, authorizes viaauthorize, and returns{ resumeSnapshot, activeRun }JSON for a server-authoritative client to hydrate from.artifacts(ArtifactStore) and ablobs(BlobStore) store and the middleware writes each generated file's bytes underartifacts/<runId>/<artifactId>, records anArtifactRecord, and attachesPersistedArtifactRefs to the result and the run record. The newartifactUrloption 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 sharedartifactBlobKey) serve the bytes back; extraction is customizable viaextractArtifacts/nameArtifact.memoryPersistence()ships in-memorygenerationRuns/artifacts/blobs;defineGenerationRunStore/defineArtifactStore/defineBlobStoretype a custom store inline the waydefineMessageStore/defineRunStorealready do.Client
Generation hooks (
useGenerateImage,useGenerateVideo,useGenerateAudio,useGenerateSpeech,useGeneration,useSummarize,useTranscription, and the Solid/Vue/Svelte/Angular equivalents) take the samepersistenceoption chat does. The option reuses theChatStorageAdaptercontract, solocalStoragePersistence/sessionStoragePersistence/indexedDBPersistencework for generations too with no type argument — a bare call is correct on either side. Untrusted snapshots are validated on read with the newparseGenerationResumeSnapshot.With byte storage configured, a restored
resultis rebuilt whole, its media resolved to the durable serve URL and its refs onresult.artifacts. Without it,status/errorrestore andresultstaysnull— a client snapshot never holds bytes.The base generation return types (
UseGenerationReturn/CreateGenerationReturn/InjectGenerationResult) also gained a defaultedTInputgeneric across all five frameworks, sogenerateis precisely typed(input: TInput) => Promise<void>. That deleted the unsound internal narrow-to-wide casts and every wrapper-levelgenerate ascast — twenty-five in total — with existing single-generic references unaffected.Packages
@tanstack/ai-persistence—GenerationRunStore,reconstructGeneration, the byte-storage half (ArtifactStore/BlobStore/retrieveArtifact/retrieveBlob/artifactUrl), and thedefine*store helpers.@tanstack/ai-client— the resume-snapshot reducer,parseGenerationResumeSnapshot, mount hydration (client store and serverGET), result reconstruction from refs.@tanstack/ai-utils—base64ToUint8Array.@tanstack/ai— the generation activities gainedthreadId/runIdoptions.Docs, skills, examples, tests
docs/persistence/generation-persistence.mdanddocs/persistence/keep-generated-files.md, plus updates acrossclient-persistence,overview,controls,build-your-own-adapter,internals, thedocs/media/*pages,docs/chat/streaming.mdanddocs/interrupts/overview.md(threads / runs / turns are now explained consistently). All kiira-verified.ai-core/client-persistence,ai-core/media-generation,ai-persistence, and a newai-persistence/build-cloudflare-artifact-store.examples/ts-react-chatwires generation persistence end to end (image + video via Grok Imagine).generation-persistence.spec.ts(client-driven: stream → reload → transparent restore → reset) andgeneration-persistence-server.spec.ts(server-driven:persistence: true, restore from a?threadId=GET,localStoragestays empty, no run auto-starts). Plus hydration/restore unit coverage in all five framework packages and the persistence package.✅ Checklist
pnpm run test:pr.🚀 Release Impact
Summary by CodeRabbit
threadId+ ephemeralrunId.resumeStateand correctly repaintstatus/result/errorwhen persistence is enabled.threadId, plus secure URL input fetching protections.threadId/runIdfor better correlation.