Commit 996a980
authored
feat(persistence): generation run persistence (client + server) (#1011)
* feat(persistence): client-side generation persistence
Layer a lightweight, read-only resume snapshot onto media generation.
As a run streams, the client builds a GenerationResumeSnapshot (run
identity, status, errors, result metadata + artifact refs — never media
bytes) and writes it to an optional GenerationServerPersistence store.
- ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot
reducer; GenerationClient/VideoGenerationClient observe chunks, persist
snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot();
disposed guard. No resume() action (stream re-attach is PR #955).
- ai-event-client: optional threadId/runId on generation events.
- react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot
options; expose resumeSnapshot/resumeState (+ pending/result artifacts).
- example: Persisted mode on the image generation route.
- docs: persistence/generation-persistence.md + nav entry.
Pairs with the existing withGenerationPersistence server middleware.
* docs(persistence): drop generic from generation snapshot store example
* refactor(persistence): align generation persistence API with chat
Drop the bespoke `GenerationServerPersistence` type and the `{ server }`
option wrapper. The `persistence` option is now a bare storage adapter
reusing the shared `ChatStorageAdapter` contract (aliased as
`GenerationPersistence`), so `localStoragePersistence` /
`sessionStoragePersistence` / `indexedDBPersistence` work for generations
exactly as they do for chat — matching main's ergonomics.
* refactor(persistence): infer generation store type via GenerationPersistence (no call-site generic)
* refactor(persistence): value-agnostic web-storage adapter defaults
Default `localStoragePersistence` / `sessionStoragePersistence` /
`indexedDBPersistence` to a value-agnostic `TValue` so a bare, unannotated
call works for BOTH chat and generation persistence — the consuming
`persistence` option constrains the stored value. Generation docs/example now
use `localStoragePersistence({ keyPrefix })` with no type declaration.
* docs(persistence): fix stale generation-persistence delivery guidance
PR #955 (resumable streams) is merged, so delivery durability is available
today — it was wrongly described as an unlanded future feature. Rewrite the
generation-persistence doc: the server example now wires a durability adapter
+ GET handler, and the delivery section explains that a dropped mid-generation
connection re-attaches through the same adapters useChat uses. Clarify that the
read-only snapshot carries run state (incl. runId) across reloads, while
hooks do not auto-resume on mount.
* docs(persistence): rewrite generation-persistence for clarity + when-to-use
* docs(persistence): drop redundant storage-adapter comment
* fix(persistence): generation snapshot lifecycle, hydration, StrictMode revival
- kiira: replace phantom @tanstack/ai-persistence-drizzle import with the
hand-rolled adapter from build-your-own-adapter (CI was red on this)
- hydrate the resume snapshot from persistence.getItem on construction,
validated via new parseGenerationResumeSnapshot(unknown) export;
initialResumeSnapshot seed takes precedence
- namespace storage keys as generation:<id> so chat and generation clients
sharing an id and adapter no longer collide
- write terminal snapshots on stop() (idle) and transport-level errors
(error); reset() clears memory + removeItem; RUN_STARTED drops stale
result/error/pendingArtifacts from the previous run; plain-fetcher runs
now record a complete snapshot built from the fetcher result
- capture video jobId into the snapshot from video:job:created
- add schemaVersion: 1 to persisted snapshots
- gate persistence writes on material change (ignore lastEvent-only churn),
warn once per failure transition, clear resumePersistenceError on success
- mountDevtools() revives a disposed client (React StrictMode replay);
generate() checks disposed before mounting devtools
- onResumeSnapshotChange now receives undefined when reset() clears
- fix mojibake em dashes in 12 hook files
* fix(persistence): docs, example, changeset, React hooks, and real test coverage
- rewrite docs/persistence/generation-persistence.md around the implemented
behavior: hydration on mount, generation:<id> keys, resumeState vs
resumeSnapshot semantics, honest reconnect story, no media-URL claim;
drop the inert threadId/runId spreads from the server sample
- fix the example's Persisted panel: distinguish in-flight run from last-run
outcome; reload now actually shows the persisted record
- revert ai-event-client: BaseEventContext already carries threadId/runId,
the 36 added lines were redundant redeclarations; changeset no longer
bumps that package and now describes hydration + lifecycle accurately
- normalize wrong hook JSDoc (Server-side → client-side storage; read-only
seed claims; run/cursor wording) and mark artifact fields dormant
- React hooks: post-dispose guards on callbacks/setters, StrictMode revive
via mount effect, stable empty artifact arrays, re-export persistence
types (+ PersistedArtifactRef)
- tests: replace the two vacuous reducer tests with real externalUrl
positive/negative and stop coverage; add reducer seed-merge, RUN_STARTED
stale-field-drop, video jobId capture, parseGenerationResumeSnapshot
suite; add client lifecycle suite (hydration, seed precedence, corrupt
storage, stop/reset/transport-error, write gating, StrictMode revive);
add React hydration/StrictMode/artifact-exposure hook tests
* test(persistence): E2E reload-and-rehydrate spec for generation snapshots
Provider-free harness (api.generation-persistence streams a fixed AG-UI
sequence; aimock-exempt) + page using useGenerateImage with
localStoragePersistence. Proves: snapshot written under
tanstack-ai:generation:<id> with no media bytes, hydrated after reload with
no auto-run, and removed by reset().
* docs(skills): cover generation resume snapshots in client-persistence + media-generation skills
* fix(persistence): framework sweeps for Solid/Vue/Svelte/Angular + hydration ordering
- solid: build the client outside reactive tracking (untrack) — the old
createMemo second-arg was a seed, not deps, so option reads were tracked
and a change orphaned an undisposed client; stable empty artifact arrays
- svelte: explicit generate() now revives a disposed client (mountDevtools)
since Svelte has no remount effect; reactive bindings revive with it
- vue: stable empty artifact array constants (shallowRef identity)
- angular: JSDoc for persistence/initialResumeSnapshot on inject-generate-video
- all four: re-export GenerationPersistence/GenerationResumeSnapshot/
GenerationResumeState/GenerationResumeStatus/GenerationPendingArtifact +
PersistedArtifactRef from package index; hydration + reset()/removeItem
tests against Map-backed adapters
- ai-client: kick off snapshot hydration only after callbacksRef is
assigned (removes a sync-adapter ordering hazard)
* ci: apply automated fixes
* refactor(ai-react): thread TInput through UseGenerationReturn, drop generate casts
UseGenerationReturn gains a defaulted second generic
(TInput extends Record<string, any> = Record<string, any>) so generate is
typed (input: TInput) => Promise<void>. useGeneration returns the type it
actually builds — the unsound internal narrow-to-wide cast and the five
wrapper-level casts back down to the concrete input type all disappear,
and direct useGeneration consumers get a precisely typed generate.
Existing UseGenerationReturn<MyOutput> references keep compiling via the
default.
* refactor: thread TInput through generation return types in solid/vue/svelte/angular
Same fix as fbc3dc3 for the remaining four frameworks: the base return
interface (UseGenerationReturn / CreateGenerationReturn /
InjectGenerationResult) gains a defaulted second generic
(TInput extends Record<string, any> = Record<string, any>) so generate is
typed (input: TInput) => Promise<void>. The base hooks return the type
they actually build and the internal narrow-to-wide casts plus every
wrapper-level 'generate as' cast are deleted. Video hooks were already
cast-free (they build their own client). Defaults keep existing
single-generic references compiling.
* refactor(persistence): restore typed storage-adapter defaults (drop TValue = any)
Revert the web-storage factory defaults to TValue = ChatPersistedState, as
shipped in #984. The any default erased type safety on every direct
adapter use (getItem returned any; a store built for one domain assigned
silently to the other's hook) and carried three oxlint suppressions —
while buying nothing for inline usage, where contextual typing infers the
value type from the persistence option regardless of the default. The one
affected pattern, a standalone store for generations, now states its type:
localStoragePersistence<GenerationResumeSnapshot>(). Doc, example, and e2e
call sites updated; runtime behavior unchanged.
* feat(persistence): durable generation media-byte storage
Layer server-side artifact + blob storage onto the client generation snapshot.
When the persistence backend provides both an artifacts (ArtifactStore) and a
blobs (BlobStore) store, withGenerationPersistence writes each generated file's
bytes to the blob store (key artifacts/<runId>/<artifactId>), records an
ArtifactRecord, attaches PersistedArtifactRefs to the result, and emits
generation:artifacts (which the client reducer already consumes).
- @tanstack/ai: result-transform machinery (resultTransforms/artifactInputs on
GenerationMiddlewareContext, applyGenerationResultTransforms), threadId/runId
on the image/audio/speech/transcription activities, generation:artifacts
emission from streamGenerationResult.
- @tanstack/ai-utils: base64ToUint8Array.
- @tanstack/ai-persistence: ArtifactStore + BlobStore contracts + in-memory
impls in memoryPersistence(); byte persistence in withGenerationPersistence
(extractArtifacts/nameArtifact); retrieveArtifact/retrieveBlob/artifactBlobKey
serve helpers.
- @tanstack/ai-event-client: optional threadId/runId on generation events.
- docs + changeset updated for byte storage.
* feat(persistence): two-mode generation persistence + GenerationJobStore
Give media generation the same two persistence modes useChat has, driven
by the `persistence` option:
- server-driven (`persistence: true` + a stable `threadId`): the client
keeps no local store and hydrates the last generation job from the
server on mount via a read-only `hydrateGeneration` GET, answered by the
new `reconstructGeneration` helper.
- client-driven (a storage adapter): unchanged.
Server: reshape `withGenerationPersistence` off the flagged stopgap that
faked `threadId = requestId` on the chat RunStore onto a dedicated
`GenerationJobStore` keyed by `jobId` (threadId only an optional link).
Add `defineGenerationJobStore` / `defineArtifactStore` / `defineBlobStore`
and `reconstructGeneration`; durable byte storage (artifacts + blobs)
stays an optional layer on top.
Client: widen `persistence` to `boolean | adapter`, add `threadId`, and
thread both through every generation hook across react/solid/vue/svelte/
angular. `hydrateFromServer` validates the untrusted server snapshot and
only adopts it when nothing was observed locally first; a live generate()
always wins and no run is ever auto-started.
Docs (two modes + BYO job/artifact/blob stores), a Cloudflare R2
artifact/blob skill, unit tests, and a server-driven e2e spec included.
* ci: apply automated fixes
* docs(persistence): route readers to generation persistence + split byte storage
Generation persistence shipped, but nothing pointed readers to it. Fix the
discovery paths:
- Split "keep the generated files" out of generation-persistence into its own
Keep Generated Files page (server-only byte storage is a distinct journey).
- Point the media docs at it: a callout on the generation-hooks hub and
video-generation (minutes-long runs), lighter pointers on image/audio/
transcription.
- Give the persistence overview a Generation persistence sibling section, add
the jobs/artifacts/blobs stores to the store-contract table, and link the
generation pages from "Where to go next".
- Note in client-persistence that generation hooks share the same
true/adapter modes.
* refactor(generation-persistence): restore transparently into the normal hook fields
Generation persistence exposed a bolt-on client surface: `resumeSnapshot`,
`resumeState`, `pendingArtifacts`, `resultArtifacts`, and on restore it
repainted only `resumeSnapshot`, leaving `result`/`status`/`error` idle. Make
it invisible like chat, which restores straight into `messages`.
Client (@tanstack/ai-client + 5 frameworks):
- Hooks now return only `generate`, `result`, `isLoading`, `error`, `status`,
`stop`, `reset`, `resumeState`. `resumeSnapshot` / `pendingArtifacts` /
`resultArtifacts` are gone; final artifact refs live on `result.artifacts`,
in-flight ones on `resumeState.pendingArtifacts`.
- On restore (client store or server hydrate) the client repaints
`result` / `status` / `error` and emits `resumeState`, so a reload looks like
a just-finished run. A per-activity `reconstructResult` mapper (image / audio
/ transcription / summarize; video built into the video client) rebuilds a
typed result, with media resolved to the durable serve URL. Live `generate()`
still wins over a slow restore; no run is auto-started.
- `localStoragePersistence()` / `sessionStoragePersistence()` /
`indexedDBPersistence()` now work on a generation hook with no type argument.
Server (@tanstack/ai + @tanstack/ai-persistence):
- `PersistedArtifactRef.url` (durable app-origin serve URL). New
`withGenerationPersistence({ artifactUrl })` stamps it onto each ref and
rewrites the live result's media URL to it, so live and restored results both
render media from your own origin, not the provider's expiring link.
- Text results (transcription / summarize) persist their text + usage so they
restore too.
Docs, skills, the example, and both e2e specs updated to the transparent
surface; the e2e now asserts the restored image renders from the durable URL.
* ci: apply automated fixes
* docs(persistence): slim the generation page, move advanced material to its own page
The generation-persistence page had grown to cover everything: the two modes,
reconnecting a live stream, resumeState semantics, seeding state, securing the
hydration endpoint, and the record internals. Keep the main page a focused
two-mode quickstart (choose a mode, server-driven, client-driven) and move the
deeper material to a new "Generation Persistence: Advanced" page.
* feat(generation-persistence): rejoin an in-flight run on mount (useChat parity)
When a generation run was still streaming at reload, the client only repainted
the record; it did not re-attach to the live stream. Now it does, mirroring
useChat: on mount, when hydration reports a run still generating, the client
tails it through the durability log and finishes it in place.
- Expose the connection's `joinRun` on the generation `ConnectConnectionAdapter`
(the SSE/HTTP adapters already implement it for chat).
- `rejoinInFlight(runId)` in the generation + video clients, reusing
`processStream`. Triggered from the server hydrate's `activeRun` and from a
client-driven `running` snapshot's `resumeState.runId`. A live `generate()`
wins; each run rejoins once; the loading/abort reset is guarded so a
stop-then-generate race can't clear a fresh run's loading flag.
- Docs: drop the "cannot re-attach on reload" caveat; the main page now states a
dropped connection or reload rejoins automatically.
* ci: apply automated fixes
* docs(persistence): remove the generation-persistence advanced page
Its reconnect section became false once in-flight runs rejoin automatically, and
the rest (resumeState, seeding, record internals) is already covered on the main
page. Fold the one load-bearing bit — the reconstructGeneration `authorize`
tenancy note — inline into the server example and drop the page + its nav entry.
* feat(examples): shared generation run history + fix stale generation-persistence docs
Example app: every generation route now wires its hook through
`generationRunPersistence()`, which delegates to `localStoragePersistence()`
and layers a shared run-history list on top of the storage-adapter seam. The
new `GenerationRunHistory` component renders that list, so each page shows its
previous runs — run history is an app concern, and the adapter seam is where
you build it.
Docs/comments: correct three stale claims that predate the dedicated
`GenerationJobStore`.
- `internals.md` still said generation "reuses chat `RunStore` and dual-keys
`(runId, threadId)` both to `requestId`" as a stopgap, and called artifact
persistence a follow-up. Both shipped; replaced with what the middleware
actually does and how the optional `threadId` link works.
- `controls.md` and `internals.md` both listed `withGenerationPersistence` as
requiring `runs`; it requires `jobs`.
- `RunRecord`'s JSDoc glossed a run as "one agent turn within a conversation",
contradicting every other use of "turn" in the package. A run is one
AG-UI `RUN_STARTED` → `RUN_FINISHED` cycle: it contains many agent-loop
turns, and one user turn may span several runs across interrupt-resume.
* refactor(persistence): rename generation job to run (GenerationRunStore, runId, providerJobId)
One generation id previously wore three names: minted as runId on the wire
(AG-UI), stored as jobId in the generation store, and handed back as runId on
hydration. 'jobId' also collided with the provider's async video job handle
sitting one field away in the same snapshot. Converge on 'run' for the AG-UI
id and reserve 'job' for provider async jobs:
- GenerationJobStore/Record/Status -> GenerationRunStore/Record/Status;
defineGenerationJobStore -> defineGenerationRunStore; record field
jobId -> runId (matches chat's RunStore/RunRecord.runId)
- stores.jobs -> stores.generationRuns (bundle key, validators, memory store)
- reconstructGeneration reads ?runId= (option jobParam -> runParam)
- GenerationResultSnapshot.jobId -> providerJobId (ditto
GenerationRestoredResult); parser accepts both spellings since live
provider results still carry jobId
- provider surfaces unchanged: VideoGenerateResult.jobId, getVideoJobStatus,
useGenerateVideo jobId state, PersistedArtifactRef.source.jobId,
video:job:created payload
- docs (6 persistence pages + config dates), 4 skills, changeset updated
All unreleased surface (none of it is on main), so no migration needed.
* docs: explain threads, runs, and turns across streaming, interrupts, and persistence
Add a 'Threads, runs, and turns' section to the streaming guide defining
threadId vs runId and why a turn can span multiple runs, then cross-link
it from interrupts, resumable streams, and the persistence docs. Add
mermaid diagrams for the run/interrupt/generation state lifecycles, the
persistence ER schema, and the reconnect sequences.
* docs: narrow streaming guide to threads and runs, cross-link from persistence
Rename the streaming section to 'Threads and runs': just the two id
definitions, a note that tool calls stream inside the same run, and a
mermaid diagram of one thread with three runs. Update the inbound links
from interrupts, resumable streams, and the persistence docs to the new
anchor.
* refactor(examples): drop generation run history, switch image/video to Grok Imagine
Each generation page now shows only its last run, restored from the
shared localStorage snapshot adapter (lib/generation-persistence.ts) —
the shared history list, GenerationRunHistory component, and
label/preview recording are gone.
Image and video generation move from OpenAI (gpt-image-1, sora-2) to
xAI Grok Imagine (grok-imagine-image, grok-imagine-video) in the API
routes and server functions.
* ci: apply automated fixes
* fix(persistence): don't fetch caller-supplied prompt URLs + review fixes
Byte storage had one fetch path serving two purposes: `descriptorBody`
branched on `descriptor.url` alone and never looked at `descriptor.role`,
so a prompt part with `source: { type: 'url' }` was fetched server-side and
stored, readable back through the artifact GET route. Fetching an expiring
provider result URL is the point of the feature; mirroring a caller-supplied
URL is not, and the bytes are redundant since the client already had them.
Input URLs are no longer fetched. Opting back in is `allowInputUrl`, a
predicate rather than a boolean so the check can't be skipped. Every artifact
fetch is now http/https-only, timed out (`artifactFetchTimeoutMs`) and
size-capped during the drain (`maxArtifactBytes`); input fetches also block
loopback/private/link-local hosts and refuse redirects. Output fetches skip
the host block on purpose — a self-hosted provider legitimately returns a
localhost URL. `artifactFetch` injects the fetch for egress-proxy routing.
Also from review:
- gate `emitResumeState` on a signature, so a per-chunk snapshot rebuild no
longer re-renders every framework hook on every stream event
- guard an invalid Date before `toISOString()` in the resume snapshot reducer
- fall back to the literal payload when a data URL has a bad percent escape
- treat a non-object hydration body as a miss instead of reading `.activeRun`
off null
- docs: authorize artifact reads by `ArtifactRecord.threadId` (404, not 403),
drop auto-resume language for snapshot hydration, add the Mode B server
snippet, honour `limit: 0` in the R2 sample
* refactor(persistence): rename artifact externalUrl to sourceUrl
`externalUrl` sat directly above `url` on `PersistedArtifactRef` and read
backwards: `externalUrl` is the provider's original expiring link, kept for
provenance, while the plain `url` is the durable app-origin URL that actually
serves the bytes publicly. The field named "external" was the internal one.
`sourceUrl` says what it is — where the bytes came from. It also covers the
case `providerUrl` would miss: with `allowInputUrl`, an input artifact's
source is a caller-supplied URL, not a provider's.
Straight rename, no alias: `PersistedArtifactRef` is not in the published
@tanstack/ai@0.42.0, so nothing downstream can be depending on the old name.
* feat(generation-persistence): require threadId when persistence is on
`threadId` was introduced as an optional "link to the chat conversation that
triggered this generation". It is not that — it is the generation's own scope,
the stable slot successive runs are filed under, and a workflow generating (say)
a video's start frame has no conversation anywhere near it.
Presenting it as optional produced three concrete defects:
- The fallback chain `threadId ?? id ?? generated` ends in Date.now()+random,
rebuilt on every construction. With neither supplied, client-driven wrote a
new localStorage key every reload (restoring nothing, orphaning the last one)
and server-driven asked for a threadId that had never existed. Both failed
silently.
- The two modes keyed on DIFFERENT values — client-driven on `id`, server-driven
on `threadId` — so `id: 'a'` + `threadId: 'b'` wrote slot a and read slot b.
- `id` did double duty as devtools label and persistence key, so relabelling in
devtools silently relocated persisted data.
`threadId` is now required whenever `persistence` is set, via a union
(`GenerationPersistenceOptions`) intersected onto each hook's parameter. It stays
optional for ephemeral generations, so the published no-persistence signature is
untouched — adding an unconditional required option would have broken every
existing call site.
Persistence now keys on the explicit `threadId` in both modes. The `?? id`
fallback survives only for the AG-UI wire thread id, which the protocol requires
even when nothing is persisted; a runtime warning covers JS callers who bypass
the type.
The union is fragile in one specific way — a plain `Omit` over it collapses the
union and the requirement silently disappears — so the options interfaces stay
non-union (keeping Pick/Omit composition working in vue/solid/svelte/angular)
and `use-generation-persistence-types.test.ts` pins the behaviour.
* fix(persistence): fail loudly when a threadId lookup needs findLatestForThread
`findLatestForThread` is optional on GenerationRunStore and was called through
`?.`, so an adapter that does not implement it produced `undefined ?? null` —
indistinguishable from an ordinary 'no run found'. A server-driven client would
therefore restore nothing, forever, with no error anywhere to explain why.
Throw instead, and only on the path that actually needs the method: an explicit
`?runId=` lookup never calls it and keeps working on a minimal adapter.
* feat(persistence): storageKey for blob paths, and blobKey on the record
Generated bytes were written to a hardcoded `artifacts/<runId>/<artifactId>`
with no way to influence it, so "keep my generated files in my own R2 folder
structure" was not expressible. `withGenerationPersistence` now takes a
`storageKey` mapper receiving the artifact's identity, role, activity, mime type
and resolved name.
Server-side only, deliberately: a key supplied by the browser would be a
path-traversal and cross-tenant-write vector, the same class as the two issues
already fixed on this branch.
This forces a companion change. `retrieveBlob` RECOMPUTED the path from runId +
artifactId, which only works while the derivation is a fixed constant — the
moment it is user-supplied the read looks in the wrong place. The resolved key is
therefore recorded on the new `ArtifactRecord.blobKey`, and reads go through
`resolveArtifactBlobKey`, which falls back to the old convention for records
written before the field existed. That fallback is what makes this a
non-breaking addition, and also why the default convention can never be changed
retroactively.
Worth having independently of `storageKey`: with the key recomputed rather than
remembered, the default convention was effectively frozen forever — changing
`artifactBlobKey` would have orphaned every blob already written.
Also threads the required `threadId` through the docs, skills, E2E harness and
example call sites, and documents both new capabilities in the changeset.
* feat(persistence): server-side generation persistence in the example; require store methods
The example demonstrated only the client-driven half of generation persistence.
`withGenerationPersistence` and `reconstructGeneration` had never been run
against each other over HTTP anywhere — each was unit-tested in isolation, and
the e2e harness deliberately hand-builds the hydration JSON rather than pull in
`@tanstack/ai-persistence`. That left the join between them, which this branch
just changed the key of, as the least-covered part of the feature.
`/api/generate/image` now runs the real thing: `withGenerationPersistence` with
byte storage and an `artifactUrl`, plus a GET that serves artifact bytes by id
or answers mount hydration. The Streaming variant switches to `persistence:
true`; Direct and Server Fn keep the client adapter because server functions
have no GET path for server-driven restore to use.
Make three store methods required, per this file's own evolution policy:
- `GenerationRunStore.findLatestForThread` was optional and feature-detected —
the exact anti-pattern the policy documents, and the exact bug it records
`findActiveRun` causing for a release cycle. Server-driven hydration calls it
on every mount, so an adapter without it was indistinguishable from a thread
with no runs: `persistence: true` silently restored nothing, forever. The
runtime guard added earlier on this branch is deleted — the compiler enforces
it now, and the cases those tests covered are unrepresentable.
- `ArtifactStore.delete` / `deleteForRun` were optional while their pair
`BlobStore.delete` is required, so a backend could drop the bytes but keep the
record. An app calling `stores.artifacts.delete?.(id)` for an erasure request
would silently no-op.
Also documents `blobKey` in the adapter guide's reference record and ER diagram,
where `ARTIFACT ||--|| BLOB` was a derived convention and is now a real key, and
deletes `api.interrupts.test.ts` — 19 assertions no runner has ever executed
(the example's vitest config scopes to `src/lib/**`), which also cost a
route-scanner warning on every dev start.
* fix(example): keep generation persistence across HMR re-evaluation
The module-level `memoryPersistence()` was rebuilt every time Vite
re-evaluated this module, so any artifact URL already stamped into a rendered
result 404'd on the next file save — the image broke even though its b64Json
was still present, because the UI prefers `img.url`. Stash the instance on
globalThis so one dev session keeps one store.
* feat(persistence): sqlite generation stores + conformance coverage
Finish the example's `node:sqlite` adapter for generations: the schema and
row types were in place, the store implementations were not.
- `GenerationRunStore`: idempotent `createOrResume` via ON CONFLICT DO
NOTHING, dynamic-SET `update` over the JSON columns, `findLatestForThread`
on the (thread_id, started_at DESC) index.
- `ArtifactStore`: upsert `save` persisting `blobKey`/`sourceUrl`, run-scoped
`list` / `deleteForRun`.
- `BlobStore`: bytes in a BLOB column, keyset-cursor `list`. Prefix matching
uses `substr(key, 1, length(?)) = ?` rather than LIKE — SQLite's LIKE is
case-insensitive for ASCII and treats %/_ as wildcards, both of which break
the contract's literal, case-sensitive prefix rule.
The factory returns a fully-spelled seven-store `AIPersistence`, so one
instance backs both `withPersistence` and `withGenerationPersistence`, and
the example's generation route now runs on it instead of `memoryPersistence()`
— generated images survive a dev-server restart, which is what the reverted
HMR workaround was standing in for.
Extend `runPersistenceConformance` to `generationRuns` / `artifacts` / `blobs`
so the generation half is held to the same gate as chat. Because the suite
fails loudly on an undeclared missing store, a chat-only adapter now passes
`skip: ['generationRuns', 'artifacts', 'blobs']`; the adapter-building skills
and the build-your-own-adapter guide are updated to match.
Also fixes the pre-`blobKey` artifact schema still shown in the docs and the
Cloudflare artifact-store skill (`external_url`, no `blob_key`) — copying it
made any artifact written with a custom `storageKey` unreadable, since the
key can no longer be recomputed.
* feat(example): server-side generation persistence on every activity
Image was the only route running `withGenerationPersistence`; video, audio,
speech and transcription streamed straight through, so their media lived only
at the provider's expiring URL and a restored run had nothing to render.
All five now persist. Bytes are served by ONE shared route — `/api/artifacts`
— instead of a per-route `?artifact=` branch: artifacts are addressed by id
and carry their own `mimeType`, so nothing about serving them is
activity-specific, and the authorization check a real deployment needs lives
in one place. `artifactServeUrl` points there and every route passes it as
`artifactUrl`, so results are rewritten to our origin.
The image route's GET is now purely `reconstructGeneration` mount hydration.
Audio/speech/transcription keep their zod validation and typed 400s; they gain
`generationParamsFromBody` to lift `threadId` / `runId` off the AG-UI envelope
so runs are filed under the scope the client hydrates by. Video reads its
adapter arguments off `data` as before — `size`/`model` are adapter-specific
unions the provider-agnostic video input widens to `string` — and uses the
helper for identity only.
Transcription produces text, not media: what it persists is the run record
plus the input audio artifact.
* fix(example): make generation routes resumable so a refresh can rejoin
Refreshing mid-generation surfaced "Stream response body read failed".
Resumability is automatic on the CLIENT and opt-in on the SERVER. On mount the
client re-attaches to a run it believes is still going by issuing
`GET <route>?offset=-1&runId=…`. None of the generation routes had a GET, so
Start's catch-all answered with the SPA's HTML shell, which the client then
failed to parse as SSE — surfacing a raw transport error (StreamReadError) in
place of anything actionable.
Every streaming generation route now opts in, per the resumable-streams guide:
chunks are logged and id-tagged through `memoryStream` on the response, and a
GET replays the log. An unknown or aged-out run now answers with a RUN_ERROR
event on a real `text/event-stream` instead of HTML.
Video additionally detaches its run from the request (`startDetachedGeneration`
in the new lib/generation-durability), so a reload cannot kill a multi-minute
job — the producer keeps going and the reader is what gets cancelled. That is
the persistent-chat route's policy and it is deliberately NOT applied to the
short activities: a detached run keeps billing after the user leaves, and an
image or a speech clip is cheaper to re-run than to keep alive.
The image GET now serves two jobs in order, like the chat route: delivery
replay when the request carries a resume offset, otherwise `reconstructGeneration`
mount hydration.
* fix(example): let nitro's dev middleware serve /api to subresources
`nitro/dist/_build/vite.dev.mjs` classifies a request as a static asset from
`Sec-Fetch-Dest`: anything that isn't `document`/`iframe`/`frame` falls through
to vite's static middleware, which has no file and 404s with connect's
`Cannot GET` page. The extension branch only applies when the header is absent
or `empty`, so renaming the route doesn't help.
That makes every artifact URL unloadable in dev: `<img src="/api/artifacts?id=…">`
sends `Sec-Fetch-Dest: image` and 404s, while the same URL fetched from JS
(`empty`) returns the bytes. It only bites routes served under Start's
catch-all `/**`, which is all of them here.
A pre-plugin presents `empty` for our own `/api/` paths, routing them back to
the server without changing what the browser sends. Dev-only — this middleware
does not exist in a production build.
* feat(persistence): server-driven generation persistence over server functions
Server-driven persistence (`persistence: true`) previously required an HTTP
endpoint, because the hydrate/rejoin handlers lived on the connection adapter
and only the fetch/XHR adapters implemented them. A TanStack Start server
function had no way to participate, so `persistence: true` silently restored
nothing there.
Persistence handlers are now supplied independently of the transport:
- `stream()` takes an optional second argument of `{ hydrate,
hydrateGeneration, joinRun }`, spread onto the adapter.
- The generation client accepts `hydrateGeneration` / `joinRun` as options,
used when the connection carries none. The connection's handlers win when
both exist, and `persistence: true` with no handler from either source warns
instead of silently no-opping.
- `memoryStream` accepts an explicit `{ runId, offset }` alongside a `Request`,
and the new `replayRunStream` replays a run's delivery log as a bare chunk
stream — what a server function needs to serve `joinRun` without an HTTP
`Response`.
A restored snapshot that reports a run still in flight is now repainted through
one path: tail it via `joinRun` when a handler exists, otherwise repaint it as
an interrupted error rather than a `generating` status that would never settle.
The generation hooks across React, Solid, Vue, Svelte and Angular forward the
new options.
* Merge remote-tracking branch 'origin/main' into feat/generation-persistence-full
* fix(ai): apply result transforms and carry identity in streaming generateVideo
A persisted video restored as nothing on reload. The run record showed
`status: 'complete'` and nothing else — no result metadata, no artifact refs,
no stored bytes, and `thread_id` NULL.
Streaming video was the only media activity that never called
`applyGenerationResultTransforms`, and never put the caller's `threadId` /
`runId` on the middleware context. `withGenerationPersistence` registers BOTH
its artifact capture and its run-record `result` write as result transforms,
pushed onto an OPTIONAL `ctx.resultTransforms` — so both silently no-opped,
and the run was filed under the internal `requestId` with no thread link. The
client rebuilds a restored video from an output artifact carrying a durable
url, found none, and restored nothing.
Video now applies the transforms to its terminal result before yielding it, so
the `generation:result` chunk and the stored record carry the same urls
(including the app-origin one `artifactUrl` stamps), and passes `threadId` /
`runId` / `artifactInputs` into the context like `generateImage`.
`threadId` is now a documented option on `generateVideo`. It previously had
none, so callers passing one through an object spread type-checked and were
silently ignored — which is how the example's route looked correct while
recording NULL. When omitted, an id is still minted for the RUN_* wire chunks,
but the middleware context gets `undefined` instead: a fabricated thread id is
a slot no client can hydrate by, which is worse than no link at all.
Both regression tests fail against the previous behaviour.
* feat(persistence)!: require threadId on withGenerationPersistence
The client hooks require `threadId` whenever `persistence` is set; the server
middleware did not. That asymmetry hid a class of silent failure: a run filed
under no scope cannot be hydrated by one, so `persistence: true` restored
nothing, forever, with no error to explain why. The example's video route hit
exactly this — its runs recorded `thread_id: NULL`.
`withGenerationPersistence(persistence, { threadId, ... })` now takes a
required `threadId` via the new `WithGenerationPersistenceOptions`, mirroring
the client's discriminated union.
The option is also the AUTHORITY for the run record's and artifacts' scope, in
preference to `ctx.threadId`. An activity mints a throwaway thread id for its
RUN_* wire chunks when the caller passes none, and persisting that fabricated
id filed runs in a slot nothing could look up — worse than recording no link,
because it looks like one. A test that asserted the old fallback (wire id ==
persisted id) now asserts they deliberately diverge.
Call sites updated across the example routes, docs and skills. The example
routes reject a request carrying no `threadId` with a 400 rather than inventing
one, which is the pattern the docs now show.
Note: `docs/persistence/generation-persistence.md` has one remaining kiira
failure in the `getImageHydrationFn` snippet (a `ReconstructedGeneration` /
Start `ServerFn` return-type mismatch). It predates this commit — verified by
stashing these changes — and is left alone.
* fix(generation): make runs survive client disconnect and resume mid-run
Durability decouples the producer from the HTTP response so a durable run keeps draining to the log after a reload; RUN_STARTED flushes immediately so one-shot activities are resumable from the start; summarize threads runId through chat (openai-base honors options.runId) so its delivery log aligns with the client's rejoin; TTS restores via reconstructSpeechResult; a failed rejoin settles to error instead of stuck-generating; dispose keeps the run resumable; OpenAI reasoning models drop unsupported temperature/top_p.
Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh
* feat(example): persistent-generation route; rely on library durability
New /generations/persistent-generation page wiring all six generation hooks (server-driven for the five media, client-driven for summarize). Server routes now fall back to reconstructGeneration on GET, and the video route drops the hand-rolled startDetachedGeneration/tailGenerationResponse in favor of the plain toServerSentEventsResponse(stream, { durability }) path now that the library owns run lifetime. Summarize route adds delivery durability + a resume GET and threads runId.
Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh1 parent f5c43d8 commit 996a980
220 files changed
Lines changed: 20349 additions & 1336 deletions
File tree
- .changeset
- docs
- adapters
- advanced
- api
- chat
- interrupts
- media
- persistence
- resumable-streams
- examples/ts-react-chat
- src
- components
- lib
- routes
- packages
- ai-angular
- src
- tests
- ai-client
- src
- tests
- ai-devtools
- src/components/hooks
- tests
- ai-event-client/src
- ai-openai
- src
- adapters
- tests
- ai-persistence
- skills/ai-persistence
- build-cloudflare-adapter
- build-cloudflare-artifact-store
- build-custom-adapter
- build-drizzle-adapter
- build-prisma-adapter
- stores
- src
- testkit
- tests
- ai-preact/src
- ai-react
- src
- tests
- ai-solid
- src
- tests
- ai-svelte
- src
- tests
- ai-utils/src
- ai-vue
- src
- tests
- ai
- skills/ai-core
- client-persistence
- media-generation
- src
- activities
- generateAudio
- generateImage
- generateSpeech
- generateTranscription
- generateVideo
- middleware
- summarize
- middlewares
- tests
- middlewares
- openai-base/src/adapters
- testing/e2e
- src
- routes
- $provider
- tests
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
0 commit comments