From b3fe626970b78a1ed47ce8409c0275419734ded9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 13:24:55 +0200 Subject: [PATCH 01/66] feat(persistence): client-side generation persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/generation-persistence.md | 15 + docs/config.json | 5 + docs/persistence/generation-persistence.md | 121 +++++ .../src/routes/generations.image.tsx | 87 +++- .../ai-angular/src/inject-generate-audio.ts | 43 +- .../ai-angular/src/inject-generate-image.ts | 35 +- .../ai-angular/src/inject-generate-speech.ts | 36 +- .../ai-angular/src/inject-generate-video.ts | 107 ++++- packages/ai-angular/src/inject-generation.ts | 97 +++- packages/ai-angular/src/inject-summarize.ts | 37 +- .../ai-angular/src/inject-transcription.ts | 39 +- .../tests/inject-generation.test.ts | 104 +++++ packages/ai-client/src/generation-client.ts | 122 ++++- packages/ai-client/src/generation-types.ts | 351 ++++++++++++++- packages/ai-client/src/index.ts | 14 +- .../ai-client/src/video-generation-client.ts | 118 ++++- .../ai-client/tests/generation-client.test.ts | 425 +++++++++++++++++- .../tests/generation-resume-state.test.ts | 259 +++++++++++ packages/ai-event-client/src/index.ts | 36 ++ packages/ai-react/src/use-generate-audio.ts | 41 +- packages/ai-react/src/use-generate-image.ts | 41 +- packages/ai-react/src/use-generate-speech.ts | 41 +- packages/ai-react/src/use-generate-video.ts | 32 +- packages/ai-react/src/use-generation.ts | 32 +- packages/ai-react/src/use-summarize.ts | 41 +- packages/ai-react/src/use-transcription.ts | 39 +- .../ai-react/tests/use-generation.test.ts | 177 +++++++- packages/ai-solid/src/use-generate-audio.ts | 43 +- packages/ai-solid/src/use-generate-image.ts | 43 +- packages/ai-solid/src/use-generate-speech.ts | 42 +- packages/ai-solid/src/use-generate-video.ts | 83 +++- packages/ai-solid/src/use-generation.ts | 65 ++- packages/ai-solid/src/use-summarize.ts | 44 +- packages/ai-solid/src/use-transcription.ts | 45 +- .../ai-solid/tests/use-generation.test.ts | 98 +++- .../src/create-generate-audio.svelte.ts | 34 +- .../src/create-generate-image.svelte.ts | 34 +- .../src/create-generate-speech.svelte.ts | 33 +- .../src/create-generate-video.svelte.ts | 76 +++- .../ai-svelte/src/create-generation.svelte.ts | 66 ++- .../ai-svelte/src/create-summarize.svelte.ts | 34 +- .../src/create-transcription.svelte.ts | 38 +- .../ai-svelte/tests/create-generation.test.ts | 89 +++- packages/ai-vue/src/use-generate-audio.ts | 43 +- packages/ai-vue/src/use-generate-image.ts | 43 +- packages/ai-vue/src/use-generate-speech.ts | 42 +- packages/ai-vue/src/use-generate-video.ts | 80 +++- packages/ai-vue/src/use-generation.ts | 70 ++- packages/ai-vue/src/use-summarize.ts | 44 +- packages/ai-vue/src/use-transcription.ts | 45 +- packages/ai-vue/tests/use-generation.test.ts | 124 ++++- 51 files changed, 3392 insertions(+), 461 deletions(-) create mode 100644 .changeset/generation-persistence.md create mode 100644 docs/persistence/generation-persistence.md create mode 100644 packages/ai-client/tests/generation-resume-state.test.ts diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md new file mode 100644 index 000000000..28fc580e8 --- /dev/null +++ b/.changeset/generation-persistence.md @@ -0,0 +1,15 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-event-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +--- + +Add client-side generation persistence: a lightweight, read-only resume snapshot for media generation activities. + +Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence: { server }` option and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus observed `pendingArtifacts` / `resultArtifacts`). As a run streams, the client builds a `GenerationResumeSnapshot` — run identity, status, errors, and result metadata, but **never** the generated media bytes — and writes it to the provided `GenerationServerPersistence` store. On reload the snapshot is surfaced for observability; it exposes no `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called. + +This pairs with the existing `withGenerationPersistence` server middleware, which records run status in the shared `RunStore`. diff --git a/docs/config.json b/docs/config.json index 85d704248..3fb4f1189 100644 --- a/docs/config.json +++ b/docs/config.json @@ -256,6 +256,11 @@ "addedAt": "2026-07-22", "updatedAt": "2026-07-27" }, + { + "label": "Generation Persistence", + "to": "persistence/generation-persistence", + "addedAt": "2026-07-23" + }, { "label": "Controls", "to": "persistence/controls", diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md new file mode 100644 index 000000000..2b3119a9b --- /dev/null +++ b/docs/persistence/generation-persistence.md @@ -0,0 +1,121 @@ +--- +title: Generation Persistence +id: generation-persistence +--- + +# Generation Persistence + +Generation persistence records run status for media generation activities. +Client hooks may store a lightweight, read-only snapshot containing run +identity, status, and errors. + +Generated bytes never belong in browser resume state. + +## Create the server endpoint + +```ts group=generation-persistence +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { withGenerationPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' + +const persistence = sqlitePersistence({ + url: 'file:.tanstack-ai/generation.sqlite', + migrate: true, +}) + +export async function POST(request: Request) { + const { input, threadId, runId } = + await generationParamsFromRequest('image', request) + + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + const stream = generateImage({ + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + stream: true, + middleware: [withGenerationPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +Use the matching request kind for audio, TTS, video, or transcription. +`withGenerationPersistence` records runs whenever a `runs` store is present. + +Keep run ids unique across chat and generation when they share a backend, +because `RunStore` is keyed by `runId`. + +## Persist the client snapshot + +```tsx +import { localStoragePersistence } from '@tanstack/ai-client' +import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' +import type { GenerationResumeSnapshot } from '@tanstack/ai-client' + +function serializeJson(value: unknown): string { + const stringify: (input: unknown) => unknown = JSON.stringify + const serialized = stringify(value) + if (typeof serialized !== 'string') { + throw new TypeError('The value is not JSON serializable.') + } + return serialized +} + +const snapshots = localStoragePersistence({ + keyPrefix: 'my-app:generation:', + serialize: serializeJson, + deserialize: JSON.parse, +}) + +export function HeroImageGenerator() { + const image = useGenerateImage({ + id: 'hero-image', + connection: fetchServerSentEvents('/api/generate/image'), + persistence: { server: snapshots }, + }) + + return ( +
+ + + {image.resumeState ?

Last run: {image.resumeState.runId}

: null} +
+ ) +} +``` + +The snapshot has no stream delivery offset and exposes no `resume()` action. +Generation starts only when `generate(...)` is called. The snapshot is useful +for observability after reload; it does not restart or reconnect to provider +work. + +## Media bytes are not stored yet + +Persisted generation results reference the media URLs the provider returned. +Provider CDN URLs typically expire, so a snapshot inspected much later may +point at media that is no longer downloadable. Durable artifact storage +(artifact metadata plus blob bytes, served from your own storage) is a +follow-up feature and is not part of this release. + +## Delivery remains separate + +State persistence makes the run and result queryable after completion. It does +not replay an in-flight response — that is a separate transport-layer feature +(stream re-attach / delivery durability), landing in PR #955. diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 044570498..64b5a6589 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -3,9 +3,40 @@ import { createFileRoute } from '@tanstack/react-router' import { useGenerateImage } from '@tanstack/ai-react' import type { UseGenerateImageReturn } from '@tanstack/ai-react' import { fetchServerSentEvents } from '@tanstack/ai-client' +import type { + GenerationResumeSnapshot, + GenerationServerPersistence, +} from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' +// Lightweight, read-only generation resume snapshots persisted in the browser. +// Only run identity, status, errors, and result metadata are stored — never the +// generated image bytes. On reload the last snapshot is surfaced for +// observability; generation still only starts when `generate(...)` is called. +const imageSnapshots: GenerationServerPersistence = { + getItem: (id) => { + if (typeof window === 'undefined') return null + const raw = window.localStorage.getItem(`example:generation:${id}`) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + return parsed && typeof parsed === 'object' + ? (parsed as GenerationResumeSnapshot) + : null + }, + setItem: (id, value) => { + if (typeof window === 'undefined') return + window.localStorage.setItem( + `example:generation:${id}`, + JSON.stringify(value), + ) + }, + removeItem: (id) => { + if (typeof window === 'undefined') return + window.localStorage.removeItem(`example:generation:${id}`) + }, +} + function StreamingImageGeneration() { const [prompt, setPrompt] = useState('') const [numberOfImages, setNumberOfImages] = useState(1) @@ -69,6 +100,42 @@ function ServerFnImageGeneration() { ) } +function PersistedImageGeneration() { + const [prompt, setPrompt] = useState('') + const [numberOfImages, setNumberOfImages] = useState(1) + + const hookReturn = useGenerateImage({ + id: 'persisted-image', + connection: fetchServerSentEvents('/api/generate/image'), + persistence: { server: imageSnapshots }, + }) + + return ( +
+
+

+ Resume status: {hookReturn.status} +

+ {hookReturn.resumeState ? ( +

+ Last run:{' '} + {hookReturn.resumeState.runId} +

+ ) : ( +

No persisted run yet.

+ )} +
+ +
+ ) +} + function ImageGenerationUI({ prompt, setPrompt, @@ -170,9 +237,9 @@ function ImageGenerationUI({ } function ImageGenerationPage() { - const [mode, setMode] = useState<'streaming' | 'direct' | 'server-fn'>( - 'streaming', - ) + const [mode, setMode] = useState< + 'streaming' | 'direct' | 'server-fn' | 'persisted' + >('streaming') return (
@@ -215,6 +282,16 @@ function ImageGenerationPage() { > Server Fn +
@@ -225,8 +302,10 @@ function ImageGenerationPage() { ) : mode === 'direct' ? ( - ) : ( + ) : mode === 'server-fn' ? ( + ) : ( + )} diff --git a/packages/ai-angular/src/inject-generate-audio.ts b/packages/ai-angular/src/inject-generate-audio.ts index 188a032e0..fbb57607c 100644 --- a/packages/ai-angular/src/inject-generate-audio.ts +++ b/packages/ai-angular/src/inject-generate-audio.ts @@ -10,13 +10,22 @@ import type { } from '@tanstack/ai-client' import type { Signal } from '@angular/core' import type { ReactiveOption } from './internal/to-reactive' +import type { + InjectGenerationOptions, + InjectGenerationResult, +} from './inject-generation' /** * Options for the injectGenerateAudio injectable. * * @template TOutput - The output type after optional transform (defaults to AudioGenerationResult) */ -export interface InjectGenerateAudioOptions { +export interface InjectGenerateAudioOptions< + TOutput = AudioGenerationResult, +> extends Pick< + InjectGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for audio generation */ @@ -48,7 +57,9 @@ export interface InjectGenerateAudioOptions { * * @template TOutput - The output type (after optional transform) */ -export interface InjectGenerateAudioResult { +export interface InjectGenerateAudioResult< + TOutput = AudioGenerationResult, +> extends Omit, 'generate'> { /** Trigger audio generation */ generate: (input: AudioGenerateInput) => Promise /** The generation result containing audio, or null */ @@ -59,10 +70,6 @@ export interface InjectGenerateAudioResult { error: Signal /** Current state of the generation */ status: Signal - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -109,19 +116,19 @@ export function injectGenerateAudio( hookName: 'injectGenerateAudio', outputKind: 'audio' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - injectGeneration({ - ...options, - devtools, - }) + const generation = injectGeneration< + AudioGenerateInput, + AudioGenerationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: AudioGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: AudioGenerateInput, + ) => Promise, } } diff --git a/packages/ai-angular/src/inject-generate-image.ts b/packages/ai-angular/src/inject-generate-image.ts index 3c3c2e4fe..16b08e8e6 100644 --- a/packages/ai-angular/src/inject-generate-image.ts +++ b/packages/ai-angular/src/inject-generate-image.ts @@ -6,7 +6,10 @@ import type { ImageGenerateInput, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' -import type { InjectGenerationOptions } from './inject-generation' +import type { + InjectGenerationOptions, + InjectGenerationResult, +} from './inject-generation' export type InjectGenerateImageOptions = Omit< InjectGenerationOptions, @@ -15,14 +18,14 @@ export type InjectGenerateImageOptions = Omit< onResult?: (result: ImageGenerationResult) => TOutput | null | void } -export interface InjectGenerateImageResult { +export interface InjectGenerateImageResult< + TOutput = ImageGenerationResult, +> extends Omit, 'generate'> { generate: (input: ImageGenerateInput) => Promise result: Signal isLoading: Signal error: Signal status: Signal - stop: () => void - reset: () => void } export function injectGenerateImage( @@ -38,18 +41,18 @@ export function injectGenerateImage( hookName: 'injectGenerateImage', outputKind: 'image' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - injectGeneration({ - ...options, - devtools, - }) + const generation = injectGeneration< + ImageGenerateInput, + ImageGenerationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: ImageGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: ImageGenerateInput, + ) => Promise, } } diff --git a/packages/ai-angular/src/inject-generate-speech.ts b/packages/ai-angular/src/inject-generate-speech.ts index ce3e08549..d78b804ec 100644 --- a/packages/ai-angular/src/inject-generate-speech.ts +++ b/packages/ai-angular/src/inject-generate-speech.ts @@ -6,7 +6,10 @@ import type { InferGenerationOutputFromReturn, SpeechGenerateInput, } from '@tanstack/ai-client' -import type { InjectGenerationOptions } from './inject-generation' +import type { + InjectGenerationOptions, + InjectGenerationResult, +} from './inject-generation' export type InjectGenerateSpeechOptions = Omit< InjectGenerationOptions, @@ -15,14 +18,15 @@ export type InjectGenerateSpeechOptions = Omit< onResult?: (result: TTSResult) => TOutput | null | void } -export interface InjectGenerateSpeechResult { +export interface InjectGenerateSpeechResult extends Omit< + InjectGenerationResult, + 'generate' +> { generate: (input: SpeechGenerateInput) => Promise result: Signal isLoading: Signal error: Signal status: Signal - stop: () => void - reset: () => void } export function injectGenerateSpeech( @@ -38,18 +42,18 @@ export function injectGenerateSpeech( hookName: 'injectGenerateSpeech', outputKind: 'audio' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - injectGeneration({ - ...options, - devtools, - }) + const generation = injectGeneration< + SpeechGenerateInput, + TTSResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: SpeechGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: SpeechGenerateInput, + ) => Promise, } } diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index c493e39ee..4e75df991 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -17,12 +17,17 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' import type { StreamChunk } from '@tanstack/ai' +import type { PersistedArtifactRef } from '@tanstack/ai/client' let nextId = 0 @@ -32,6 +37,8 @@ export interface InjectGenerateVideoOptions { id?: string body?: ReactiveOption> devtools?: AIDevtoolsDisplayOptions + persistence?: GenerationPersistenceOptions + initialResumeSnapshot?: GenerationResumeSnapshot onResult?: (result: VideoGenerateResult) => TOutput | null | void onError?: (error: Error) => void onProgress?: (progress: number, message?: string) => void @@ -50,6 +57,10 @@ export interface InjectGenerateVideoResult { status: Signal stop: () => void reset: () => void + resumeSnapshot: Signal + resumeState: Signal + pendingArtifacts: Signal> + resultArtifacts: Signal> } // `TTransformed` infers from the `onResult` return position so the callback @@ -79,6 +90,29 @@ export function injectGenerateVideo( const isLoading = signal(false) const error = signal(undefined) const status = signal('idle') + const resumeSnapshot = signal( + options.initialResumeSnapshot, + ) + const resumeState = signal( + options.initialResumeSnapshot?.resumeState ?? null, + ) + const pendingArtifacts = signal>( + options.initialResumeSnapshot?.pendingArtifacts ?? [], + ) + const resultArtifacts = signal>( + options.initialResumeSnapshot?.result?.artifacts ?? [], + ) + let disposed = false + + const setResumeSnapshotState = ( + snapshot: GenerationResumeSnapshot | undefined, + ) => { + if (disposed) return + resumeSnapshot.set(snapshot) + resumeState.set(snapshot?.resumeState ?? null) + pendingArtifacts.set(snapshot?.pendingArtifacts ?? []) + resultArtifacts.set(snapshot?.result?.artifacts ?? []) + } const bodySource = options.body !== undefined ? toReactive(options.body) : undefined @@ -86,6 +120,12 @@ export function injectGenerateVideo( const baseOptions = { id: clientId, ...(bodySource !== undefined && { body: bodySource() }), + ...(options.persistence !== undefined && { + persistence: options.persistence, + }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...options.devtools, @@ -99,17 +139,40 @@ export function injectGenerateVideo( onResult: ((r: VideoGenerateResult) => options.onResult?.(r)) as ( result: VideoGenerateResult, ) => TOutput | null | void, - onError: (e: Error) => options.onError?.(e), - onProgress: (p: number, m?: string) => options.onProgress?.(p, m), - onChunk: (c: StreamChunk) => options.onChunk?.(c), - onJobCreated: (id: string) => options.onJobCreated?.(id), - onStatusUpdate: (s: VideoStatusInfo) => options.onStatusUpdate?.(s), - onResultChange: (r: TOutput | null) => result.set(r), - onLoadingChange: (l: boolean) => isLoading.set(l), - onErrorChange: (e: Error | undefined) => error.set(e), - onStatusChange: (s: GenerationClientState) => status.set(s), - onJobIdChange: (id: string | null) => jobId.set(id), - onVideoStatusChange: (s: VideoStatusInfo | null) => videoStatus.set(s), + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, + onJobCreated: (id: string) => { + if (!disposed) options.onJobCreated?.(id) + }, + onStatusUpdate: (s: VideoStatusInfo) => { + if (!disposed) options.onStatusUpdate?.(s) + }, + onResultChange: (r: TOutput | null) => { + if (!disposed) result.set(r) + }, + onLoadingChange: (l: boolean) => { + if (!disposed) isLoading.set(l) + }, + onErrorChange: (e: Error | undefined) => { + if (!disposed) error.set(e) + }, + onStatusChange: (s: GenerationClientState) => { + if (!disposed) status.set(s) + }, + onJobIdChange: (id: string | null) => { + if (!disposed) jobId.set(id) + }, + onVideoStatusChange: (s: VideoStatusInfo | null) => { + if (!disposed) videoStatus.set(s) + }, + onResumeSnapshotChange: setResumeSnapshotState, } let client: VideoGenerationClient @@ -132,14 +195,26 @@ export function injectGenerateVideo( if (bodySource) { effect( () => { - client.updateOptions({ body: bodySource() }) + client.updateOptions({ + body: bodySource(), + }) }, { injector }, ) } - afterNextRender(() => client.mountDevtools(), { injector }) - destroyRef.onDestroy(() => client.dispose()) + // Mount devtools only. Generation runs are never auto-started after render — + // persisted state is read-only for display. + afterNextRender( + () => { + client.mountDevtools() + }, + { injector }, + ) + destroyRef.onDestroy(() => { + disposed = true + client.dispose() + }) return { generate: (input: VideoGenerateInput) => client.generate(input), @@ -151,5 +226,9 @@ export function injectGenerateVideo( status: status.asReadonly(), stop: () => client.stop(), reset: () => client.reset(), + resumeSnapshot: resumeSnapshot.asReadonly(), + resumeState: resumeState.asReadonly(), + pendingArtifacts: pendingArtifacts.asReadonly(), + resultArtifacts: resultArtifacts.asReadonly(), } } diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index 177842e01..542f2c6e6 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -18,8 +18,13 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { ReactiveOption } from './internal/to-reactive' let nextId = 0 @@ -35,6 +40,10 @@ export interface InjectGenerationOptions { body?: ReactiveOption> /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. * @@ -66,6 +75,14 @@ export interface InjectGenerationResult { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: Signal + /** Observed run/cursor metadata from the snapshot (read-only state) */ + resumeState: Signal + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Signal> + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Signal> } // `TTransformed` infers from the `onResult` return position (a covariant @@ -97,6 +114,29 @@ export function injectGeneration< const isLoading = signal(false) const error = signal(undefined) const status = signal('idle') + const resumeSnapshot = signal( + options.initialResumeSnapshot, + ) + const resumeState = signal( + options.initialResumeSnapshot?.resumeState ?? null, + ) + const pendingArtifacts = signal>( + options.initialResumeSnapshot?.pendingArtifacts ?? [], + ) + const resultArtifacts = signal>( + options.initialResumeSnapshot?.result?.artifacts ?? [], + ) + let disposed = false + + const setResumeSnapshotState = ( + snapshot: GenerationResumeSnapshot | undefined, + ) => { + if (disposed) return + resumeSnapshot.set(snapshot) + resumeState.set(snapshot?.resumeState ?? null) + pendingArtifacts.set(snapshot?.pendingArtifacts ?? []) + resultArtifacts.set(snapshot?.result?.artifacts ?? []) + } const bodySource = options.body !== undefined ? toReactive(options.body) : undefined @@ -104,6 +144,12 @@ export function injectGeneration< const clientOptions: GenerationClientOptions = { id: clientId, ...(bodySource !== undefined && { body: bodySource() }), + ...(options.persistence !== undefined && { + persistence: options.persistence, + }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { ...options.devtools, @@ -116,13 +162,28 @@ export function injectGeneration< onResult: ((r: TResult) => options.onResult?.(r)) as ( result: TResult, ) => TOutput | null | void, - onError: (e: Error) => options.onError?.(e), - onProgress: (p: number, m?: string) => options.onProgress?.(p, m), - onChunk: (c: StreamChunk) => options.onChunk?.(c), - onResultChange: (r: TOutput | null) => result.set(r), - onLoadingChange: (l: boolean) => isLoading.set(l), - onErrorChange: (e: Error | undefined) => error.set(e), - onStatusChange: (s: GenerationClientState) => status.set(s), + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, + onResultChange: (r: TOutput | null) => { + if (!disposed) result.set(r) + }, + onLoadingChange: (l: boolean) => { + if (!disposed) isLoading.set(l) + }, + onErrorChange: (e: Error | undefined) => { + if (!disposed) error.set(e) + }, + onStatusChange: (s: GenerationClientState) => { + if (!disposed) status.set(s) + }, + onResumeSnapshotChange: setResumeSnapshotState, } let client: GenerationClient @@ -145,14 +206,26 @@ export function injectGeneration< if (bodySource) { effect( () => { - client.updateOptions({ body: bodySource() }) + client.updateOptions({ + body: bodySource(), + }) }, { injector }, ) } - afterNextRender(() => client.mountDevtools(), { injector }) - destroyRef.onDestroy(() => client.dispose()) + // Mount devtools only. Generation runs are never auto-started after render — + // persisted state is read-only for display. + afterNextRender( + () => { + client.mountDevtools() + }, + { injector }, + ) + destroyRef.onDestroy(() => { + disposed = true + client.dispose() + }) return { generate: ((input: TInput) => client.generate(input)) as ( @@ -164,5 +237,9 @@ export function injectGeneration< status: status.asReadonly(), stop: () => client.stop(), reset: () => client.reset(), + resumeSnapshot: resumeSnapshot.asReadonly(), + resumeState: resumeState.asReadonly(), + pendingArtifacts: pendingArtifacts.asReadonly(), + resultArtifacts: resultArtifacts.asReadonly(), } } diff --git a/packages/ai-angular/src/inject-summarize.ts b/packages/ai-angular/src/inject-summarize.ts index fa41a76fa..db6b1c6e8 100644 --- a/packages/ai-angular/src/inject-summarize.ts +++ b/packages/ai-angular/src/inject-summarize.ts @@ -6,7 +6,10 @@ import type { InferGenerationOutputFromReturn, SummarizeGenerateInput, } from '@tanstack/ai-client' -import type { InjectGenerationOptions } from './inject-generation' +import type { + InjectGenerationOptions, + InjectGenerationResult, +} from './inject-generation' export type InjectSummarizeOptions = Omit< InjectGenerationOptions, @@ -15,14 +18,14 @@ export type InjectSummarizeOptions = Omit< onResult?: (result: SummarizationResult) => TOutput | null | void } -export interface InjectSummarizeResult { +export interface InjectSummarizeResult< + TOutput = SummarizationResult, +> extends Omit, 'generate'> { generate: (input: SummarizeGenerateInput) => Promise result: Signal isLoading: Signal error: Signal status: Signal - stop: () => void - reset: () => void } export function injectSummarize( @@ -38,20 +41,18 @@ export function injectSummarize( hookName: 'injectSummarize', outputKind: 'text' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - injectGeneration( - { - ...options, - devtools, - }, - ) + const generation = injectGeneration< + SummarizeGenerateInput, + SummarizationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: SummarizeGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: SummarizeGenerateInput, + ) => Promise, } } diff --git a/packages/ai-angular/src/inject-transcription.ts b/packages/ai-angular/src/inject-transcription.ts index ef2066ad2..b7bc832af 100644 --- a/packages/ai-angular/src/inject-transcription.ts +++ b/packages/ai-angular/src/inject-transcription.ts @@ -6,7 +6,10 @@ import type { InferGenerationOutputFromReturn, TranscriptionGenerateInput, } from '@tanstack/ai-client' -import type { InjectGenerationOptions } from './inject-generation' +import type { + InjectGenerationOptions, + InjectGenerationResult, +} from './inject-generation' export type InjectTranscriptionOptions = Omit< InjectGenerationOptions< @@ -19,14 +22,14 @@ export type InjectTranscriptionOptions = Omit< onResult?: (result: TranscriptionResult) => TOutput | null | void } -export interface InjectTranscriptionResult { +export interface InjectTranscriptionResult< + TOutput = TranscriptionResult, +> extends Omit, 'generate'> { generate: (input: TranscriptionGenerateInput) => Promise result: Signal isLoading: Signal error: Signal status: Signal - stop: () => void - reset: () => void } export function injectTranscription( @@ -42,22 +45,18 @@ export function injectTranscription( hookName: 'injectTranscription', outputKind: 'text' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - injectGeneration< - TranscriptionGenerateInput, - TranscriptionResult, - TTransformed - >({ - ...options, - devtools, - }) + const generation = injectGeneration< + TranscriptionGenerateInput, + TranscriptionResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: TranscriptionGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: TranscriptionGenerateInput, + ) => Promise, } } diff --git a/packages/ai-angular/tests/inject-generation.test.ts b/packages/ai-angular/tests/inject-generation.test.ts index 1ae52833e..2ee11685c 100644 --- a/packages/ai-angular/tests/inject-generation.test.ts +++ b/packages/ai-angular/tests/inject-generation.test.ts @@ -6,6 +6,13 @@ import { } from '@angular/platform-browser-dynamic/testing' import { describe, expect, it, vi } from 'vitest' import { injectGeneration } from '../src/inject-generation' +import { injectGenerateVideo } from '../src/inject-generate-video' +import type { StreamChunk } from '@tanstack/ai' +import type { + ConnectConnectionAdapter, + GenerationResumeSnapshot, + RunAgentInputContext, +} from '@tanstack/ai-client' // Ensure TestBed is initialized in this module's scope, regardless of whether // the setup file's initialization was in a different module context (possible @@ -34,9 +41,53 @@ function renderInjectGeneration(options: any) { return fixture.componentInstance.gen }, flush: () => fixture.detectChanges(), + destroy: () => fixture.destroy(), } } +function renderInjectGenerateVideo(options: any) { + @Component({ standalone: true, template: '' }) + class Host { + gen = injectGenerateVideo(options) + } + const fixture = TestBed.createComponent(Host) + fixture.detectChanges() + return { + get result() { + return fixture.componentInstance.gen + }, + flush: () => fixture.detectChanges(), + destroy: () => fixture.destroy(), + } +} + +const videoResumeSnapshot: GenerationResumeSnapshot = { + resumeState: { + threadId: 'thread-resume', + runId: 'run-resume', + }, + status: 'running', +} + +function createRunContextCaptureAdapter(chunks: Array): { + adapter: ConnectConnectionAdapter + connect: ReturnType + runContexts: Array +} { + const runContexts: Array = [] + const connect = vi.fn() + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, _signal, runContext) { + connect(runContext) + runContexts.push(runContext) + for (const chunk of chunks) { + yield chunk + } + }, + } + return { adapter, connect, runContexts } +} + describe('injectGeneration', () => { it('initializes idle with a fetcher and generates a result', async () => { const fetcher = vi.fn(async () => ({ value: 42 })) @@ -68,4 +119,57 @@ describe('injectGeneration', () => { expect(result.result()).toEqual({ playable: true }) expect(result.status()).toBe('success') }) + + it('does not auto-fire a generation after render from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface. + const snapshot: GenerationResumeSnapshot = { + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running', + } + const { adapter, connect } = createRunContextCaptureAdapter([]) + const getItem = vi.fn(() => snapshot) + const { result } = renderInjectGeneration({ + id: 'no-auto-fire', + connection: adapter, + persistence: { + server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, + }, + initialResumeSnapshot: snapshot, + }) + + await Promise.resolve() + + expect(connect).not.toHaveBeenCalled() + expect(getItem).not.toHaveBeenCalled() + expect(result.isLoading()).toBe(false) + expect(result.status()).toBe('idle') + // The persisted snapshot remains exposed as read-only state. + expect(result.resumeState()).toEqual(snapshot.resumeState) + }) +}) + +describe('injectGenerateVideo', () => { + it('does not auto-fire a video generation after render from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface (video). + const { adapter, connect } = createRunContextCaptureAdapter([]) + const getItem = vi.fn(() => videoResumeSnapshot) + const { result } = renderInjectGenerateVideo({ + id: 'video-no-auto-fire', + connection: adapter, + persistence: { + server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, + }, + initialResumeSnapshot: videoResumeSnapshot, + }) + + await Promise.resolve() + + expect(connect).not.toHaveBeenCalled() + expect(getItem).not.toHaveBeenCalled() + expect(result.isLoading()).toBe(false) + expect(result.status()).toBe('idle') + // The persisted snapshot remains exposed as read-only state. + expect(result.resumeSnapshot()).toEqual(videoResumeSnapshot) + expect(result.resumeState()).toEqual(videoResumeSnapshot.resumeState) + }) }) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 38bc56a0b..9d1dd6ebe 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -1,4 +1,7 @@ -import { GENERATION_EVENTS } from './generation-types' +import { + GENERATION_EVENTS, + updateGenerationResumeSnapshot, +} from './generation-types' import { createNoOpGenerationDevtoolsBridge } from './devtools-noop' import { parseSSEResponse } from './sse-parser' import type { StreamChunk } from '@tanstack/ai/client' @@ -16,6 +19,8 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, + GenerationResumeSnapshot, + GenerationServerPersistence, } from './generation-types' /** @@ -33,6 +38,9 @@ interface GenerationCallbacks { onLoadingChange?: ((isLoading: boolean) => void) | undefined onErrorChange?: ((error: Error | undefined) => void) | undefined onStatusChange?: ((status: GenerationClientState) => void) | undefined + onResumeSnapshotChange?: + | ((snapshot: GenerationResumeSnapshot) => void) + | undefined } /** @@ -81,6 +89,7 @@ export class GenerationClient< private readonly devtoolsMetadata: AIDevtoolsClientMetadata private readonly devtoolsBridge: GenerationDevtoolsBridge private readonly threadId: string + private readonly serverPersistence: GenerationServerPersistence | undefined private body: Record private result: TOutput | null = null private input: TInput | null = null @@ -88,9 +97,13 @@ export class GenerationClient< private isLoading = false private error: Error | undefined = undefined private status: GenerationClientState = 'idle' + private resumeSnapshot: GenerationResumeSnapshot | undefined + private resumeSnapshotPersistenceQueue: Promise = Promise.resolve() + private resumePersistenceError: Error | undefined = undefined private abortController: AbortController | null = null private readonly callbacksRef: GenerationCallbacks private devtoolsMounted = false + private disposed = false constructor( options: GenerationClientOptions & @@ -107,6 +120,8 @@ export class GenerationClient< this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} + this.serverPersistence = options.persistence?.server + this.resumeSnapshot = options.initialResumeSnapshot this.callbacksRef = { onResult: options.onResult, @@ -117,6 +132,7 @@ export class GenerationClient< onLoadingChange: options.onLoadingChange, onErrorChange: options.onErrorChange, onStatusChange: options.onStatusChange, + onResumeSnapshotChange: options.onResumeSnapshotChange, } this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools) @@ -159,6 +175,7 @@ export class GenerationClient< */ async generate(input: TInput): Promise { this.mountDevtools() + if (this.disposed) return if (this.isLoading) return this.input = input @@ -179,11 +196,16 @@ export class GenerationClient< if (signal.aborted) return if (result instanceof Response) { // Server function returned SSE Response — parse stream - await this.processStream(parseSSEResponse(result, signal), runId) + await this.processStream( + parseSSEResponse(result, signal), + runId, + signal, + ) } else { this.devtoolsBridge.ensureRunStarted(runId) this.setResult(result) this.setStatus('success') + this.completePlainFetcherResumeSnapshot() } } else if (this.connection) { // Streaming adapter path @@ -194,7 +216,7 @@ export class GenerationClient< signal, this.createRunContext(runId), ) - await this.processStream(stream, runId) + await this.processStream(stream, runId, signal) } else { throw new Error( 'GenerationClient requires either a connection or fetcher option', @@ -225,8 +247,10 @@ export class GenerationClient< ) this.callbacksRef.onError?.(error) } finally { - this.abortController = null - this.setIsLoading(false) + if (this.abortController === abortController) { + this.abortController = null + this.setIsLoading(false) + } } } @@ -236,13 +260,15 @@ export class GenerationClient< private async processStream( source: AsyncIterable, fallbackRunId: string, + signal: AbortSignal, ): Promise { let streamRunId: string | undefined for await (const chunk of source) { - if (this.abortController?.signal.aborted) break + if (signal.aborted) break this.callbacksRef.onChunk?.(chunk) + this.observeResumeSnapshot(chunk) const chunkRunId = 'runId' in chunk && typeof chunk.runId === 'string' ? chunk.runId @@ -352,6 +378,7 @@ export class GenerationClient< } dispose(): void { + this.disposed = true this.stop() this.devtoolsBridge.dispose() this.devtoolsMounted = false @@ -373,10 +400,41 @@ export class GenerationClient< return this.error } + getResumePersistenceError(): Error | undefined { + return this.resumePersistenceError + } + getStatus(): GenerationClientState { return this.status } + getResumeSnapshot(): GenerationResumeSnapshot | undefined { + return this.resumeSnapshot + ? { + ...this.resumeSnapshot, + ...(this.resumeSnapshot.pendingArtifacts + ? { pendingArtifacts: [...this.resumeSnapshot.pendingArtifacts] } + : {}), + ...(this.resumeSnapshot.result + ? { + result: { + ...this.resumeSnapshot.result, + ...(this.resumeSnapshot.result.artifacts + ? { artifacts: [...this.resumeSnapshot.result.artifacts] } + : {}), + }, + } + : {}), + ...(this.resumeSnapshot.error + ? { error: { ...this.resumeSnapshot.error } } + : {}), + ...(this.resumeSnapshot.lastEvent + ? { lastEvent: { ...this.resumeSnapshot.lastEvent } } + : {}), + } + : undefined + } + // =========================== // Private state setters // =========================== @@ -466,6 +524,58 @@ export class GenerationClient< runId, } } + + private observeResumeSnapshot(chunk: StreamChunk): void { + this.resumeSnapshot = updateGenerationResumeSnapshot( + this.resumeSnapshot, + chunk, + ) + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + void this.persistResumeSnapshot(this.resumeSnapshot) + } + + private completePlainFetcherResumeSnapshot(): void { + if (!this.resumeSnapshot) { + return + } + this.resumeSnapshot = { + ...this.resumeSnapshot, + resumeState: null, + status: 'complete', + } + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + void this.persistResumeSnapshot(this.resumeSnapshot) + } + + private async persistResumeSnapshot( + snapshot: GenerationResumeSnapshot, + ): Promise { + if (!this.serverPersistence) { + return + } + + this.resumeSnapshotPersistenceQueue = + this.resumeSnapshotPersistenceQueue.then( + () => this.writeResumeSnapshot(snapshot), + () => this.writeResumeSnapshot(snapshot), + ) + await this.resumeSnapshotPersistenceQueue + } + + private async writeResumeSnapshot( + snapshot: GenerationResumeSnapshot, + ): Promise { + try { + await this.serverPersistence?.setItem(this.threadId, snapshot) + } catch (error) { + this.resumePersistenceError = + error instanceof Error ? error : new Error(String(error)) + console.warn( + '[TanStack AI] Failed to persist generation resume snapshot', + error, + ) + } + } } function completeProgressValue( diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index b11e8ca2a..86d03cb0e 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -1,4 +1,8 @@ -import type { MediaPrompt, StreamChunk } from '@tanstack/ai/client' +import type { + MediaPrompt, + PersistedArtifactRef, + StreamChunk, +} from '@tanstack/ai/client' import type { TranscriptionResponseFormat } from '@tanstack/ai' import type { ConnectConnectionAdapter } from './connection-adapters' import type { AIDevtoolsClientMetadata } from './devtools' @@ -58,6 +62,61 @@ export type InferGenerationOutput = TFn extends ( */ export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error' +export type GenerationResumeStatus = 'idle' | 'running' | 'complete' | 'error' + +export interface GenerationResumeState { + threadId: string + runId: string +} + +export type GenerationPendingArtifact = PersistedArtifactRef + +export interface GenerationResultSnapshot { + id?: string + model?: string + status?: string + jobId?: string + expiresAt?: string + artifacts?: Array +} + +export interface GenerationErrorSnapshot { + message: string + code?: string +} + +export interface GenerationEventSnapshot { + type: StreamChunk['type'] + name?: string + timestamp?: number +} + +export interface GenerationResumeSnapshot { + resumeState: GenerationResumeState | null + status: GenerationResumeStatus + activity?: PersistedArtifactRef['source']['activity'] + pendingArtifacts?: Array + result?: GenerationResultSnapshot + error?: GenerationErrorSnapshot + lastEvent?: GenerationEventSnapshot +} + +export interface GenerationServerPersistence { + getItem: ( + id: string, + ) => + | GenerationResumeSnapshot + | null + | undefined + | Promise + setItem: (id: string, value: GenerationResumeSnapshot) => void | Promise + removeItem: (id: string) => void | Promise +} + +export interface GenerationPersistenceOptions { + server?: GenerationServerPersistence +} + // =========================== // Event Constants // =========================== @@ -70,6 +129,8 @@ export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error' export const GENERATION_EVENTS = { /** The generation result payload */ RESULT: 'generation:result', + /** Persisted artifact refs for generated media */ + ARTIFACTS: 'generation:artifacts', /** Progress update (0-100) with optional message */ PROGRESS: 'generation:progress', /** Video job created with jobId */ @@ -135,6 +196,24 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { /** Metadata used to register this generation hook with TanStack AI Devtools */ devtools?: Partial + /** + * Initial lightweight resume snapshot restored by framework hooks. Contains + * only observed run metadata, errors, and persisted artifact refs. It does + * not trigger any generation, but it is **not** inert: it seeds the client's + * live resume snapshot, which subsequent run events merge into and which + * `getResumeSnapshot()` returns and the client re-persists. Later reads + * therefore reflect this seed merged with observed activity, not the original + * value verbatim. + */ + initialResumeSnapshot?: GenerationResumeSnapshot + + /** + * Optional persistence adapters for lightweight generation state. + * Generation hooks only support `server` persistence; generated media bytes + * are never written into browser storage by this client. + */ + persistence?: GenerationPersistenceOptions + /** * Factory that constructs the devtools bridge. Default is a no-op * factory; the real implementation lives in `@tanstack/ai-client/devtools`. @@ -166,6 +245,63 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { onErrorChange?: (error: Error | undefined) => void /** @internal Called when generation status changes */ onStatusChange?: (status: GenerationClientState) => void + /** @internal Called when lightweight resume snapshot changes */ + onResumeSnapshotChange?: (snapshot: GenerationResumeSnapshot) => void +} + +export function updateGenerationResumeSnapshot( + previous: GenerationResumeSnapshot | null | undefined, + chunk: StreamChunk, +): GenerationResumeSnapshot { + const threadId = stringField(chunk, 'threadId') + const runId = stringField(chunk, 'runId') + const previousArtifacts = previous?.pendingArtifacts ?? [] + const next: GenerationResumeSnapshot = { + resumeState: previous?.resumeState ?? null, + status: previous?.status ?? 'idle', + ...(previous?.activity ? { activity: previous.activity } : {}), + ...(previousArtifacts.length > 0 + ? { pendingArtifacts: [...previousArtifacts] } + : {}), + ...(previous?.result ? { result: { ...previous.result } } : {}), + ...(previous?.error ? { error: { ...previous.error } } : {}), + lastEvent: createGenerationEventSnapshot(chunk), + } + + if (threadId && runId) { + next.resumeState = { threadId, runId } + next.status = 'running' + } else if (chunk.type === 'RUN_STARTED') { + next.status = 'running' + } + + if (chunk.type === 'CUSTOM') { + if (chunk.name === GENERATION_EVENTS.ARTIFACTS) { + const artifacts = collectArtifactRefs(chunk.value) + if (artifacts.length > 0) { + next.pendingArtifacts = artifacts + next.activity = artifacts[0]?.source.activity + } + } else if (chunk.name === GENERATION_EVENTS.RESULT) { + const result = createGenerationResultSnapshot(chunk.value) + if (result) { + next.result = result + if (result.artifacts && result.artifacts.length > 0) { + next.pendingArtifacts = result.artifacts + next.activity = result.artifacts[0]?.source.activity + } + } + } + } else if (chunk.type === 'RUN_FINISHED') { + next.resumeState = null + next.status = 'complete' + } else if (chunk.type === 'RUN_ERROR') { + next.resumeState = null + next.status = 'error' + next.error = createGenerationErrorSnapshot(chunk) + } + + return next } // =========================== @@ -200,6 +336,8 @@ export interface VideoGenerateResult { url: string /** When the URL expires, if applicable */ expiresAt?: Date + /** Persisted artifact references for generated assets, when available */ + artifacts?: Array } /** @@ -328,3 +466,214 @@ export interface VideoGenerateInput { /** Model-specific options */ modelOptions?: Record } + +function createGenerationEventSnapshot( + chunk: StreamChunk, +): GenerationEventSnapshot { + const name = stringField(chunk, 'name') + const timestamp = numberField(chunk, 'timestamp') + return { + type: chunk.type, + ...(name ? { name } : {}), + ...(timestamp !== undefined ? { timestamp } : {}), + } +} + +function createGenerationResultSnapshot( + value: unknown, +): GenerationResultSnapshot | undefined { + if (!isObject(value)) return undefined + + const artifacts = collectArtifactRefs(Reflect.get(value, 'artifacts')) + const snapshot: GenerationResultSnapshot = {} + const id = stringField(value, 'id') + const model = stringField(value, 'model') + const status = stringField(value, 'status') + const jobId = stringField(value, 'jobId') + if (id) snapshot.id = id + if (model) snapshot.model = model + if (status) snapshot.status = status + if (jobId) snapshot.jobId = jobId + const expiresAt = Reflect.get(value, 'expiresAt') + if (typeof expiresAt === 'string') { + snapshot.expiresAt = expiresAt + } else if (expiresAt instanceof Date) { + snapshot.expiresAt = expiresAt.toISOString() + } + if (artifacts.length > 0) { + snapshot.artifacts = artifacts + } + + return Object.keys(snapshot).length > 0 ? snapshot : undefined +} + +function createGenerationErrorSnapshot( + chunk: StreamChunk, +): GenerationErrorSnapshot { + const message = + stringField(chunk, 'message') ?? + nestedStringField(chunk, 'error', 'message') ?? + 'An error occurred' + const code = stringField(chunk, 'code') + return { + message, + ...(code ? { code } : {}), + } +} + +function collectArtifactRefs(value: unknown): Array { + if (!Array.isArray(value)) return [] + const refs: Array = [] + for (const item of value) { + const ref = createPersistedArtifactRefSnapshot(item) + if (ref) { + refs.push(ref) + } + } + return refs +} + +function createPersistedArtifactRefSnapshot( + value: unknown, +): PersistedArtifactRef | undefined { + if (!isObject(value)) return undefined + const source = Reflect.get(value, 'source') + if (!isObject(source)) return undefined + + const role = persistedArtifactRoleField(value, 'role') + const artifactId = stringField(value, 'artifactId') + const threadId = stringField(value, 'threadId') + const runId = stringField(value, 'runId') + const name = stringField(value, 'name') + const mimeType = stringField(value, 'mimeType') + const size = numberField(value, 'size') + const createdAt = stringField(value, 'createdAt') + const activity = persistedArtifactActivityField(source, 'activity') + const path = stringField(source, 'path') + const provider = stringField(source, 'provider') + const model = stringField(source, 'model') + if ( + !role || + !artifactId || + !threadId || + !runId || + !name || + !mimeType || + size === undefined || + !createdAt || + !activity || + !path || + !provider || + !model + ) { + return undefined + } + + const externalUrl = durableUrlField(value, 'externalUrl') + const mediaType = persistedArtifactMediaTypeField(source, 'mediaType') + const jobId = stringField(source, 'jobId') + const expiresAt = stringField(source, 'expiresAt') + + return { + role, + artifactId, + threadId, + runId, + name, + mimeType, + size, + createdAt, + ...(externalUrl ? { externalUrl } : {}), + source: { + activity, + path, + provider, + model, + ...(mediaType ? { mediaType } : {}), + ...(jobId ? { jobId } : {}), + ...(expiresAt ? { expiresAt } : {}), + }, + } +} + +function durableUrlField(value: object, key: string): string | undefined { + const field = stringField(value, key) + if (!field || field.length > 2048) return undefined + try { + const url = new URL(field) + return url.protocol === 'http:' || url.protocol === 'https:' + ? field + : undefined + } catch { + return undefined + } +} + +function persistedArtifactRoleField( + value: object, + key: string, +): PersistedArtifactRef['role'] | undefined { + const field = stringField(value, key) + return field === 'input' || field === 'output' ? field : undefined +} + +function persistedArtifactActivityField( + value: object, + key: string, +): PersistedArtifactRef['source']['activity'] | undefined { + const field = stringField(value, key) + if (field === undefined) return undefined + + switch (field) { + case 'image': + case 'audio': + case 'tts': + case 'video': + case 'transcription': + return field + default: + return undefined + } +} + +function persistedArtifactMediaTypeField( + value: object, + key: string, +): PersistedArtifactRef['source']['mediaType'] | undefined { + const field = stringField(value, key) + if (field === undefined) return undefined + + switch (field) { + case 'image': + case 'audio': + case 'video': + case 'document': + case 'json': + return field + default: + return undefined + } +} + +function nestedStringField( + value: object, + key: string, + nestedKey: string, +): string | undefined { + const nested = Reflect.get(value, key) + return isObject(nested) ? stringField(nested, nestedKey) : undefined +} + +function stringField(value: object, key: string): string | undefined { + const field = Reflect.get(value, key) + return typeof field === 'string' ? field : undefined +} + +function numberField(value: object, key: string): number | undefined { + const field = Reflect.get(value, key) + return typeof field === 'number' ? field : undefined +} + +function isObject(value: unknown): value is object { + return typeof value === 'object' && value !== null +} diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index e8091f85b..e23a1e866 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -70,6 +70,15 @@ export type { InferGenerationOutput, InferGenerationOutputFromReturn, GenerationClientState, + GenerationResumeState, + GenerationResumeStatus, + GenerationResumeSnapshot, + GenerationPendingArtifact, + GenerationResultSnapshot, + GenerationErrorSnapshot, + GenerationEventSnapshot, + GenerationServerPersistence, + GenerationPersistenceOptions, GenerationClientOptions, GenerationFetcher, GenerationFetcherOptions, @@ -84,7 +93,10 @@ export type { SummarizeGenerateInput, VideoGenerateInput, } from './generation-types' -export { GENERATION_EVENTS } from './generation-types' +export { + GENERATION_EVENTS, + updateGenerationResumeSnapshot, +} from './generation-types' export { UnsupportedResponseStreamError } from './response-stream' export { clientTools, createChatClientOptions } from './types' // Web storage adapters for durable chat persistence (messages + resume snapshot) diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index ce8b1fe72..4cd5c6c1c 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -1,4 +1,7 @@ -import { GENERATION_EVENTS } from './generation-types' +import { + GENERATION_EVENTS, + updateGenerationResumeSnapshot, +} from './generation-types' import { createNoOpVideoDevtoolsBridge } from './devtools-noop' import { parseSSEResponse } from './sse-parser' import type { StreamChunk } from '@tanstack/ai/client' @@ -15,6 +18,8 @@ import type { import type { GenerationClientState, GenerationFetcher, + GenerationResumeSnapshot, + GenerationServerPersistence, VideoGenerateInput, VideoGenerateResult, VideoGenerationClientOptions, @@ -42,6 +47,9 @@ interface VideoCallbacks { onStatusChange?: ((status: GenerationClientState) => void) | undefined onJobIdChange?: ((jobId: string | null) => void) | undefined onVideoStatusChange?: ((status: VideoStatusInfo | null) => void) | undefined + onResumeSnapshotChange?: + | ((snapshot: GenerationResumeSnapshot) => void) + | undefined } /** @@ -88,6 +96,7 @@ export class VideoGenerationClient { private readonly devtoolsMetadata: AIDevtoolsClientMetadata private readonly devtoolsBridge: VideoDevtoolsBridge private readonly threadId: string + private readonly serverPersistence: GenerationServerPersistence | undefined private body: Record private result: TOutput | null = null @@ -98,9 +107,13 @@ export class VideoGenerationClient { private isLoading = false private error: Error | undefined = undefined private status: GenerationClientState = 'idle' + private resumeSnapshot: GenerationResumeSnapshot | undefined + private resumeSnapshotPersistenceQueue: Promise = Promise.resolve() + private resumePersistenceError: Error | undefined = undefined private abortController: AbortController | null = null private readonly callbacksRef: VideoCallbacks private devtoolsMounted = false + private disposed = false constructor( options: VideoGenerationClientOptions & @@ -117,6 +130,8 @@ export class VideoGenerationClient { this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} + this.serverPersistence = options.persistence?.server + this.resumeSnapshot = options.initialResumeSnapshot this.callbacksRef = { onResult: options.onResult, @@ -131,6 +146,7 @@ export class VideoGenerationClient { onStatusChange: options.onStatusChange, onJobIdChange: options.onJobIdChange, onVideoStatusChange: options.onVideoStatusChange, + onResumeSnapshotChange: options.onResumeSnapshotChange, } this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools) @@ -174,6 +190,7 @@ export class VideoGenerationClient { */ async generate(input: VideoGenerateInput): Promise { this.mountDevtools() + if (this.disposed) return if (this.isLoading) return this.input = input @@ -200,7 +217,7 @@ export class VideoGenerationClient { signal, this.createRunContext(runId), ) - await this.processStream(stream, runId) + await this.processStream(stream, runId, signal) } else { throw new Error( 'VideoGenerationClient requires either a connection or fetcher option', @@ -226,8 +243,10 @@ export class VideoGenerationClient { ) this.callbacksRef.onError?.(error) } finally { - this.abortController = null - this.setIsLoading(false) + if (this.abortController === abortController) { + this.abortController = null + this.setIsLoading(false) + } } } @@ -247,11 +266,12 @@ export class VideoGenerationClient { if (result instanceof Response) { // Server function returned SSE Response — parse stream - await this.processStream(parseSSEResponse(result, signal), runId) + await this.processStream(parseSSEResponse(result, signal), runId, signal) } else { this.devtoolsBridge.ensureRunStarted(runId) this.setResult(result) this.setStatus('success') + this.completePlainFetcherResumeSnapshot() } } @@ -262,13 +282,15 @@ export class VideoGenerationClient { private async processStream( source: AsyncIterable, fallbackRunId: string, + signal: AbortSignal, ): Promise { let streamRunId: string | undefined for await (const chunk of source) { - if (this.abortController?.signal.aborted) break + if (signal.aborted) break this.callbacksRef.onChunk?.(chunk) + this.observeResumeSnapshot(chunk) const chunkRunId = 'runId' in chunk && typeof chunk.runId === 'string' ? chunk.runId @@ -403,6 +425,7 @@ export class VideoGenerationClient { } dispose(): void { + this.disposed = true this.stop() this.devtoolsBridge.dispose() this.devtoolsMounted = false @@ -432,10 +455,41 @@ export class VideoGenerationClient { return this.error } + getResumePersistenceError(): Error | undefined { + return this.resumePersistenceError + } + getStatus(): GenerationClientState { return this.status } + getResumeSnapshot(): GenerationResumeSnapshot | undefined { + return this.resumeSnapshot + ? { + ...this.resumeSnapshot, + ...(this.resumeSnapshot.pendingArtifacts + ? { pendingArtifacts: [...this.resumeSnapshot.pendingArtifacts] } + : {}), + ...(this.resumeSnapshot.result + ? { + result: { + ...this.resumeSnapshot.result, + ...(this.resumeSnapshot.result.artifacts + ? { artifacts: [...this.resumeSnapshot.result.artifacts] } + : {}), + }, + } + : {}), + ...(this.resumeSnapshot.error + ? { error: { ...this.resumeSnapshot.error } } + : {}), + ...(this.resumeSnapshot.lastEvent + ? { lastEvent: { ...this.resumeSnapshot.lastEvent } } + : {}), + } + : undefined + } + // =========================== // Private state setters // =========================== @@ -556,4 +610,56 @@ export class VideoGenerationClient { runId, } } + + private observeResumeSnapshot(chunk: StreamChunk): void { + this.resumeSnapshot = updateGenerationResumeSnapshot( + this.resumeSnapshot, + chunk, + ) + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + void this.persistResumeSnapshot(this.resumeSnapshot) + } + + private completePlainFetcherResumeSnapshot(): void { + if (!this.resumeSnapshot) { + return + } + this.resumeSnapshot = { + ...this.resumeSnapshot, + resumeState: null, + status: 'complete', + } + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + void this.persistResumeSnapshot(this.resumeSnapshot) + } + + private async persistResumeSnapshot( + snapshot: GenerationResumeSnapshot, + ): Promise { + if (!this.serverPersistence) { + return + } + + this.resumeSnapshotPersistenceQueue = + this.resumeSnapshotPersistenceQueue.then( + () => this.writeResumeSnapshot(snapshot), + () => this.writeResumeSnapshot(snapshot), + ) + await this.resumeSnapshotPersistenceQueue + } + + private async writeResumeSnapshot( + snapshot: GenerationResumeSnapshot, + ): Promise { + try { + await this.serverPersistence?.setItem(this.threadId, snapshot) + } catch (error) { + this.resumePersistenceError = + error instanceof Error ? error : new Error(String(error)) + console.warn( + '[TanStack AI] Failed to persist generation resume snapshot', + error, + ) + } + } } diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index fb921a89a..978fdf77c 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import { EventType } from '@tanstack/ai/client' -import { GenerationClient, UnsupportedResponseStreamError } from '../src' +import { + GenerationClient, + UnsupportedResponseStreamError, + VideoGenerationClient, +} from '../src' import type { StreamChunk } from '@tanstack/ai/client' import type { ConnectConnectionAdapter } from '../src/connection-adapters' +import type { + GenerationResumeSnapshot, + GenerationServerPersistence, +} from '../src' // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( @@ -17,6 +25,34 @@ function createMockConnection( } } +function createDeferred(): { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +async function waitForCondition(assertion: () => void): Promise { + let lastError: unknown + for (let attempt = 0; attempt < 20; attempt++) { + try { + assertion() + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + throw lastError +} + describe('GenerationClient', () => { describe('fetcher mode', () => { it('should generate a result using fetcher', async () => { @@ -433,6 +469,266 @@ describe('GenerationClient', () => { expect(client.getStatus()).toBe('idle') }) + it('should ignore chunks yielded after stop() by an abort-ignoring connection', async () => { + const onResult = vi.fn() + const aborted = createDeferred() + + const connection: ConnectConnectionAdapter = { + async *connect(_msgs, _data, signal) { + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } + signal?.addEventListener('abort', () => aborted.resolve(undefined), { + once: true, + }) + await aborted.promise + yield { + type: EventType.CUSTOM as const, + name: 'generation:result', + value: { id: 'late-result' }, + timestamp: Date.now(), + } + yield { + type: EventType.RUN_FINISHED as const, + runId: 'run-1', + threadId: 'thread-1', + finishReason: 'stop' as const, + timestamp: Date.now(), + } + }, + } + + const client = new GenerationClient({ + connection, + onResult, + }) + + const generatePromise = client.generate({ prompt: 'test' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + client.stop() + await generatePromise + + expect(onResult).not.toHaveBeenCalled() + expect(client.getResult()).toBeNull() + expect(client.getStatus()).toBe('idle') + }) + + it('should not let a stopped run clear the controller for a newer generation', async () => { + const firstAborted = createDeferred() + const firstCanFinish = createDeferred() + const secondAborted = createDeferred() + const signals: Array = [] + + const connection: ConnectConnectionAdapter = { + async *connect(_msgs, data, signal) { + signals.push(signal) + if (data?.prompt === 'first') { + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } + signal?.addEventListener( + 'abort', + () => firstAborted.resolve(undefined), + { once: true }, + ) + await firstAborted.promise + await firstCanFinish.promise + yield { + type: EventType.CUSTOM as const, + name: 'generation:result', + value: { id: 'late-first' }, + timestamp: Date.now(), + } + return + } + + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-2', + threadId: 'thread-1', + timestamp: Date.now(), + } + signal?.addEventListener( + 'abort', + () => secondAborted.resolve(undefined), + { once: true }, + ) + await secondAborted.promise + }, + } + + const client = new GenerationClient({ + connection, + }) + + const firstGenerate = client.generate({ prompt: 'first' }) + await waitForCondition(() => { + expect(signals).toHaveLength(1) + }) + + client.stop() + const secondGenerate = client.generate({ prompt: 'second' }) + await waitForCondition(() => { + expect(signals).toHaveLength(2) + expect(client.getIsLoading()).toBe(true) + }) + + firstCanFinish.resolve(undefined) + await firstGenerate + + expect(client.getIsLoading()).toBe(true) + + client.stop() + expect(signals[1]?.aborted).toBe(true) + + await secondGenerate + expect(client.getIsLoading()).toBe(false) + }) + + it('should ignore video chunks yielded after stop() by an abort-ignoring connection', async () => { + const onResult = vi.fn() + const onStatusUpdate = vi.fn() + const aborted = createDeferred() + + const connection: ConnectConnectionAdapter = { + async *connect(_msgs, _data, signal) { + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } + signal?.addEventListener('abort', () => aborted.resolve(undefined), { + once: true, + }) + await aborted.promise + yield { + type: EventType.CUSTOM as const, + name: 'generation:result', + value: { id: 'late-video' }, + timestamp: Date.now(), + } + yield { + type: EventType.CUSTOM as const, + name: 'video:status', + value: { status: 'completed', progress: 100 }, + timestamp: Date.now(), + } + yield { + type: EventType.RUN_FINISHED as const, + runId: 'run-1', + threadId: 'thread-1', + finishReason: 'stop' as const, + timestamp: Date.now(), + } + }, + } + + const client = new VideoGenerationClient({ + connection, + onResult, + onStatusUpdate, + }) + + const generatePromise = client.generate({ prompt: 'test' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + client.stop() + await generatePromise + + expect(onResult).not.toHaveBeenCalled() + expect(onStatusUpdate).not.toHaveBeenCalled() + expect(client.getResult()).toBeNull() + expect(client.getVideoStatus()).toBeNull() + expect(client.getStatus()).toBe('idle') + }) + + it('should not let a stopped video run clear the controller for a newer generation', async () => { + const firstAborted = createDeferred() + const firstCanFinish = createDeferred() + const secondAborted = createDeferred() + const signals: Array = [] + + const connection: ConnectConnectionAdapter = { + async *connect(_msgs, data, signal) { + signals.push(signal) + if (data?.prompt === 'first') { + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } + signal?.addEventListener( + 'abort', + () => firstAborted.resolve(undefined), + { once: true }, + ) + await firstAborted.promise + await firstCanFinish.promise + yield { + type: EventType.CUSTOM as const, + name: 'generation:result', + value: { + jobId: 'late-first', + status: 'completed', + url: 'https://example.com/late.mp4', + }, + timestamp: Date.now(), + } + return + } + + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-2', + threadId: 'thread-1', + timestamp: Date.now(), + } + signal?.addEventListener( + 'abort', + () => secondAborted.resolve(undefined), + { once: true }, + ) + await secondAborted.promise + }, + } + + const client = new VideoGenerationClient({ + connection, + }) + + const firstGenerate = client.generate({ prompt: 'first' }) + await waitForCondition(() => { + expect(signals).toHaveLength(1) + }) + + client.stop() + const secondGenerate = client.generate({ prompt: 'second' }) + await waitForCondition(() => { + expect(signals).toHaveLength(2) + expect(client.getIsLoading()).toBe(true) + }) + + firstCanFinish.resolve(undefined) + await firstGenerate + + expect(client.getIsLoading()).toBe(true) + + client.stop() + expect(signals[1]?.aborted).toBe(true) + + await secondGenerate + expect(client.getIsLoading()).toBe(false) + }) + it('should not set result if fetcher resolves after stop()', async () => { let resolvePromise: (value: { id: string }) => void const onResult = vi.fn() @@ -1031,4 +1327,131 @@ describe('GenerationClient', () => { expect(states).toEqual(['generating', 'error']) }) }) + + describe('resume snapshot persistence', () => { + it('reports rejected persistence writes without rejecting generation', async () => { + const warningSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const persistenceError = new Error('persistence failed') + const persistence: GenerationServerPersistence = { + getItem: vi.fn(), + setItem: vi.fn(async () => { + throw persistenceError + }), + removeItem: vi.fn(), + } + const client = new GenerationClient({ + connection: createMockConnection([ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + ]), + persistence: { server: persistence }, + }) + + await expect(client.generate({ prompt: 'test' })).resolves.toBeUndefined() + + await waitForCondition(() => { + expect(warningSpy).toHaveBeenCalledWith( + '[TanStack AI] Failed to persist generation resume snapshot', + persistenceError, + ) + }) + + warningSpy.mockRestore() + }) + + it('keeps a delayed running write from overwriting a terminal complete snapshot', async () => { + const runningWrite = createDeferred() + let storedSnapshot: GenerationResumeSnapshot | undefined + const persistence: GenerationServerPersistence = { + getItem: vi.fn(), + setItem: vi.fn(async (_id, snapshot) => { + if (snapshot.status === 'running') { + await runningWrite.promise + } + storedSnapshot = snapshot + }), + removeItem: vi.fn(), + } + const client = new GenerationClient({ + connection: createMockConnection([ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + finishReason: 'stop', + timestamp: Date.now(), + }, + ]), + persistence: { server: persistence }, + }) + + await client.generate({ prompt: 'test' }) + expect(persistence.setItem).toHaveBeenCalledTimes(1) + runningWrite.resolve(undefined) + + await waitForCondition(() => { + expect(persistence.setItem).toHaveBeenCalledTimes(2) + expect(storedSnapshot).toMatchObject({ + status: 'complete', + resumeState: null, + }) + }) + }) + + it('keeps a delayed video running write from overwriting a terminal error snapshot', async () => { + const runningWrite = createDeferred() + let storedSnapshot: GenerationResumeSnapshot | undefined + const persistence: GenerationServerPersistence = { + getItem: vi.fn(), + setItem: vi.fn(async (_id, snapshot) => { + if (snapshot.status === 'running') { + await runningWrite.promise + } + storedSnapshot = snapshot + }), + removeItem: vi.fn(), + } + const client = new VideoGenerationClient({ + connection: createMockConnection([ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.RUN_ERROR, + runId: 'run-1', + threadId: 'thread-1', + message: 'Video failed', + timestamp: Date.now(), + }, + ]), + persistence: { server: persistence }, + }) + + await client.generate({ prompt: 'test' }) + expect(persistence.setItem).toHaveBeenCalledTimes(1) + runningWrite.resolve(undefined) + + await waitForCondition(() => { + expect(persistence.setItem).toHaveBeenCalledTimes(2) + expect(storedSnapshot).toMatchObject({ + status: 'error', + resumeState: null, + error: { message: 'Video failed' }, + }) + }) + }) + }) }) diff --git a/packages/ai-client/tests/generation-resume-state.test.ts b/packages/ai-client/tests/generation-resume-state.test.ts new file mode 100644 index 000000000..74f8fad78 --- /dev/null +++ b/packages/ai-client/tests/generation-resume-state.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest' +import { EventType } from '@tanstack/ai/client' +import { + GENERATION_EVENTS, + updateGenerationResumeSnapshot, +} from '../src/generation-types' +import type { PersistedArtifactRef, StreamChunk } from '@tanstack/ai/client' +import type { GenerationResumeSnapshot } from '../src/generation-types' + +const artifactRef: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-1', + threadId: 'thread-1', + runId: 'run-1', + name: 'image.png', + mimeType: 'image/png', + size: 1234, + createdAt: '2026-07-06T00:00:00.000Z', + source: { + activity: 'image', + path: 'thread-1/run-1/image.png', + provider: 'test', + model: 'image-model', + mediaType: 'image', + }, +} + +function reduceChunks( + chunks: ReadonlyArray, + initial?: GenerationResumeSnapshot, +): GenerationResumeSnapshot { + let snapshot = initial + for (const chunk of chunks) { + snapshot = updateGenerationResumeSnapshot(snapshot, chunk) + } + if (!snapshot) { + throw new Error('Expected at least one generation event') + } + return snapshot +} + +describe('generation resume state reducer', () => { + it('tracks thread and run from persisted generation events', () => { + const snapshot = reduceChunks([ + { + type: EventType.RUN_STARTED, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 1, + }, + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.PROGRESS, + value: { progress: 50, message: 'Halfway' }, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 2, + }, + ]) + + expect(snapshot).toMatchObject({ + resumeState: { + threadId: 'thread-1', + runId: 'run-1', + }, + status: 'running', + lastEvent: { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.PROGRESS, + }, + }) + }) + + it('stores generation artifacts as persisted refs only', () => { + const snapshot = reduceChunks([ + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.ARTIFACTS, + value: [ + artifactRef, + { + b64Json: 'raw-media-bytes', + url: 'data:image/png;base64,raw-media-bytes', + }, + ], + threadId: 'thread-1', + runId: 'run-1', + timestamp: 1, + }, + ]) + + expect(snapshot.pendingArtifacts).toEqual([artifactRef]) + expect(JSON.stringify(snapshot)).not.toContain('raw-media-bytes') + expect(snapshot.activity).toBe('image') + }) + + it('sanitizes artifact refs before storing them in resume snapshots', () => { + const unsafeArtifactRef = { + ...artifactRef, + externalUrl: 'data:image/png;base64,raw-artifact-bytes', + b64Json: 'raw-artifact-bytes', + blob: new Blob(['raw-artifact-bytes']), + url: `https://example.com/${'x'.repeat(4096)}`, + source: { + ...artifactRef.source, + extraRawField: 'raw-artifact-bytes', + }, + } + + const snapshot = reduceChunks([ + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.ARTIFACTS, + value: [unsafeArtifactRef], + threadId: 'thread-1', + runId: 'run-1', + timestamp: 1, + }, + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.RESULT, + value: { + id: 'result-1', + artifacts: [unsafeArtifactRef], + }, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 2, + }, + ]) + + expect(snapshot.pendingArtifacts).toEqual([artifactRef]) + expect(snapshot.result?.artifacts).toEqual([artifactRef]) + expect(JSON.stringify(snapshot)).not.toContain('raw-artifact-bytes') + expect(JSON.stringify(snapshot)).not.toContain('b64Json') + expect(JSON.stringify(snapshot)).not.toContain('extraRawField') + }) + + it('stores terminal result metadata without raw generated bytes', () => { + const snapshot = reduceChunks([ + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.RESULT, + value: { + id: 'result-1', + model: 'image-model', + images: [ + { + b64Json: 'raw-image-bytes', + url: 'data:image/png;base64,raw-image-bytes', + revisedPrompt: 'clean prompt', + }, + ], + artifacts: [artifactRef], + }, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 2, + }, + { + type: EventType.RUN_FINISHED, + threadId: 'thread-1', + runId: 'run-1', + finishReason: 'stop', + timestamp: 3, + }, + ]) + + expect(snapshot.resumeState).toBeNull() + expect(snapshot.status).toBe('complete') + expect(snapshot.result).toMatchObject({ + id: 'result-1', + model: 'image-model', + artifacts: [artifactRef], + }) + expect(JSON.stringify(snapshot)).not.toContain('raw-image-bytes') + expect(JSON.stringify(snapshot)).not.toContain('b64Json') + }) + + it('omits non-durable and oversized result URLs from resume snapshots', () => { + const oversizedUrl = `https://example.com/${'x'.repeat(4096)}` + const unsafeUrls = [ + 'data:image/png;base64,raw-image-bytes', + 'blob:https://example.com/raw-image-bytes', + oversizedUrl, + ] + + for (const url of unsafeUrls) { + const snapshot = reduceChunks([ + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.RESULT, + value: { + id: 'result-1', + model: 'image-model', + url, + artifacts: [artifactRef], + }, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 2, + }, + ]) + + expect(snapshot.result).toMatchObject({ + id: 'result-1', + model: 'image-model', + artifacts: [artifactRef], + }) + expect(snapshot.result).not.toHaveProperty('url') + expect(JSON.stringify(snapshot)).not.toContain('raw-image-bytes') + expect(JSON.stringify(snapshot)).not.toContain(oversizedUrl) + } + }) + + it('clears resume state and stores lightweight error metadata on terminal errors', () => { + const snapshot = reduceChunks([ + { + type: EventType.RUN_STARTED, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 1, + }, + { + type: EventType.RUN_ERROR, + threadId: 'thread-1', + runId: 'run-1', + message: 'Generation failed', + code: 'provider_error', + error: { message: 'legacy message' }, + timestamp: 2, + }, + ]) + + expect(snapshot).toMatchObject({ + resumeState: null, + status: 'error', + error: { + message: 'Generation failed', + code: 'provider_error', + }, + }) + }) + + it('does not model explicit stop as durable cancel state', () => { + const snapshot = reduceChunks([ + { + type: EventType.RUN_STARTED, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 1, + }, + ]) + + expect(snapshot.status).toBe('running') + expect(snapshot).not.toHaveProperty('cancelled') + expect(snapshot).not.toHaveProperty('cancelEndpoint') + }) +}) diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 062faabdd..7465168be 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -614,6 +614,8 @@ export interface SummarizeUsageEvent extends BaseEventContext { /** Emitted when an image request starts. */ export interface ImageRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string prompt: string @@ -630,6 +632,8 @@ export interface ImageRequestStartedEvent extends BaseEventContext { /** Emitted when an image request completes. */ export interface ImageRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string images: Array<{ url?: string; b64Json?: string }> @@ -639,6 +643,8 @@ export interface ImageRequestCompletedEvent extends BaseEventContext { /** Emitted when image usage metrics are available. */ export interface ImageUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } @@ -650,6 +656,8 @@ export interface ImageUsageEvent extends BaseEventContext { /** Emitted when a speech request starts. */ export interface SpeechRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string text: string @@ -661,6 +669,8 @@ export interface SpeechRequestStartedEvent extends BaseEventContext { /** Emitted when a speech request completes. */ export interface SpeechRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string audio: string @@ -673,6 +683,8 @@ export interface SpeechRequestCompletedEvent extends BaseEventContext { /** Emitted when speech usage metrics are available. */ export interface SpeechUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } @@ -684,6 +696,8 @@ export interface SpeechUsageEvent extends BaseEventContext { /** Emitted when a transcription request starts. */ export interface TranscriptionRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string language?: string @@ -694,6 +708,8 @@ export interface TranscriptionRequestStartedEvent extends BaseEventContext { /** Emitted when a transcription request completes. */ export interface TranscriptionRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string text: string @@ -704,6 +720,8 @@ export interface TranscriptionRequestCompletedEvent extends BaseEventContext { /** Emitted when transcription usage metrics are available. */ export interface TranscriptionUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } @@ -715,6 +733,8 @@ export interface TranscriptionUsageEvent extends BaseEventContext { /** Emitted when an audio generation request starts. */ export interface AudioRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string prompt: string @@ -743,6 +763,8 @@ export type AudioRequestCompletedAudio = /** Emitted when an audio generation request completes. */ export interface AudioRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string audio: AudioRequestCompletedAudio @@ -752,6 +774,8 @@ export interface AudioRequestCompletedEvent extends BaseEventContext { /** Emitted when an audio generation request fails. */ export interface AudioRequestErrorEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string error: { message: string; name?: string } @@ -761,6 +785,8 @@ export interface AudioRequestErrorEvent extends BaseEventContext { /** Emitted when a speech generation request fails. */ export interface SpeechRequestErrorEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string error: { message: string; name?: string } @@ -770,6 +796,8 @@ export interface SpeechRequestErrorEvent extends BaseEventContext { /** Emitted when a transcription request fails. */ export interface TranscriptionRequestErrorEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string error: { message: string; name?: string } @@ -779,6 +807,8 @@ export interface TranscriptionRequestErrorEvent extends BaseEventContext { /** Emitted when audio usage metrics are available. */ export interface AudioUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } @@ -790,6 +820,8 @@ export interface AudioUsageEvent extends BaseEventContext { /** Emitted when a video request starts. */ export interface VideoRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string requestType: 'create' | 'status' | 'url' @@ -802,6 +834,8 @@ export interface VideoRequestStartedEvent extends BaseEventContext { /** Emitted when a video request completes. */ export interface VideoRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string requestType: 'create' | 'status' | 'url' @@ -816,6 +850,8 @@ export interface VideoRequestCompletedEvent extends BaseEventContext { /** Emitted when video usage metrics are available. */ export interface VideoUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index a5f8223c0..402566ef0 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -6,8 +6,13 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGenerateAudio hook. @@ -25,6 +30,10 @@ export interface UseGenerateAudioOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app. */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when audio is generated. Can optionally return a transformed value. * @@ -61,6 +70,14 @@ export interface UseGenerateAudioReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: GenerationResumeSnapshot | undefined + /** Current resumable run/cursor state, if one is available */ + resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Array } /** @@ -106,19 +123,19 @@ export function useGenerateAudio( hookName: 'useGenerateAudio', outputKind: 'audio' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + AudioGenerateInput, + AudioGenerationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: AudioGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: AudioGenerateInput, + ) => Promise, } } diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index 078a792d1..0f6b06c78 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -5,9 +5,14 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, ImageGenerateInput, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGenerateImage hook. @@ -25,6 +30,10 @@ export interface UseGenerateImageOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app. */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when images are generated. Can optionally return a transformed value. * @@ -61,6 +70,14 @@ export interface UseGenerateImageReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: GenerationResumeSnapshot | undefined + /** Current resumable run/cursor state, if one is available */ + resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Array } /** @@ -108,19 +125,19 @@ export function useGenerateImage( hookName: 'useGenerateImage', outputKind: 'image' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + ImageGenerateInput, + ImageGenerationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: ImageGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: ImageGenerateInput, + ) => Promise, } } diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index b22199ff5..65da8e3dc 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -5,9 +5,14 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, SpeechGenerateInput, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGenerateSpeech hook. @@ -25,6 +30,10 @@ export interface UseGenerateSpeechOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app. */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when speech is generated. Can optionally return a transformed value. * @@ -61,6 +70,14 @@ export interface UseGenerateSpeechReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: GenerationResumeSnapshot | undefined + /** Current resumable run/cursor state, if one is available */ + resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Array } /** @@ -102,19 +119,19 @@ export function useGenerateSpeech( hookName: 'useGenerateSpeech', outputKind: 'audio' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + SpeechGenerateInput, + TTSResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: SpeechGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: SpeechGenerateInput, + ) => Promise, } } diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index 3aece73c5..0a2f015cc 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -7,11 +7,16 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGenerateVideo hook. @@ -27,6 +32,10 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when video generation completes. Can optionally return a transformed value. * @@ -71,6 +80,14 @@ export interface UseGenerateVideoReturn { stop: () => void /** Clear all state and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: GenerationResumeSnapshot | undefined + /** Current resumable run/cursor state, if one is available */ + resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Array } /** @@ -127,6 +144,9 @@ export function useGenerateVideo( const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(undefined) const [status, setStatus] = useState('idle') + const [resumeSnapshot, setResumeSnapshot] = useState< + GenerationResumeSnapshot | undefined + >(options.initialResumeSnapshot) const optionsRef = useRef(options) optionsRef.current = options @@ -142,6 +162,10 @@ export function useGenerateVideo( const baseOptions = { id: clientId, body: opts.body, + ...(opts.persistence !== undefined && { persistence: opts.persistence }), + ...(opts.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: opts.initialResumeSnapshot, + }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...opts.devtools, @@ -177,6 +201,7 @@ export function useGenerateVideo( onStatusChange: setStatus, onJobIdChange: setJobId, onVideoStatusChange: setVideoStatus, + onResumeSnapshotChange: setResumeSnapshot, } if (opts.connection) { @@ -206,7 +231,8 @@ export function useGenerateVideo( }) }, [client, options.body]) - // Cleanup on unmount + // Mount devtools and clean up on unmount. Generation runs are never + // auto-started on mount — persisted state is read-only for display. useEffect(() => { client.mountDevtools() @@ -240,5 +266,9 @@ export function useGenerateVideo( status, stop, reset, + resumeSnapshot, + resumeState: resumeSnapshot?.resumeState ?? null, + pendingArtifacts: resumeSnapshot?.pendingArtifacts ?? [], + resultArtifacts: resumeSnapshot?.result?.artifacts ?? [], } } diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 6e5557d9b..55ead65a3 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -8,8 +8,13 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGeneration hook. @@ -31,6 +36,10 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. * @@ -67,6 +76,14 @@ export interface UseGenerationReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation state snapshot, if one is available */ + resumeSnapshot: GenerationResumeSnapshot | undefined + /** Observed run/cursor metadata from the snapshot (read-only state) */ + resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Array } /** @@ -111,6 +128,9 @@ export function useGeneration< const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(undefined) const [status, setStatus] = useState('idle') + const [resumeSnapshot, setResumeSnapshot] = useState< + GenerationResumeSnapshot | undefined + >(options.initialResumeSnapshot) const optionsRef = useRef(options) optionsRef.current = options @@ -125,6 +145,10 @@ export function useGeneration< const clientOptions: GenerationClientOptions = { id: clientId, body: opts.body, + ...(opts.persistence !== undefined && { persistence: opts.persistence }), + ...(opts.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: opts.initialResumeSnapshot, + }), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { hookName: 'useGeneration', @@ -150,6 +174,7 @@ export function useGeneration< onLoadingChange: setIsLoading, onErrorChange: setError, onStatusChange: setStatus, + onResumeSnapshotChange: setResumeSnapshot, } if (opts.connection) { @@ -179,7 +204,8 @@ export function useGeneration< }) }, [client, options.body]) - // Cleanup on unmount + // Mount devtools and clean up on unmount. Generation runs are never + // auto-started on mount — persisted state is read-only for display. useEffect(() => { client.mountDevtools() @@ -211,5 +237,9 @@ export function useGeneration< status, stop, reset, + resumeSnapshot, + resumeState: resumeSnapshot?.resumeState ?? null, + pendingArtifacts: resumeSnapshot?.pendingArtifacts ?? [], + resultArtifacts: resumeSnapshot?.result?.artifacts ?? [], } } diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index 9de8eeb71..9e36d0263 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -5,9 +5,14 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, SummarizeGenerateInput, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useSummarize hook. @@ -25,6 +30,10 @@ export interface UseSummarizeOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app. */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when summarization is complete. Can optionally return a transformed value. * @@ -61,6 +70,14 @@ export interface UseSummarizeReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: GenerationResumeSnapshot | undefined + /** Current resumable run/cursor state, if one is available */ + resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Array } /** @@ -105,19 +122,19 @@ export function useSummarize( hookName: 'useSummarize', outputKind: 'text' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + SummarizeGenerateInput, + SummarizationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: SummarizeGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: SummarizeGenerateInput, + ) => Promise, } } diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index d0e01df8c..b752bce39 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -5,9 +5,14 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, TranscriptionGenerateInput, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useTranscription hook. @@ -25,6 +30,10 @@ export interface UseTranscriptionOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app. */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when transcription is complete. Can optionally return a transformed value. * @@ -61,6 +70,14 @@ export interface UseTranscriptionReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: GenerationResumeSnapshot | undefined + /** Current resumable run/cursor state, if one is available */ + resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Array } /** @@ -110,20 +127,16 @@ export function useTranscription( hookName: 'useTranscription', outputKind: 'text' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration< - TranscriptionGenerateInput, - TranscriptionResult, - TTransformed - >({ ...options, devtools }) + const generation = useGeneration< + TranscriptionGenerateInput, + TranscriptionResult, + TTransformed + >({ ...options, devtools }) return { - generate: generate as (input: TranscriptionGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: TranscriptionGenerateInput, + ) => Promise, } } diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index f3540b520..fa323e69e 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -8,8 +8,19 @@ import { useTranscription } from '../src/use-transcription' import { useSummarize } from '../src/use-summarize' import { useGenerateVideo } from '../src/use-generate-video' import { createMockConnectionAdapter } from './test-utils' -import type { StreamChunk, TTSResult, TranscriptionResult } from '@tanstack/ai' +import type { + PersistedArtifactRef, + StreamChunk, + TTSResult, + TranscriptionResult, +} from '@tanstack/ai' import { EventType } from '@tanstack/ai' +import type { + ConnectConnectionAdapter, + GenerationResumeSnapshot, + GenerationServerPersistence, + RunAgentInputContext, +} from '@tanstack/ai-client' // Helper to create generation stream chunks function createGenerationChunks(result: unknown): Array { @@ -71,6 +82,87 @@ function createVideoChunks(jobId: string, url: string): Array { ] } +const videoResumeSnapshot: GenerationResumeSnapshot = { + resumeState: { + threadId: 'thread-resume', + runId: 'run-resume', + }, + status: 'running', +} + +const replayedVideoArtifact: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-video-1', + threadId: 'thread-resume', + runId: 'run-resume', + name: 'video.mp4', + mimeType: 'video/mp4', + size: 1234, + createdAt: '2026-07-06T00:00:00.000Z', + externalUrl: 'https://example.com/video.mp4', + source: { + activity: 'video', + path: 'runs/run-resume/video.mp4', + provider: 'test', + model: 'test-video', + mediaType: 'video', + jobId: 'job-replay', + expiresAt: '2026-07-07T00:00:00.000Z', + }, +} + +function createReplayVideoChunks(): Array { + return [ + { + type: EventType.RUN_STARTED, + runId: 'run-resume', + threadId: 'thread-resume', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:result', + value: { + jobId: 'job-replay', + status: 'completed', + url: 'https://example.com/video.mp4', + artifacts: [replayedVideoArtifact], + }, + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-resume', + threadId: 'thread-resume', + timestamp: Date.now(), + }, + ] +} + +function createRunContextCaptureAdapter(chunks: Array): { + adapter: ConnectConnectionAdapter + connect: ReturnType + runContexts: Array +} { + const runContexts: Array = [] + const connect = vi.fn() + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, _signal, runContext) { + connect(runContext) + runContexts.push(runContext) + for (const chunk of chunks) { + yield chunk + } + }, + } + return { adapter, connect, runContexts } +} + +async function flushPromises(): Promise { + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + // Helper to create error stream chunks. // NOTE: The AG-UI spec for RUN_ERROR carries `message` directly on the event // (not nested under `error`). We emit BOTH shapes here because GenerationClient @@ -276,6 +368,52 @@ describe('useGeneration', () => { // Resolve the promise after unmount — should not cause errors resolvePromise!({ id: '1' }) }) + + it('does not auto-fire a generation on mount from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface: mounting a + // generation hook that has server persistence and a persisted `running` + // snapshot must NOT start a fresh empty-prompt generation (previously + // `maybeAutoResume()` -> `resume()` -> `generate({})` fired here). + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const persistence: GenerationServerPersistence = { + getItem: vi.fn(() => ({ + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderHook(() => + useGeneration({ + id: 'no-auto-fire', + connection: adapter, + persistence: { server: persistence }, + initialResumeSnapshot: { + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running', + }, + }), + ) + + await act(async () => { + await flushPromises() + }) + + expect(connect).not.toHaveBeenCalled() + // Persisted state is read-only for display; the client never reads it + // back to drive a resume, so getItem is not consulted on mount. + expect(persistence.getItem).not.toHaveBeenCalled() + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + // The persisted snapshot is still exposed as read-only state. + expect(result.current.resumeState).toEqual({ + threadId: 'thread-resume', + runId: 'run-resume', + }) + }) }) }) @@ -535,7 +673,7 @@ describe('useSummarize', () => { const mockResult = { id: 'sum-1', summary: 'A brief summary', - model: 'gpt-4', + model: 'gpt-5.5', usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120 }, } @@ -554,7 +692,7 @@ describe('useSummarize', () => { }) it('should summarize text using connection', async () => { - const mockResult = { summary: 'A brief summary', model: 'gpt-4' } + const mockResult = { summary: 'A brief summary', model: 'gpt-5.5' } const chunks = createGenerationChunks(mockResult) const adapter = createMockConnectionAdapter({ chunks }) @@ -681,6 +819,39 @@ describe('useGenerateVideo', () => { expect(result.current.videoStatus).toBeNull() expect(result.current.status).toBe('idle') }) + + it('does not auto-fire a video generation on mount from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface (video). + const { adapter, connect } = createRunContextCaptureAdapter( + createReplayVideoChunks(), + ) + const persistence: GenerationServerPersistence = { + getItem: vi.fn(() => videoResumeSnapshot), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderHook(() => + useGenerateVideo({ + id: 'video-no-auto-fire', + connection: adapter, + persistence: { server: persistence }, + initialResumeSnapshot: videoResumeSnapshot, + }), + ) + + await act(async () => { + await flushPromises() + }) + + expect(connect).not.toHaveBeenCalled() + expect(persistence.getItem).not.toHaveBeenCalled() + expect(result.current.isLoading).toBe(false) + expect(result.current.status).toBe('idle') + // The persisted snapshot remains exposed as read-only state. + expect(result.current.resumeSnapshot).toEqual(videoResumeSnapshot) + expect(result.current.resumeState).toEqual(videoResumeSnapshot.resumeState) + }) }) describe('onResult transform', () => { diff --git a/packages/ai-solid/src/use-generate-audio.ts b/packages/ai-solid/src/use-generate-audio.ts index 94bfac297..fdca21fac 100644 --- a/packages/ai-solid/src/use-generate-audio.ts +++ b/packages/ai-solid/src/use-generate-audio.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,12 @@ import type { Accessor } from 'solid-js' * * @template TOutput - The transformed output type (defaults to AudioGenerationResult) */ -export interface UseGenerateAudioOptions { +export interface UseGenerateAudioOptions< + TOutput = AudioGenerationResult, +> extends Pick< + UseGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for audio generation */ @@ -47,7 +56,9 @@ export interface UseGenerateAudioOptions { * * @template TOutput - The transformed output type (defaults to AudioGenerationResult) */ -export interface UseGenerateAudioReturn { +export interface UseGenerateAudioReturn< + TOutput = AudioGenerationResult, +> extends Omit, 'generate'> { /** Trigger audio generation */ generate: (input: AudioGenerateInput) => Promise /** The generation result containing audio, or null */ @@ -58,10 +69,6 @@ export interface UseGenerateAudioReturn { error: Accessor /** Current state of the generation */ status: Accessor - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -101,19 +108,19 @@ export function useGenerateAudio( hookName: 'useGenerateAudio', outputKind: 'audio' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + AudioGenerateInput, + AudioGenerationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: AudioGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: AudioGenerateInput, + ) => Promise, } } diff --git a/packages/ai-solid/src/use-generate-image.ts b/packages/ai-solid/src/use-generate-image.ts index b88902163..5a85ba6f3 100644 --- a/packages/ai-solid/src/use-generate-image.ts +++ b/packages/ai-solid/src/use-generate-image.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { ImageGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,12 @@ import type { Accessor } from 'solid-js' * * @template TOutput - The transformed output type (defaults to ImageGenerationResult) */ -export interface UseGenerateImageOptions { +export interface UseGenerateImageOptions< + TOutput = ImageGenerationResult, +> extends Pick< + UseGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for image generation */ @@ -47,7 +56,9 @@ export interface UseGenerateImageOptions { * * @template TOutput - The transformed output type (defaults to ImageGenerationResult) */ -export interface UseGenerateImageReturn { +export interface UseGenerateImageReturn< + TOutput = ImageGenerationResult, +> extends Omit, 'generate'> { /** Trigger image generation */ generate: (input: ImageGenerateInput) => Promise /** The generation result containing images, or null */ @@ -58,10 +69,6 @@ export interface UseGenerateImageReturn { error: Accessor /** Current state of the generation */ status: Accessor - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -109,19 +116,19 @@ export function useGenerateImage( hookName: 'useGenerateImage', outputKind: 'image' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + ImageGenerateInput, + ImageGenerationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: ImageGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: ImageGenerateInput, + ) => Promise, } } diff --git a/packages/ai-solid/src/use-generate-speech.ts b/packages/ai-solid/src/use-generate-speech.ts index b682e71a4..96fd5a5cc 100644 --- a/packages/ai-solid/src/use-generate-speech.ts +++ b/packages/ai-solid/src/use-generate-speech.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { StreamChunk, TTSResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,10 @@ import type { Accessor } from 'solid-js' * * @template TOutput - The transformed output type (defaults to TTSResult) */ -export interface UseGenerateSpeechOptions { +export interface UseGenerateSpeechOptions extends Pick< + UseGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for speech generation */ @@ -47,7 +54,10 @@ export interface UseGenerateSpeechOptions { * * @template TOutput - The transformed output type (defaults to TTSResult) */ -export interface UseGenerateSpeechReturn { +export interface UseGenerateSpeechReturn extends Omit< + UseGenerationReturn, + 'generate' +> { /** Trigger speech generation */ generate: (input: SpeechGenerateInput) => Promise /** The TTS result containing audio data, or null */ @@ -58,10 +68,6 @@ export interface UseGenerateSpeechReturn { error: Accessor /** Current state of the generation */ status: Accessor - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -103,19 +109,19 @@ export function useGenerateSpeech( hookName: 'useGenerateSpeech', outputKind: 'audio' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + SpeechGenerateInput, + TTSResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: SpeechGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: SpeechGenerateInput, + ) => Promise, } } diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index 4fd68b5de..0b8af0fd6 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -14,11 +14,16 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { Accessor } from 'solid-js' /** @@ -37,6 +42,10 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when video generation completes. Can optionally return a transformed value. * @@ -81,6 +90,14 @@ export interface UseGenerateVideoReturn { stop: () => void /** Clear all state and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: Accessor + /** Observed run/cursor metadata from the snapshot (read-only state) */ + resumeState: Accessor + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Accessor> + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Accessor> } /** @@ -139,6 +156,10 @@ export function useGenerateVideo( const [isLoading, setIsLoading] = createSignal(false) const [error, setError] = createSignal(undefined) const [status, setStatus] = createSignal('idle') + const [resumeSnapshot, setResumeSnapshot] = createSignal< + GenerationResumeSnapshot | undefined + >(options.initialResumeSnapshot) + let disposed = false const client = createMemo(() => { // Conditional spread on `body`: VideoGenerationClientOptions.body @@ -146,6 +167,12 @@ export function useGenerateVideo( const baseOptions = { id: clientId, body: options.body, + ...(options.persistence !== undefined && { + persistence: options.persistence, + }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...options.devtools, @@ -159,17 +186,44 @@ export function useGenerateVideo( onResult: ((r: VideoGenerateResult) => options.onResult?.(r)) as ( result: VideoGenerateResult, ) => TOutput | null | void, - onError: (e: Error) => options.onError?.(e), - onProgress: (p: number, m?: string) => options.onProgress?.(p, m), - onChunk: (c: StreamChunk) => options.onChunk?.(c), - onJobCreated: (id: string) => options.onJobCreated?.(id), - onStatusUpdate: (s: VideoStatusInfo) => options.onStatusUpdate?.(s), - onResultChange: setResult, - onLoadingChange: setIsLoading, - onErrorChange: setError, - onStatusChange: setStatus, - onJobIdChange: setJobId, - onVideoStatusChange: setVideoStatus, + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, + onJobCreated: (id: string) => { + if (!disposed) options.onJobCreated?.(id) + }, + onStatusUpdate: (s: VideoStatusInfo) => { + if (!disposed) options.onStatusUpdate?.(s) + }, + onResultChange: (r: TOutput | null) => { + if (!disposed) setResult(() => r) + }, + onLoadingChange: (l: boolean) => { + if (!disposed) setIsLoading(l) + }, + onErrorChange: (e: Error | undefined) => { + if (!disposed) setError(e) + }, + onStatusChange: (s: GenerationClientState) => { + if (!disposed) setStatus(s) + }, + onJobIdChange: (id: string | null) => { + if (!disposed) setJobId(id) + }, + onVideoStatusChange: (s: VideoStatusInfo | null) => { + if (!disposed) setVideoStatus(s) + }, + onResumeSnapshotChange: ( + snapshot: GenerationResumeSnapshot | undefined, + ) => { + if (!disposed) setResumeSnapshot(snapshot) + }, } if (options.connection) { @@ -199,12 +253,15 @@ export function useGenerateVideo( }) }) + // Mount devtools only. Generation runs are never auto-started on mount — + // persisted state is read-only for display. onMount(() => { client().mountDevtools() }) // Cleanup on unmount: stop any in-flight requests and unregister devtools onCleanup(() => { + disposed = true client().dispose() }) @@ -230,5 +287,9 @@ export function useGenerateVideo( status, stop, reset, + resumeSnapshot, + resumeState: () => resumeSnapshot()?.resumeState ?? null, + pendingArtifacts: () => resumeSnapshot()?.pendingArtifacts ?? [], + resultArtifacts: () => resumeSnapshot()?.result?.artifacts ?? [], } } diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index 2db94a579..bd78244fb 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -15,8 +15,13 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { Accessor } from 'solid-js' /** @@ -39,6 +44,10 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. * @@ -75,6 +84,14 @@ export interface UseGenerationReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: Accessor + /** Observed run/cursor metadata from the snapshot (read-only state) */ + resumeState: Accessor + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: Accessor> + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: Accessor> } /** @@ -120,6 +137,10 @@ export function useGeneration< const [isLoading, setIsLoading] = createSignal(false) const [error, setError] = createSignal(undefined) const [status, setStatus] = createSignal('idle') + const [resumeSnapshot, setResumeSnapshot] = createSignal< + GenerationResumeSnapshot | undefined + >(options.initialResumeSnapshot) + let disposed = false const client = createMemo(() => { // Conditional spread on `body`: `GenerationClientOptions.body` is a @@ -128,6 +149,12 @@ export function useGeneration< const clientOptions: GenerationClientOptions = { id: clientId, body: options.body, + ...(options.persistence !== undefined && { + persistence: options.persistence, + }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { ...options.devtools, @@ -140,13 +167,30 @@ export function useGeneration< onResult: ((r: TResult) => options.onResult?.(r)) as ( result: TResult, ) => TOutput | null | void, - onError: (e: Error) => options.onError?.(e), - onProgress: (p: number, m?: string) => options.onProgress?.(p, m), - onChunk: (c: StreamChunk) => options.onChunk?.(c), - onResultChange: setResult, - onLoadingChange: setIsLoading, - onErrorChange: setError, - onStatusChange: setStatus, + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, + onResultChange: (r) => { + if (!disposed) setResult(() => r) + }, + onLoadingChange: (l) => { + if (!disposed) setIsLoading(l) + }, + onErrorChange: (e) => { + if (!disposed) setError(e) + }, + onStatusChange: (s) => { + if (!disposed) setStatus(s) + }, + onResumeSnapshotChange: (snapshot) => { + if (!disposed) setResumeSnapshot(snapshot) + }, } if (options.connection) { @@ -176,12 +220,15 @@ export function useGeneration< }) }) + // Mount devtools only. Generation runs are never auto-started on mount — + // persisted state is read-only for display. onMount(() => { client().mountDevtools() }) // Cleanup on unmount: stop any in-flight requests and unregister devtools onCleanup(() => { + disposed = true client().dispose() }) @@ -205,5 +252,9 @@ export function useGeneration< status, stop, reset, + resumeSnapshot, + resumeState: () => resumeSnapshot()?.resumeState ?? null, + pendingArtifacts: () => resumeSnapshot()?.pendingArtifacts ?? [], + resultArtifacts: () => resumeSnapshot()?.result?.artifacts ?? [], } } diff --git a/packages/ai-solid/src/use-summarize.ts b/packages/ai-solid/src/use-summarize.ts index b6a9eefc6..804d7cd6d 100644 --- a/packages/ai-solid/src/use-summarize.ts +++ b/packages/ai-solid/src/use-summarize.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { StreamChunk, SummarizationResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,12 @@ import type { Accessor } from 'solid-js' * * @template TOutput - The transformed output type (defaults to SummarizationResult) */ -export interface UseSummarizeOptions { +export interface UseSummarizeOptions< + TOutput = SummarizationResult, +> extends Pick< + UseGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for summarization */ @@ -47,7 +56,10 @@ export interface UseSummarizeOptions { * * @template TOutput - The transformed output type (defaults to SummarizationResult) */ -export interface UseSummarizeReturn { +export interface UseSummarizeReturn extends Omit< + UseGenerationReturn, + 'generate' +> { /** Trigger summarization */ generate: (input: SummarizeGenerateInput) => Promise /** The summarization result, or null */ @@ -58,10 +70,6 @@ export interface UseSummarizeReturn { error: Accessor /** Current state of the generation */ status: Accessor - /** Abort the current summarization */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -106,19 +114,19 @@ export function useSummarize( hookName: 'useSummarize', outputKind: 'text' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + SummarizeGenerateInput, + SummarizationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: SummarizeGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: SummarizeGenerateInput, + ) => Promise, } } diff --git a/packages/ai-solid/src/use-transcription.ts b/packages/ai-solid/src/use-transcription.ts index e3e19ee7d..fadb5aabf 100644 --- a/packages/ai-solid/src/use-transcription.ts +++ b/packages/ai-solid/src/use-transcription.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { StreamChunk, TranscriptionResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,16 @@ import type { Accessor } from 'solid-js' * * @template TOutput - The transformed output type (defaults to TranscriptionResult) */ -export interface UseTranscriptionOptions { +export interface UseTranscriptionOptions< + TOutput = TranscriptionResult, +> extends Pick< + UseGenerationOptions< + TranscriptionGenerateInput, + TranscriptionResult, + TOutput + >, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for transcription */ @@ -47,7 +60,9 @@ export interface UseTranscriptionOptions { * * @template TOutput - The transformed output type (defaults to TranscriptionResult) */ -export interface UseTranscriptionReturn { +export interface UseTranscriptionReturn< + TOutput = TranscriptionResult, +> extends Omit, 'generate'> { /** Trigger transcription */ generate: (input: TranscriptionGenerateInput) => Promise /** The transcription result, or null */ @@ -58,10 +73,6 @@ export interface UseTranscriptionReturn { error: Accessor /** Current state of the generation */ status: Accessor - /** Abort the current transcription */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -112,20 +123,16 @@ export function useTranscription( hookName: 'useTranscription', outputKind: 'text' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration< - TranscriptionGenerateInput, - TranscriptionResult, - TTransformed - >({ ...options, devtools }) + const generation = useGeneration< + TranscriptionGenerateInput, + TranscriptionResult, + TTransformed + >({ ...options, devtools }) return { - generate: generate as (input: TranscriptionGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: TranscriptionGenerateInput, + ) => Promise, } } diff --git a/packages/ai-solid/tests/use-generation.test.ts b/packages/ai-solid/tests/use-generation.test.ts index 562268aa0..c365c8b10 100644 --- a/packages/ai-solid/tests/use-generation.test.ts +++ b/packages/ai-solid/tests/use-generation.test.ts @@ -10,6 +10,12 @@ import { useGenerateVideo } from '../src/use-generate-video' import { createMockConnectionAdapter } from './test-utils' import type { StreamChunk, TTSResult, TranscriptionResult } from '@tanstack/ai' import { EventType } from '@tanstack/ai' +import type { + ConnectConnectionAdapter, + GenerationResumeSnapshot, + GenerationServerPersistence, + RunAgentInputContext, +} from '@tanstack/ai-client' // Helper to create generation stream chunks function createGenerationChunks(result: unknown): Array { @@ -71,6 +77,60 @@ function createVideoChunks(jobId: string, url: string): Array { ] } +const videoResumeSnapshot: GenerationResumeSnapshot = { + resumeState: { + threadId: 'thread-resume', + runId: 'run-resume', + }, + status: 'running', +} + +function createReplayVideoChunks(): Array { + return [ + { + type: EventType.RUN_STARTED, + runId: 'run-resume', + threadId: 'thread-resume', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:result', + value: { + jobId: 'job-replay', + status: 'completed', + url: 'https://example.com/video.mp4', + }, + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-resume', + threadId: 'thread-resume', + timestamp: Date.now(), + }, + ] +} + +function createRunContextCaptureAdapter(chunks: Array): { + adapter: ConnectConnectionAdapter + connect: ReturnType + runContexts: Array +} { + const runContexts: Array = [] + const connect = vi.fn() + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, _signal, runContext) { + connect(runContext) + runContexts.push(runContext) + for (const chunk of chunks) { + yield chunk + } + }, + } + return { adapter, connect, runContexts } +} + // Helper to create error stream chunks. // NOTE: The AG-UI spec for RUN_ERROR carries `message` directly on the event // (not nested under `error`). We emit BOTH shapes here because GenerationClient @@ -809,7 +869,7 @@ describe('useSummarize', () => { const mockResult = { id: 'sum-1', summary: 'A brief summary', - model: 'gpt-4', + model: 'gpt-5.5', usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120 }, } const onResult = vi.fn() @@ -851,7 +911,7 @@ describe('useSummarize', () => { describe('connection mode', () => { it('should summarize text using connection', async () => { - const mockResult = { summary: 'A brief summary', model: 'gpt-4' } + const mockResult = { summary: 'A brief summary', model: 'gpt-5.5' } const chunks = createGenerationChunks(mockResult) const adapter = createMockConnectionAdapter({ chunks }) @@ -869,7 +929,7 @@ describe('useSummarize', () => { const mockResult = { id: 'sum-1', summary: 'A brief summary', - model: 'gpt-4', + model: 'gpt-5.5', usage: { promptTokens: 100, completionTokens: 20, totalTokens: 120 }, } @@ -1074,6 +1134,38 @@ describe('useGenerateVideo', () => { expect(result.isLoading()).toBe(false) expect(result.status()).toBe('idle') }) + + it('does not auto-fire a video generation on mount from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface (video). + const { adapter, connect } = createRunContextCaptureAdapter( + createReplayVideoChunks(), + ) + const persistence: GenerationServerPersistence = { + getItem: vi.fn(() => videoResumeSnapshot), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderHook(() => + useGenerateVideo({ + id: 'video-no-auto-fire', + connection: adapter, + persistence: { server: persistence }, + initialResumeSnapshot: videoResumeSnapshot, + }), + ) + + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(connect).not.toHaveBeenCalled() + expect(persistence.getItem).not.toHaveBeenCalled() + expect(result.isLoading()).toBe(false) + expect(result.status()).toBe('idle') + // The persisted snapshot remains exposed as read-only state. + expect(result.resumeSnapshot()).toEqual(videoResumeSnapshot) + expect(result.resumeState()).toEqual(videoResumeSnapshot.resumeState) + }) }) describe('error handling', () => { diff --git a/packages/ai-svelte/src/create-generate-audio.svelte.ts b/packages/ai-svelte/src/create-generate-audio.svelte.ts index 5838ee6f2..87ded3f2a 100644 --- a/packages/ai-svelte/src/create-generate-audio.svelte.ts +++ b/packages/ai-svelte/src/create-generate-audio.svelte.ts @@ -1,4 +1,8 @@ import { createGeneration } from './create-generation.svelte' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.svelte' import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -14,7 +18,12 @@ import type { * * @template TOutput - The output type after optional transform (defaults to AudioGenerationResult) */ -export interface CreateGenerateAudioOptions { +export interface CreateGenerateAudioOptions< + TOutput = AudioGenerationResult, +> extends Pick< + CreateGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for audio generation */ @@ -46,7 +55,9 @@ export interface CreateGenerateAudioOptions { * * @template TOutput - The output type (after optional transform) */ -export interface CreateGenerateAudioReturn { +export interface CreateGenerateAudioReturn< + TOutput = AudioGenerationResult, +> extends Omit, 'generate'> { /** The generation result containing audio, or null */ readonly result: TOutput | null /** Whether generation is in progress */ @@ -57,12 +68,6 @@ export interface CreateGenerateAudioReturn { readonly status: GenerationClientState /** Trigger audio generation */ generate: (input: AudioGenerateInput) => Promise - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void - /** Update additional body parameters */ - updateBody: (body: Record) => void } /** @@ -130,5 +135,18 @@ export function createGenerateAudio( stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, + dispose: gen.dispose, + get resumeSnapshot() { + return gen.resumeSnapshot + }, + get resumeState() { + return gen.resumeState + }, + get pendingArtifacts() { + return gen.pendingArtifacts + }, + get resultArtifacts() { + return gen.resultArtifacts + }, } } diff --git a/packages/ai-svelte/src/create-generate-image.svelte.ts b/packages/ai-svelte/src/create-generate-image.svelte.ts index 0909ddca2..e42b70d55 100644 --- a/packages/ai-svelte/src/create-generate-image.svelte.ts +++ b/packages/ai-svelte/src/create-generate-image.svelte.ts @@ -1,4 +1,8 @@ import { createGeneration } from './create-generation.svelte' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.svelte' import type { ImageGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -14,7 +18,12 @@ import type { * * @template TOutput - The output type after optional transform (defaults to ImageGenerationResult) */ -export interface CreateGenerateImageOptions { +export interface CreateGenerateImageOptions< + TOutput = ImageGenerationResult, +> extends Pick< + CreateGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for image generation */ @@ -46,7 +55,9 @@ export interface CreateGenerateImageOptions { * * @template TOutput - The output type (after optional transform) */ -export interface CreateGenerateImageReturn { +export interface CreateGenerateImageReturn< + TOutput = ImageGenerationResult, +> extends Omit, 'generate'> { /** The generation result containing images, or null */ readonly result: TOutput | null /** Whether generation is in progress */ @@ -57,12 +68,6 @@ export interface CreateGenerateImageReturn { readonly status: GenerationClientState /** Trigger image generation */ generate: (input: ImageGenerateInput) => Promise - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void - /** Update additional body parameters */ - updateBody: (body: Record) => void } /** @@ -139,5 +144,18 @@ export function createGenerateImage( stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, + dispose: gen.dispose, + get resumeSnapshot() { + return gen.resumeSnapshot + }, + get resumeState() { + return gen.resumeState + }, + get pendingArtifacts() { + return gen.pendingArtifacts + }, + get resultArtifacts() { + return gen.resultArtifacts + }, } } diff --git a/packages/ai-svelte/src/create-generate-speech.svelte.ts b/packages/ai-svelte/src/create-generate-speech.svelte.ts index 20ccafbf9..90daf58e4 100644 --- a/packages/ai-svelte/src/create-generate-speech.svelte.ts +++ b/packages/ai-svelte/src/create-generate-speech.svelte.ts @@ -1,4 +1,8 @@ import { createGeneration } from './create-generation.svelte' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.svelte' import type { StreamChunk, TTSResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -14,7 +18,10 @@ import type { * * @template TOutput - The output type after optional transform (defaults to TTSResult) */ -export interface CreateGenerateSpeechOptions { +export interface CreateGenerateSpeechOptions extends Pick< + CreateGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for speech generation */ @@ -46,7 +53,10 @@ export interface CreateGenerateSpeechOptions { * * @template TOutput - The output type (after optional transform) */ -export interface CreateGenerateSpeechReturn { +export interface CreateGenerateSpeechReturn extends Omit< + CreateGenerationReturn, + 'generate' +> { /** The TTS result containing audio data, or null */ readonly result: TOutput | null /** Whether generation is in progress */ @@ -57,12 +67,6 @@ export interface CreateGenerateSpeechReturn { readonly status: GenerationClientState /** Trigger speech generation */ generate: (input: SpeechGenerateInput) => Promise - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void - /** Update additional body parameters */ - updateBody: (body: Record) => void } /** @@ -126,5 +130,18 @@ export function createGenerateSpeech( stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, + dispose: gen.dispose, + get resumeSnapshot() { + return gen.resumeSnapshot + }, + get resumeState() { + return gen.resumeState + }, + get pendingArtifacts() { + return gen.pendingArtifacts + }, + get resultArtifacts() { + return gen.resultArtifacts + }, } } diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index 05f2d787b..45460fc1e 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -6,11 +6,16 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the createGenerateVideo function. @@ -28,6 +33,10 @@ export interface CreateGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when video generation completes. Can optionally return a transformed value. * @@ -76,6 +85,14 @@ export interface CreateGenerateVideoReturn { dispose: () => void /** Update additional body parameters */ updateBody: (body: Record) => void + /** Lightweight generation resume snapshot, if one is available */ + readonly resumeSnapshot: GenerationResumeSnapshot | undefined + /** Observed run/cursor metadata from the snapshot (read-only state) */ + readonly resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + readonly pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + readonly resultArtifacts: Array } /** @@ -133,6 +150,17 @@ export function createGenerateVideo( let isLoading = $state(false) let error = $state(undefined) let status = $state('idle') + let resumeSnapshot = $state( + options.initialResumeSnapshot, + ) + let disposed = false + + const setResumeSnapshotState = ( + snapshot: GenerationResumeSnapshot | undefined, + ) => { + if (disposed) return + resumeSnapshot = snapshot + } // `body` uses a conditional spread because `VideoGenerationClientOptions.body` // is declared `body?: Record` (absent vs. present) under @@ -141,6 +169,12 @@ export function createGenerateVideo( const baseOptions = { id: clientId, body: options.body, + ...(options.persistence !== undefined && { + persistence: options.persistence, + }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...options.devtools, @@ -154,29 +188,46 @@ export function createGenerateVideo( onResult: ((r: VideoGenerateResult) => options.onResult?.(r)) as ( result: VideoGenerateResult, ) => TOutput | null | void, - onError: (e: Error) => options.onError?.(e), - onProgress: (p: number, m?: string) => options.onProgress?.(p, m), - onChunk: (c: StreamChunk) => options.onChunk?.(c), - onJobCreated: (id: string) => options.onJobCreated?.(id), - onStatusUpdate: (s: VideoStatusInfo) => options.onStatusUpdate?.(s), + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, + onJobCreated: (id: string) => { + if (!disposed) options.onJobCreated?.(id) + }, + onStatusUpdate: (s: VideoStatusInfo) => { + if (!disposed) options.onStatusUpdate?.(s) + }, onResultChange: (r: TOutput | null) => { + if (disposed) return result = r }, onLoadingChange: (l: boolean) => { + if (disposed) return isLoading = l }, onErrorChange: (e: Error | undefined) => { + if (disposed) return error = e }, onStatusChange: (s: GenerationClientState) => { + if (disposed) return status = s }, onJobIdChange: (id: string | null) => { + if (disposed) return jobId = id }, onVideoStatusChange: (s: VideoStatusInfo | null) => { + if (disposed) return videoStatus = s }, + onResumeSnapshotChange: setResumeSnapshotState, } let client: VideoGenerationClient @@ -197,6 +248,8 @@ export function createGenerateVideo( ) } + // Mount devtools only. Generation runs are never auto-started on setup — + // persisted state is read-only for display. client.mountDevtools() // Note: Cleanup is handled by calling dispose() directly when needed. @@ -217,6 +270,7 @@ export function createGenerateVideo( } const dispose = () => { + disposed = true client.dispose() } @@ -248,5 +302,17 @@ export function createGenerateVideo( reset, dispose, updateBody, + get resumeSnapshot() { + return resumeSnapshot + }, + get resumeState() { + return resumeSnapshot?.resumeState ?? null + }, + get pendingArtifacts() { + return resumeSnapshot?.pendingArtifacts ?? [] + }, + get resultArtifacts() { + return resumeSnapshot?.result?.artifacts ?? [] + }, } } diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index a731ac398..5f80a7b7e 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -7,8 +7,13 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the createGeneration function. @@ -30,6 +35,10 @@ export interface CreateGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. * @@ -70,6 +79,14 @@ export interface CreateGenerationReturn { dispose: () => void /** Update additional body parameters */ updateBody: (body: Record) => void + /** Lightweight generation resume snapshot, if one is available */ + readonly resumeSnapshot: GenerationResumeSnapshot | undefined + /** Observed run/cursor metadata from the snapshot (read-only state) */ + readonly resumeState: GenerationResumeState | null + /** Pending persisted artifact references observed during generation/replay */ + readonly pendingArtifacts: Array + /** Final persisted artifact references observed from a replayed result */ + readonly resultArtifacts: Array } /** @@ -129,6 +146,17 @@ export function createGeneration< let isLoading = $state(false) let error = $state(undefined) let status = $state('idle') + let resumeSnapshot = $state( + options.initialResumeSnapshot, + ) + let disposed = false + + const setResumeSnapshotState = ( + snapshot: GenerationResumeSnapshot | undefined, + ) => { + if (disposed) return + resumeSnapshot = snapshot + } // `body` uses a conditional spread because `GenerationClientOptions.body` // is declared `body?: Record` (absent vs. present) under @@ -138,6 +166,12 @@ export function createGeneration< const clientOptions: GenerationClientOptions = { id: clientId, body: options.body, + ...(options.persistence !== undefined && { + persistence: options.persistence, + }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { ...options.devtools, @@ -150,21 +184,32 @@ export function createGeneration< onResult: ((r: TResult) => options.onResult?.(r)) as ( result: TResult, ) => TOutput | null | void, - onError: (e: Error) => options.onError?.(e), - onProgress: (p: number, m?: string) => options.onProgress?.(p, m), - onChunk: (c: StreamChunk) => options.onChunk?.(c), + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, onResultChange: (r: TOutput | null) => { + if (disposed) return result = r }, onLoadingChange: (l: boolean) => { + if (disposed) return isLoading = l }, onErrorChange: (e: Error | undefined) => { + if (disposed) return error = e }, onStatusChange: (s: GenerationClientState) => { + if (disposed) return status = s }, + onResumeSnapshotChange: setResumeSnapshotState, } let client: GenerationClient @@ -185,6 +230,8 @@ export function createGeneration< ) } + // Mount devtools only. Generation runs are never auto-started on setup — + // persisted state is read-only for display. client.mountDevtools() // Note: Cleanup is handled by calling dispose() directly when needed. @@ -205,6 +252,7 @@ export function createGeneration< } const dispose = () => { + disposed = true client.dispose() } @@ -230,5 +278,17 @@ export function createGeneration< reset, dispose, updateBody, + get resumeSnapshot() { + return resumeSnapshot + }, + get resumeState() { + return resumeSnapshot?.resumeState ?? null + }, + get pendingArtifacts() { + return resumeSnapshot?.pendingArtifacts ?? [] + }, + get resultArtifacts() { + return resumeSnapshot?.result?.artifacts ?? [] + }, } } diff --git a/packages/ai-svelte/src/create-summarize.svelte.ts b/packages/ai-svelte/src/create-summarize.svelte.ts index 0009eee15..5d582de21 100644 --- a/packages/ai-svelte/src/create-summarize.svelte.ts +++ b/packages/ai-svelte/src/create-summarize.svelte.ts @@ -1,4 +1,8 @@ import { createGeneration } from './create-generation.svelte' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.svelte' import type { StreamChunk, SummarizationResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -14,7 +18,12 @@ import type { * * @template TOutput - The output type after optional transform (defaults to SummarizationResult) */ -export interface CreateSummarizeOptions { +export interface CreateSummarizeOptions< + TOutput = SummarizationResult, +> extends Pick< + CreateGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for summarization */ @@ -46,7 +55,9 @@ export interface CreateSummarizeOptions { * * @template TOutput - The output type (after optional transform) */ -export interface CreateSummarizeReturn { +export interface CreateSummarizeReturn< + TOutput = SummarizationResult, +> extends Omit, 'generate'> { /** The summarization result, or null */ readonly result: TOutput | null /** Whether summarization is in progress */ @@ -57,12 +68,6 @@ export interface CreateSummarizeReturn { readonly status: GenerationClientState /** Trigger summarization */ generate: (input: SummarizeGenerateInput) => Promise - /** Abort the current summarization */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void - /** Update additional body parameters */ - updateBody: (body: Record) => void } /** @@ -134,5 +139,18 @@ export function createSummarize( stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, + dispose: gen.dispose, + get resumeSnapshot() { + return gen.resumeSnapshot + }, + get resumeState() { + return gen.resumeState + }, + get pendingArtifacts() { + return gen.pendingArtifacts + }, + get resultArtifacts() { + return gen.resultArtifacts + }, } } diff --git a/packages/ai-svelte/src/create-transcription.svelte.ts b/packages/ai-svelte/src/create-transcription.svelte.ts index b6583cb98..f6a7fdfae 100644 --- a/packages/ai-svelte/src/create-transcription.svelte.ts +++ b/packages/ai-svelte/src/create-transcription.svelte.ts @@ -1,4 +1,8 @@ import { createGeneration } from './create-generation.svelte' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.svelte' import type { StreamChunk, TranscriptionResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -14,7 +18,16 @@ import type { * * @template TOutput - The output type after optional transform (defaults to TranscriptionResult) */ -export interface CreateTranscriptionOptions { +export interface CreateTranscriptionOptions< + TOutput = TranscriptionResult, +> extends Pick< + CreateGenerationOptions< + TranscriptionGenerateInput, + TranscriptionResult, + TOutput + >, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for transcription */ @@ -46,7 +59,9 @@ export interface CreateTranscriptionOptions { * * @template TOutput - The output type (after optional transform) */ -export interface CreateTranscriptionReturn { +export interface CreateTranscriptionReturn< + TOutput = TranscriptionResult, +> extends Omit, 'generate'> { /** The transcription result, or null */ readonly result: TOutput | null /** Whether transcription is in progress */ @@ -57,12 +72,6 @@ export interface CreateTranscriptionReturn { readonly status: GenerationClientState /** Trigger transcription */ generate: (input: TranscriptionGenerateInput) => Promise - /** Abort the current transcription */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void - /** Update additional body parameters */ - updateBody: (body: Record) => void } /** @@ -141,5 +150,18 @@ export function createTranscription( stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, + dispose: gen.dispose, + get resumeSnapshot() { + return gen.resumeSnapshot + }, + get resumeState() { + return gen.resumeState + }, + get pendingArtifacts() { + return gen.pendingArtifacts + }, + get resultArtifacts() { + return gen.resultArtifacts + }, } } diff --git a/packages/ai-svelte/tests/create-generation.test.ts b/packages/ai-svelte/tests/create-generation.test.ts index ca2761c9f..2da5759e4 100644 --- a/packages/ai-svelte/tests/create-generation.test.ts +++ b/packages/ai-svelte/tests/create-generation.test.ts @@ -8,6 +8,11 @@ import { createGenerateVideo } from '../src/create-generate-video.svelte' import { createMockConnectionAdapter } from './test-utils' import { EventType, type StreamChunk } from '@tanstack/ai' import type { TTSResult, TranscriptionResult } from '@tanstack/ai' +import type { + ConnectConnectionAdapter, + GenerationResumeSnapshot, + RunAgentInputContext, +} from '@tanstack/ai-client' // Helper to create generation stream chunks function createGenerationChunks(result: unknown): Array { @@ -71,6 +76,33 @@ function createVideoChunks(jobId: string, url: string): Array { ] } +const videoResumeSnapshot: GenerationResumeSnapshot = { + resumeState: { + threadId: 'thread-resume', + runId: 'run-resume', + }, + status: 'running', +} + +function createRunContextCaptureAdapter(chunks: Array): { + adapter: ConnectConnectionAdapter + connect: ReturnType + runContexts: Array +} { + const runContexts: Array = [] + const connect = vi.fn() + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, _signal, runContext) { + connect(runContext) + runContexts.push(runContext) + for (const chunk of chunks) { + yield chunk + } + }, + } + return { adapter, connect, runContexts } +} + // Helper to create error stream chunks function createErrorChunks(message: string): Array { return [ @@ -186,6 +218,33 @@ describe('createGeneration', () => { expect(gen.status).toBe('error') expect(gen.error?.message).toBe('Generation failed') }) + + it('does not auto-fire a generation on setup from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface. + const snapshot: GenerationResumeSnapshot = { + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running', + } + const { adapter, connect } = createRunContextCaptureAdapter([]) + const getItem = vi.fn(() => snapshot) + const gen = createGeneration({ + id: 'no-auto-fire', + connection: adapter, + persistence: { + server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, + }, + initialResumeSnapshot: snapshot, + }) + + await Promise.resolve() + + expect(connect).not.toHaveBeenCalled() + expect(getItem).not.toHaveBeenCalled() + expect(gen.isLoading).toBe(false) + expect(gen.status).toBe('idle') + // The persisted snapshot remains exposed as read-only state. + expect(gen.resumeState).toEqual(snapshot.resumeState) + }) }) describe('stop and reset', () => { @@ -489,7 +548,7 @@ describe('createSummarize', () => { const mockResult = { id: 'sum-1', summary: 'A brief summary', - model: 'gpt-4', + model: 'gpt-5.5', usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, } @@ -504,7 +563,7 @@ describe('createSummarize', () => { }) it('should summarize text using connection', async () => { - const mockResult = { summary: 'A brief summary', model: 'gpt-4' } + const mockResult = { summary: 'A brief summary', model: 'gpt-5.5' } const chunks = createGenerationChunks(mockResult) const adapter = createMockConnectionAdapter({ chunks }) @@ -538,7 +597,7 @@ describe('createSummarize', () => { fetcher: async () => ({ id: 'sum-1', summary: 'A brief summary', - model: 'gpt-4', + model: 'gpt-5.5', usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, }), }) @@ -658,6 +717,30 @@ describe('createGenerateVideo', () => { expect(gen.status).toBe('idle') }) + it('does not auto-fire a video generation on setup from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface (video). + const { adapter, connect } = createRunContextCaptureAdapter([]) + const getItem = vi.fn(() => videoResumeSnapshot) + const gen = createGenerateVideo({ + id: 'video-no-auto-fire', + connection: adapter, + persistence: { + server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, + }, + initialResumeSnapshot: videoResumeSnapshot, + }) + + await Promise.resolve() + + expect(connect).not.toHaveBeenCalled() + expect(getItem).not.toHaveBeenCalled() + expect(gen.isLoading).toBe(false) + expect(gen.status).toBe('idle') + // The persisted snapshot remains exposed as read-only state. + expect(gen.resumeSnapshot).toEqual(videoResumeSnapshot) + expect(gen.resumeState).toEqual(videoResumeSnapshot.resumeState) + }) + it('should expose generate, stop, reset, and updateBody methods', () => { const adapter = createMockConnectionAdapter() const gen = createGenerateVideo({ connection: adapter }) diff --git a/packages/ai-vue/src/use-generate-audio.ts b/packages/ai-vue/src/use-generate-audio.ts index 91c8eb6a0..48fb347d6 100644 --- a/packages/ai-vue/src/use-generate-audio.ts +++ b/packages/ai-vue/src/use-generate-audio.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,12 @@ import type { DeepReadonly, ShallowRef } from 'vue' * * @template TOutput - The output type after optional transform (defaults to AudioGenerationResult) */ -export interface UseGenerateAudioOptions { +export interface UseGenerateAudioOptions< + TOutput = AudioGenerationResult, +> extends Pick< + UseGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for audio generation */ @@ -47,7 +56,9 @@ export interface UseGenerateAudioOptions { * * @template TOutput - The output type (after optional transform) */ -export interface UseGenerateAudioReturn { +export interface UseGenerateAudioReturn< + TOutput = AudioGenerationResult, +> extends Omit, 'generate'> { /** Trigger audio generation */ generate: (input: AudioGenerateInput) => Promise /** The generation result containing audio, or null */ @@ -58,10 +69,6 @@ export interface UseGenerateAudioReturn { error: DeepReadonly> /** Current state of the generation */ status: DeepReadonly> - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -101,19 +108,19 @@ export function useGenerateAudio( hookName: 'useGenerateAudio', outputKind: 'audio' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + AudioGenerateInput, + AudioGenerationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: AudioGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: AudioGenerateInput, + ) => Promise, } } diff --git a/packages/ai-vue/src/use-generate-image.ts b/packages/ai-vue/src/use-generate-image.ts index f80cc2f5c..f40c88767 100644 --- a/packages/ai-vue/src/use-generate-image.ts +++ b/packages/ai-vue/src/use-generate-image.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { ImageGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,12 @@ import type { DeepReadonly, ShallowRef } from 'vue' * * @template TOutput - The output type after optional transform (defaults to ImageGenerationResult) */ -export interface UseGenerateImageOptions { +export interface UseGenerateImageOptions< + TOutput = ImageGenerationResult, +> extends Pick< + UseGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for image generation */ @@ -47,7 +56,9 @@ export interface UseGenerateImageOptions { * * @template TOutput - The output type (after optional transform) */ -export interface UseGenerateImageReturn { +export interface UseGenerateImageReturn< + TOutput = ImageGenerationResult, +> extends Omit, 'generate'> { /** Trigger image generation */ generate: (input: ImageGenerateInput) => Promise /** The generation result containing images, or null */ @@ -58,10 +69,6 @@ export interface UseGenerateImageReturn { error: DeepReadonly> /** Current state of the generation */ status: DeepReadonly> - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -111,19 +118,19 @@ export function useGenerateImage( hookName: 'useGenerateImage', outputKind: 'image' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + ImageGenerateInput, + ImageGenerationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: ImageGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: ImageGenerateInput, + ) => Promise, } } diff --git a/packages/ai-vue/src/use-generate-speech.ts b/packages/ai-vue/src/use-generate-speech.ts index 2302fc766..574d58517 100644 --- a/packages/ai-vue/src/use-generate-speech.ts +++ b/packages/ai-vue/src/use-generate-speech.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { StreamChunk, TTSResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,10 @@ import type { DeepReadonly, ShallowRef } from 'vue' * * @template TOutput - The output type after optional transform (defaults to TTSResult) */ -export interface UseGenerateSpeechOptions { +export interface UseGenerateSpeechOptions extends Pick< + UseGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for speech generation */ @@ -47,7 +54,10 @@ export interface UseGenerateSpeechOptions { * * @template TOutput - The output type (after optional transform) */ -export interface UseGenerateSpeechReturn { +export interface UseGenerateSpeechReturn extends Omit< + UseGenerationReturn, + 'generate' +> { /** Trigger speech generation */ generate: (input: SpeechGenerateInput) => Promise /** The TTS result containing audio data, or null */ @@ -58,10 +68,6 @@ export interface UseGenerateSpeechReturn { error: DeepReadonly> /** Current state of the generation */ status: DeepReadonly> - /** Abort the current generation */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -105,19 +111,19 @@ export function useGenerateSpeech( hookName: 'useGenerateSpeech', outputKind: 'audio' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + SpeechGenerateInput, + TTSResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: SpeechGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: SpeechGenerateInput, + ) => Promise, } } diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 644558508..823ab8a73 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -14,11 +14,16 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, VideoGenerateInput, VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { DeepReadonly, ShallowRef } from 'vue' /** @@ -37,6 +42,10 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when video generation completes. Can optionally return a transformed value. * @@ -81,6 +90,14 @@ export interface UseGenerateVideoReturn { stop: () => void /** Clear all state and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: DeepReadonly> + /** Observed run/cursor metadata from the snapshot (read-only state) */ + resumeState: DeepReadonly> + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: DeepReadonly>> + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: DeepReadonly>> } /** @@ -137,6 +154,29 @@ export function useGenerateVideo( const isLoading = shallowRef(false) const error = shallowRef(undefined) const status = shallowRef('idle') + const resumeSnapshot = shallowRef( + options.initialResumeSnapshot, + ) + const resumeState = shallowRef( + options.initialResumeSnapshot?.resumeState ?? null, + ) + const pendingArtifacts = shallowRef>( + options.initialResumeSnapshot?.pendingArtifacts ?? [], + ) + const resultArtifacts = shallowRef>( + options.initialResumeSnapshot?.result?.artifacts ?? [], + ) + let disposed = false + + const setResumeSnapshotState = ( + snapshot: GenerationResumeSnapshot | undefined, + ) => { + if (disposed) return + resumeSnapshot.value = snapshot + resumeState.value = snapshot?.resumeState ?? null + pendingArtifacts.value = snapshot?.pendingArtifacts ?? [] + resultArtifacts.value = snapshot?.result?.artifacts ?? [] + } // Conditional spread on `body`: `VideoGenerationClientOptions.body` is a // strict optional and under EOPT we must omit the key when absent rather @@ -144,6 +184,12 @@ export function useGenerateVideo( const baseOptions = { id: clientId, body: options.body, + ...(options.persistence !== undefined && { + persistence: options.persistence, + }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...options.devtools, @@ -157,29 +203,46 @@ export function useGenerateVideo( onResult: ((r: VideoGenerateResult) => options.onResult?.(r)) as ( result: VideoGenerateResult, ) => TOutput | null | void, - onError: (e: Error) => options.onError?.(e), - onProgress: (p: number, m?: string) => options.onProgress?.(p, m), - onChunk: (c: StreamChunk) => options.onChunk?.(c), - onJobCreated: (id: string) => options.onJobCreated?.(id), - onStatusUpdate: (s: VideoStatusInfo) => options.onStatusUpdate?.(s), + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, + onJobCreated: (id: string) => { + if (!disposed) options.onJobCreated?.(id) + }, + onStatusUpdate: (s: VideoStatusInfo) => { + if (!disposed) options.onStatusUpdate?.(s) + }, onResultChange: (r: TOutput | null) => { + if (disposed) return result.value = r }, onLoadingChange: (l: boolean) => { + if (disposed) return isLoading.value = l }, onErrorChange: (e: Error | undefined) => { + if (disposed) return error.value = e }, onStatusChange: (s: GenerationClientState) => { + if (disposed) return status.value = s }, onJobIdChange: (id: string | null) => { + if (disposed) return jobId.value = id }, onVideoStatusChange: (s: VideoStatusInfo | null) => { + if (disposed) return videoStatus.value = s }, + onResumeSnapshotChange: setResumeSnapshotState, } let client: VideoGenerationClient @@ -212,12 +275,15 @@ export function useGenerateVideo( }, ) + // Mount devtools only. Generation runs are never auto-started on mount — + // persisted state is read-only for display. onMounted(() => { client.mountDevtools() }) // Cleanup on scope dispose: stop any in-flight requests and unregister devtools onScopeDispose(() => { + disposed = true client.dispose() }) @@ -247,5 +313,9 @@ export function useGenerateVideo( status: readonly(status), stop, reset, + resumeSnapshot: readonly(resumeSnapshot), + resumeState: readonly(resumeState), + pendingArtifacts: readonly(pendingArtifacts), + resultArtifacts: readonly(resultArtifacts), } } diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index 596f14e97..6c6005d66 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -15,8 +15,13 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, + GenerationPendingArtifact, + GenerationPersistenceOptions, + GenerationResumeSnapshot, + GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { DeepReadonly, ShallowRef } from 'vue' /** @@ -39,6 +44,10 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions + /** Server-side lightweight generation state persistence. */ + persistence?: GenerationPersistenceOptions + /** Initial lightweight resume snapshot restored by the app (read-only state). */ + initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. * @@ -75,6 +84,14 @@ export interface UseGenerationReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void + /** Lightweight generation resume snapshot, if one is available */ + resumeSnapshot: DeepReadonly> + /** Observed run/cursor metadata from the snapshot (read-only state) */ + resumeState: DeepReadonly> + /** Pending persisted artifact references observed during generation/replay */ + pendingArtifacts: DeepReadonly>> + /** Final persisted artifact references observed from a replayed result */ + resultArtifacts: DeepReadonly>> } /** @@ -122,6 +139,29 @@ export function useGeneration< const isLoading = shallowRef(false) const error = shallowRef(undefined) const status = shallowRef('idle') + const resumeSnapshot = shallowRef( + options.initialResumeSnapshot, + ) + const resumeState = shallowRef( + options.initialResumeSnapshot?.resumeState ?? null, + ) + const pendingArtifacts = shallowRef>( + options.initialResumeSnapshot?.pendingArtifacts ?? [], + ) + const resultArtifacts = shallowRef>( + options.initialResumeSnapshot?.result?.artifacts ?? [], + ) + let disposed = false + + const setResumeSnapshotState = ( + snapshot: GenerationResumeSnapshot | undefined, + ) => { + if (disposed) return + resumeSnapshot.value = snapshot + resumeState.value = snapshot?.resumeState ?? null + pendingArtifacts.value = snapshot?.pendingArtifacts ?? [] + resultArtifacts.value = snapshot?.result?.artifacts ?? [] + } // Conditional spread on `body`: `GenerationClientOptions.body` is a strict // optional (`body?: Record`), and under EOPT we must omit the @@ -129,6 +169,12 @@ export function useGeneration< const clientOptions: GenerationClientOptions = { id: clientId, body: options.body, + ...(options.persistence !== undefined && { + persistence: options.persistence, + }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { ...options.devtools, @@ -141,21 +187,32 @@ export function useGeneration< onResult: ((r: TResult) => options.onResult?.(r)) as ( result: TResult, ) => TOutput | null | void, - onError: (e: Error) => options.onError?.(e), - onProgress: (p: number, m?: string) => options.onProgress?.(p, m), - onChunk: (c: StreamChunk) => options.onChunk?.(c), + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, onResultChange: (r: TOutput | null) => { + if (disposed) return result.value = r }, onLoadingChange: (l: boolean) => { + if (disposed) return isLoading.value = l }, onErrorChange: (e: Error | undefined) => { + if (disposed) return error.value = e }, onStatusChange: (s: GenerationClientState) => { + if (disposed) return status.value = s }, + onResumeSnapshotChange: setResumeSnapshotState, } let client: GenerationClient @@ -188,12 +245,15 @@ export function useGeneration< }, ) + // Mount devtools only. Generation runs are never auto-started on mount — + // persisted state is read-only for display. onMounted(() => { client.mountDevtools() }) // Cleanup on scope dispose: stop any in-flight requests and unregister devtools onScopeDispose(() => { + disposed = true client.dispose() }) @@ -221,5 +281,9 @@ export function useGeneration< status: readonly(status), stop, reset, + resumeSnapshot: readonly(resumeSnapshot), + resumeState: readonly(resumeState), + pendingArtifacts: readonly(pendingArtifacts), + resultArtifacts: readonly(resultArtifacts), } } diff --git a/packages/ai-vue/src/use-summarize.ts b/packages/ai-vue/src/use-summarize.ts index 7297c5277..e49a5a1f2 100644 --- a/packages/ai-vue/src/use-summarize.ts +++ b/packages/ai-vue/src/use-summarize.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { StreamChunk, SummarizationResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,12 @@ import type { DeepReadonly, ShallowRef } from 'vue' * * @template TOutput - The output type after optional transform (defaults to SummarizationResult) */ -export interface UseSummarizeOptions { +export interface UseSummarizeOptions< + TOutput = SummarizationResult, +> extends Pick< + UseGenerationOptions, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for summarization */ @@ -47,7 +56,10 @@ export interface UseSummarizeOptions { * * @template TOutput - The output type (after optional transform) */ -export interface UseSummarizeReturn { +export interface UseSummarizeReturn extends Omit< + UseGenerationReturn, + 'generate' +> { /** Trigger summarization */ generate: (input: SummarizeGenerateInput) => Promise /** The summarization result, or null */ @@ -58,10 +70,6 @@ export interface UseSummarizeReturn { error: DeepReadonly> /** Current state of the generation */ status: DeepReadonly> - /** Abort the current summarization */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -106,19 +114,19 @@ export function useSummarize( hookName: 'useSummarize', outputKind: 'text' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration({ - ...options, - devtools, - }) + const generation = useGeneration< + SummarizeGenerateInput, + SummarizationResult, + TTransformed + >({ + ...options, + devtools, + }) return { - generate: generate as (input: SummarizeGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: SummarizeGenerateInput, + ) => Promise, } } diff --git a/packages/ai-vue/src/use-transcription.ts b/packages/ai-vue/src/use-transcription.ts index 26e42156c..69410e039 100644 --- a/packages/ai-vue/src/use-transcription.ts +++ b/packages/ai-vue/src/use-transcription.ts @@ -1,4 +1,8 @@ import { useGeneration } from './use-generation' +import type { + UseGenerationOptions, + UseGenerationReturn, +} from './use-generation' import type { StreamChunk, TranscriptionResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -15,7 +19,16 @@ import type { DeepReadonly, ShallowRef } from 'vue' * * @template TOutput - The output type after optional transform (defaults to TranscriptionResult) */ -export interface UseTranscriptionOptions { +export interface UseTranscriptionOptions< + TOutput = TranscriptionResult, +> extends Pick< + UseGenerationOptions< + TranscriptionGenerateInput, + TranscriptionResult, + TOutput + >, + 'persistence' | 'initialResumeSnapshot' +> { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter /** Direct async function for transcription */ @@ -47,7 +60,9 @@ export interface UseTranscriptionOptions { * * @template TOutput - The output type (after optional transform) */ -export interface UseTranscriptionReturn { +export interface UseTranscriptionReturn< + TOutput = TranscriptionResult, +> extends Omit, 'generate'> { /** Trigger transcription */ generate: (input: TranscriptionGenerateInput) => Promise /** The transcription result, or null */ @@ -58,10 +73,6 @@ export interface UseTranscriptionReturn { error: DeepReadonly> /** Current state of the generation */ status: DeepReadonly> - /** Abort the current transcription */ - stop: () => void - /** Clear result, error, and return to idle */ - reset: () => void } /** @@ -111,20 +122,16 @@ export function useTranscription( hookName: 'useTranscription', outputKind: 'text' as const, } - const { generate, result, isLoading, error, status, stop, reset } = - useGeneration< - TranscriptionGenerateInput, - TranscriptionResult, - TTransformed - >({ ...options, devtools }) + const generation = useGeneration< + TranscriptionGenerateInput, + TranscriptionResult, + TTransformed + >({ ...options, devtools }) return { - generate: generate as (input: TranscriptionGenerateInput) => Promise, - result, - isLoading, - error, - status, - stop, - reset, + ...generation, + generate: generation.generate as ( + input: TranscriptionGenerateInput, + ) => Promise, } } diff --git a/packages/ai-vue/tests/use-generation.test.ts b/packages/ai-vue/tests/use-generation.test.ts index 21b40eafe..a1cbe1739 100644 --- a/packages/ai-vue/tests/use-generation.test.ts +++ b/packages/ai-vue/tests/use-generation.test.ts @@ -10,6 +10,11 @@ import { useSummarize } from '../src/use-summarize' import { useGenerateVideo } from '../src/use-generate-video' import { createMockConnectionAdapter } from './test-utils' import type { StreamChunk, TTSResult, TranscriptionResult } from '@tanstack/ai' +import type { + ConnectConnectionAdapter, + GenerationResumeSnapshot, + RunAgentInputContext, +} from '@tanstack/ai-client' import type { DeepReadonly } from 'vue' // Helper to create generation stream chunks @@ -62,6 +67,60 @@ function createVideoChunks(jobId: string, url: string): Array { ] as unknown as Array } +const videoResumeSnapshot: GenerationResumeSnapshot = { + resumeState: { + threadId: 'thread-resume', + runId: 'run-resume', + }, + status: 'running', +} + +function createReplayVideoChunks(): Array { + return [ + { + type: 'RUN_STARTED', + runId: 'run-resume', + threadId: 'thread-resume', + timestamp: Date.now(), + }, + { + type: 'CUSTOM', + name: 'generation:result', + value: { + jobId: 'job-replay', + status: 'completed', + url: 'https://example.com/video.mp4', + }, + timestamp: Date.now(), + }, + { + type: 'RUN_FINISHED', + runId: 'run-resume', + threadId: 'thread-resume', + timestamp: Date.now(), + }, + ] as unknown as Array +} + +function createRunContextCaptureAdapter(chunks: Array): { + adapter: ConnectConnectionAdapter + connect: ReturnType + runContexts: Array +} { + const runContexts: Array = [] + const connect = vi.fn() + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, _signal, runContext) { + connect(runContext) + runContexts.push(runContext) + for (const chunk of chunks) { + yield chunk + } + }, + } + return { adapter, connect, runContexts } +} + // Helper to create error stream chunks function createErrorChunks(message: string): Array { return [ @@ -187,6 +246,38 @@ describe('useGeneration', () => { expect(result.status.value).toBe('error') expect(result.error.value?.message).toBe('Generation failed') }) + + it('does not auto-fire a generation on mount from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface. + const snapshot: GenerationResumeSnapshot = { + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running', + } + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const getItem = vi.fn(() => snapshot) + const { result } = renderHook(() => + useGeneration({ + id: 'no-auto-fire', + connection: adapter, + persistence: { + server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, + }, + initialResumeSnapshot: snapshot, + }), + ) + + await flushPromises() + await nextTick() + + expect(connect).not.toHaveBeenCalled() + expect(getItem).not.toHaveBeenCalled() + expect(result.isLoading.value).toBe(false) + expect(result.status.value).toBe('idle') + // The persisted snapshot remains exposed as read-only state. + expect(result.resumeState.value).toEqual(snapshot.resumeState) + }) }) describe('stop and reset', () => { @@ -586,7 +677,7 @@ describe('useSummarize', () => { it('should summarize text using fetcher', async () => { const mockResult = { summary: 'A brief summary', - model: 'gpt-4', + model: 'gpt-5.5', } const { result } = renderHook(() => @@ -604,7 +695,7 @@ describe('useSummarize', () => { }) it('should summarize text using connection', async () => { - const mockResult = { summary: 'A brief summary', model: 'gpt-4' } + const mockResult = { summary: 'A brief summary', model: 'gpt-5.5' } const chunks = createGenerationChunks(mockResult) const adapter = createMockConnectionAdapter({ chunks }) @@ -761,6 +852,35 @@ describe('useGenerateVideo', () => { expect(result.status.value).toBe('idle') }) + it('does not auto-fire a video generation on mount from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface (video). + const { adapter, connect } = createRunContextCaptureAdapter( + createReplayVideoChunks(), + ) + const getItem = vi.fn(() => videoResumeSnapshot) + const { result } = renderHook(() => + useGenerateVideo({ + id: 'video-no-auto-fire', + connection: adapter, + persistence: { + server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, + }, + initialResumeSnapshot: videoResumeSnapshot, + }), + ) + + await flushPromises() + await nextTick() + + expect(connect).not.toHaveBeenCalled() + expect(getItem).not.toHaveBeenCalled() + expect(result.isLoading.value).toBe(false) + expect(result.status.value).toBe('idle') + // The persisted snapshot remains exposed as read-only state. + expect(result.resumeSnapshot.value).toEqual(videoResumeSnapshot) + expect(result.resumeState.value).toEqual(videoResumeSnapshot.resumeState) + }) + it('should require either connection or fetcher', () => { expect(() => { renderHook(() => useGenerateVideo({} as any)) From 5d4c721ff4fb5369c38daa85c272be95e4b3e92d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 13:45:19 +0200 Subject: [PATCH 02/66] docs(persistence): drop generic from generation snapshot store example --- docs/persistence/generation-persistence.md | 31 +++++++++++----------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 2b3119a9b..3c9c281f0 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -58,25 +58,24 @@ because `RunStore` is keyed by `runId`. ## Persist the client snapshot ```tsx -import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -import type { GenerationResumeSnapshot } from '@tanstack/ai-client' - -function serializeJson(value: unknown): string { - const stringify: (input: unknown) => unknown = JSON.stringify - const serialized = stringify(value) - if (typeof serialized !== 'string') { - throw new TypeError('The value is not JSON serializable.') - } - return serialized +import type { GenerationServerPersistence } from '@tanstack/ai-client' + +// `GenerationServerPersistence` already pins the stored value to a +// `GenerationResumeSnapshot`, so there is no generic to pass at the call site. +const snapshots: GenerationServerPersistence = { + getItem: (id) => { + const raw = localStorage.getItem(`my-app:generation:${id}`) + return raw ? JSON.parse(raw) : null + }, + setItem: (id, value) => { + localStorage.setItem(`my-app:generation:${id}`, JSON.stringify(value)) + }, + removeItem: (id) => { + localStorage.removeItem(`my-app:generation:${id}`) + }, } -const snapshots = localStoragePersistence({ - keyPrefix: 'my-app:generation:', - serialize: serializeJson, - deserialize: JSON.parse, -}) - export function HeroImageGenerator() { const image = useGenerateImage({ id: 'hero-image', From 581349112ef7f3d4f036de1b1ec0b29969089a47 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:03:12 +0200 Subject: [PATCH 03/66] refactor(persistence): align generation persistence API with chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/generation-persistence.md | 2 +- docs/persistence/generation-persistence.md | 26 ++++-------- .../src/routes/generations.image.tsx | 42 +++++-------------- .../ai-angular/src/inject-generate-video.ts | 6 +-- packages/ai-angular/src/inject-generation.ts | 8 ++-- .../tests/inject-generation.test.ts | 8 +--- packages/ai-client/src/generation-client.ts | 6 +-- packages/ai-client/src/generation-types.ts | 33 +++++++-------- packages/ai-client/src/index.ts | 3 +- .../ai-client/src/video-generation-client.ts | 6 +-- .../ai-client/tests/generation-client.test.ts | 17 ++++---- packages/ai-react/src/use-generate-audio.ts | 8 ++-- packages/ai-react/src/use-generate-image.ts | 8 ++-- packages/ai-react/src/use-generate-speech.ts | 4 +- packages/ai-react/src/use-generate-video.ts | 6 +-- packages/ai-react/src/use-generation.ts | 8 ++-- packages/ai-react/src/use-summarize.ts | 4 +- packages/ai-react/src/use-transcription.ts | 4 +- .../ai-react/tests/use-generation.test.ts | 10 ++--- packages/ai-solid/src/use-generate-video.ts | 6 +-- packages/ai-solid/src/use-generation.ts | 8 ++-- .../ai-solid/tests/use-generation.test.ts | 6 +-- .../src/create-generate-video.svelte.ts | 6 +-- .../ai-svelte/src/create-generation.svelte.ts | 10 ++--- .../ai-svelte/tests/create-generation.test.ts | 8 +--- packages/ai-vue/src/use-generate-video.ts | 6 +-- packages/ai-vue/src/use-generation.ts | 8 ++-- packages/ai-vue/tests/use-generation.test.ts | 8 +--- 28 files changed, 113 insertions(+), 162 deletions(-) diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index 28fc580e8..93299f845 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -10,6 +10,6 @@ Add client-side generation persistence: a lightweight, read-only resume snapshot for media generation activities. -Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence: { server }` option and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus observed `pendingArtifacts` / `resultArtifacts`). As a run streams, the client builds a `GenerationResumeSnapshot` — run identity, status, errors, and result metadata, but **never** the generated media bytes — and writes it to the provided `GenerationServerPersistence` store. On reload the snapshot is surfaced for observability; it exposes no `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called. +Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence` storage adapter and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus observed `pendingArtifacts` / `resultArtifacts`). As a run streams, the client builds a `GenerationResumeSnapshot` — run identity, status, errors, and result metadata, but **never** the generated media bytes — and writes it to the adapter. The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too. On reload the snapshot is surfaced for observability; it exposes no `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called. This pairs with the existing `withGenerationPersistence` server middleware, which records run status in the shared `RunStore`. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 3c9c281f0..82ba6bb7f 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -58,29 +58,21 @@ because `RunStore` is keyed by `runId`. ## Persist the client snapshot ```tsx +import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -import type { GenerationServerPersistence } from '@tanstack/ai-client' - -// `GenerationServerPersistence` already pins the stored value to a -// `GenerationResumeSnapshot`, so there is no generic to pass at the call site. -const snapshots: GenerationServerPersistence = { - getItem: (id) => { - const raw = localStorage.getItem(`my-app:generation:${id}`) - return raw ? JSON.parse(raw) : null - }, - setItem: (id, value) => { - localStorage.setItem(`my-app:generation:${id}`, JSON.stringify(value)) - }, - removeItem: (id) => { - localStorage.removeItem(`my-app:generation:${id}`) - }, -} +import type { GenerationResumeSnapshot } from '@tanstack/ai-client' + +// The same web-storage adapters the chat client uses work here — pass the +// snapshot type so the adapter stores a `GenerationResumeSnapshot`. +const snapshots = localStoragePersistence({ + keyPrefix: 'my-app:generation:', +}) export function HeroImageGenerator() { const image = useGenerateImage({ id: 'hero-image', connection: fetchServerSentEvents('/api/generate/image'), - persistence: { server: snapshots }, + persistence: snapshots, }) return ( diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 64b5a6589..64ff97140 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -2,40 +2,20 @@ import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useGenerateImage } from '@tanstack/ai-react' import type { UseGenerateImageReturn } from '@tanstack/ai-react' -import { fetchServerSentEvents } from '@tanstack/ai-client' -import type { - GenerationResumeSnapshot, - GenerationServerPersistence, +import { + fetchServerSentEvents, + localStoragePersistence, } from '@tanstack/ai-client' +import type { GenerationResumeSnapshot } from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' -// Lightweight, read-only generation resume snapshots persisted in the browser. -// Only run identity, status, errors, and result metadata are stored — never the -// generated image bytes. On reload the last snapshot is surfaced for -// observability; generation still only starts when `generate(...)` is called. -const imageSnapshots: GenerationServerPersistence = { - getItem: (id) => { - if (typeof window === 'undefined') return null - const raw = window.localStorage.getItem(`example:generation:${id}`) - if (!raw) return null - const parsed: unknown = JSON.parse(raw) - return parsed && typeof parsed === 'object' - ? (parsed as GenerationResumeSnapshot) - : null - }, - setItem: (id, value) => { - if (typeof window === 'undefined') return - window.localStorage.setItem( - `example:generation:${id}`, - JSON.stringify(value), - ) - }, - removeItem: (id) => { - if (typeof window === 'undefined') return - window.localStorage.removeItem(`example:generation:${id}`) - }, -} +// Reuse the shared web-storage adapter for the lightweight, read-only +// generation resume snapshot. Only run identity, status, errors, and result +// metadata are stored — never the generated image bytes. +const imageSnapshots = localStoragePersistence({ + keyPrefix: 'example:generation:', +}) function StreamingImageGeneration() { const [prompt, setPrompt] = useState('') @@ -107,7 +87,7 @@ function PersistedImageGeneration() { const hookReturn = useGenerateImage({ id: 'persisted-image', connection: fetchServerSentEvents('/api/generate/image'), - persistence: { server: imageSnapshots }, + persistence: imageSnapshots, }) return ( diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index 4e75df991..ce1a62a6f 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -18,7 +18,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -37,7 +37,7 @@ export interface InjectGenerateVideoOptions { id?: string body?: ReactiveOption> devtools?: AIDevtoolsDisplayOptions - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence initialResumeSnapshot?: GenerationResumeSnapshot onResult?: (result: VideoGenerateResult) => TOutput | null | void onError?: (error: Error) => void @@ -203,7 +203,7 @@ export function injectGenerateVideo( ) } - // Mount devtools only. Generation runs are never auto-started after render — + // Mount devtools only. Generation runs are never auto-started after render — // persisted state is read-only for display. afterNextRender( () => { diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index 542f2c6e6..7aef1daa9 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -19,7 +19,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -41,7 +41,7 @@ export interface InjectGenerationOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -89,7 +89,7 @@ export interface InjectGenerationResult { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function injectGeneration< TInput extends Record, @@ -214,7 +214,7 @@ export function injectGeneration< ) } - // Mount devtools only. Generation runs are never auto-started after render — + // Mount devtools only. Generation runs are never auto-started after render — // persisted state is read-only for display. afterNextRender( () => { diff --git a/packages/ai-angular/tests/inject-generation.test.ts b/packages/ai-angular/tests/inject-generation.test.ts index 2ee11685c..350dcdbb6 100644 --- a/packages/ai-angular/tests/inject-generation.test.ts +++ b/packages/ai-angular/tests/inject-generation.test.ts @@ -131,9 +131,7 @@ describe('injectGeneration', () => { const { result } = renderInjectGeneration({ id: 'no-auto-fire', connection: adapter, - persistence: { - server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, - }, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: snapshot, }) @@ -156,9 +154,7 @@ describe('injectGenerateVideo', () => { const { result } = renderInjectGenerateVideo({ id: 'video-no-auto-fire', connection: adapter, - persistence: { - server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, - }, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: videoResumeSnapshot, }) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 9d1dd6ebe..abf77cdb1 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -20,7 +20,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationResumeSnapshot, - GenerationServerPersistence, + GenerationPersistence, } from './generation-types' /** @@ -89,7 +89,7 @@ export class GenerationClient< private readonly devtoolsMetadata: AIDevtoolsClientMetadata private readonly devtoolsBridge: GenerationDevtoolsBridge private readonly threadId: string - private readonly serverPersistence: GenerationServerPersistence | undefined + private readonly serverPersistence: GenerationPersistence | undefined private body: Record private result: TOutput | null = null private input: TInput | null = null @@ -120,7 +120,7 @@ export class GenerationClient< this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} - this.serverPersistence = options.persistence?.server + this.serverPersistence = options.persistence this.resumeSnapshot = options.initialResumeSnapshot this.callbacksRef = { diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index 86d03cb0e..95084a9a9 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -6,6 +6,7 @@ import type { import type { TranscriptionResponseFormat } from '@tanstack/ai' import type { ConnectConnectionAdapter } from './connection-adapters' import type { AIDevtoolsClientMetadata } from './devtools' +import type { ChatStorageAdapter } from './types' import type { GenerationDevtoolsBridgeFactory, VideoDevtoolsBridgeFactory, @@ -101,21 +102,14 @@ export interface GenerationResumeSnapshot { lastEvent?: GenerationEventSnapshot } -export interface GenerationServerPersistence { - getItem: ( - id: string, - ) => - | GenerationResumeSnapshot - | null - | undefined - | Promise - setItem: (id: string, value: GenerationResumeSnapshot) => void | Promise - removeItem: (id: string) => void | Promise -} - -export interface GenerationPersistenceOptions { - server?: GenerationServerPersistence -} +/** + * Storage adapter for the lightweight generation resume snapshot. This is the + * same generic {@link ChatStorageAdapter} contract the chat client uses, so the + * `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` + * factories work here too. Only the snapshot is ever written — never the + * generated media bytes. + */ +export type GenerationPersistence = ChatStorageAdapter // =========================== // Event Constants @@ -208,11 +202,12 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { initialResumeSnapshot?: GenerationResumeSnapshot /** - * Optional persistence adapters for lightweight generation state. - * Generation hooks only support `server` persistence; generated media bytes - * are never written into browser storage by this client. + * Optional storage adapter for the lightweight generation resume snapshot. + * Accepts any {@link ChatStorageAdapter} — including the shared + * `localStoragePersistence` / `sessionStoragePersistence` / + * `indexedDBPersistence` factories. Generated media bytes are never written. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** * Factory that constructs the devtools bridge. Default is a no-op diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index e23a1e866..291ff4ec6 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -77,8 +77,7 @@ export type { GenerationResultSnapshot, GenerationErrorSnapshot, GenerationEventSnapshot, - GenerationServerPersistence, - GenerationPersistenceOptions, + GenerationPersistence, GenerationClientOptions, GenerationFetcher, GenerationFetcherOptions, diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 4cd5c6c1c..1d3433b71 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -19,7 +19,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationResumeSnapshot, - GenerationServerPersistence, + GenerationPersistence, VideoGenerateInput, VideoGenerateResult, VideoGenerationClientOptions, @@ -96,7 +96,7 @@ export class VideoGenerationClient { private readonly devtoolsMetadata: AIDevtoolsClientMetadata private readonly devtoolsBridge: VideoDevtoolsBridge private readonly threadId: string - private readonly serverPersistence: GenerationServerPersistence | undefined + private readonly serverPersistence: GenerationPersistence | undefined private body: Record private result: TOutput | null = null @@ -130,7 +130,7 @@ export class VideoGenerationClient { this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} - this.serverPersistence = options.persistence?.server + this.serverPersistence = options.persistence this.resumeSnapshot = options.initialResumeSnapshot this.callbacksRef = { diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index 978fdf77c..99f1fcd41 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -7,10 +7,7 @@ import { } from '../src' import type { StreamChunk } from '@tanstack/ai/client' import type { ConnectConnectionAdapter } from '../src/connection-adapters' -import type { - GenerationResumeSnapshot, - GenerationServerPersistence, -} from '../src' +import type { GenerationResumeSnapshot, GenerationPersistence } from '../src' // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( @@ -1332,7 +1329,7 @@ describe('GenerationClient', () => { it('reports rejected persistence writes without rejecting generation', async () => { const warningSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const persistenceError = new Error('persistence failed') - const persistence: GenerationServerPersistence = { + const persistence: GenerationPersistence = { getItem: vi.fn(), setItem: vi.fn(async () => { throw persistenceError @@ -1348,7 +1345,7 @@ describe('GenerationClient', () => { timestamp: Date.now(), }, ]), - persistence: { server: persistence }, + persistence: persistence, }) await expect(client.generate({ prompt: 'test' })).resolves.toBeUndefined() @@ -1366,7 +1363,7 @@ describe('GenerationClient', () => { it('keeps a delayed running write from overwriting a terminal complete snapshot', async () => { const runningWrite = createDeferred() let storedSnapshot: GenerationResumeSnapshot | undefined - const persistence: GenerationServerPersistence = { + const persistence: GenerationPersistence = { getItem: vi.fn(), setItem: vi.fn(async (_id, snapshot) => { if (snapshot.status === 'running') { @@ -1392,7 +1389,7 @@ describe('GenerationClient', () => { timestamp: Date.now(), }, ]), - persistence: { server: persistence }, + persistence: persistence, }) await client.generate({ prompt: 'test' }) @@ -1411,7 +1408,7 @@ describe('GenerationClient', () => { it('keeps a delayed video running write from overwriting a terminal error snapshot', async () => { const runningWrite = createDeferred() let storedSnapshot: GenerationResumeSnapshot | undefined - const persistence: GenerationServerPersistence = { + const persistence: GenerationPersistence = { getItem: vi.fn(), setItem: vi.fn(async (_id, snapshot) => { if (snapshot.status === 'running') { @@ -1437,7 +1434,7 @@ describe('GenerationClient', () => { timestamp: Date.now(), }, ]), - persistence: { server: persistence }, + persistence: persistence, }) await client.generate({ prompt: 'test' }) diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index 402566ef0..98122c4ec 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -7,7 +7,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -31,7 +31,7 @@ export interface UseGenerateAudioOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -84,8 +84,8 @@ export interface UseGenerateAudioReturn { * React hook for generating audio (music, sound effects) using AI models. * * Supports two transport modes: - * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) - * - **Fetcher** — Direct async function call + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call * * @example * ```tsx diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index 0f6b06c78..452b1a838 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -6,7 +6,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, ImageGenerateInput, @@ -31,7 +31,7 @@ export interface UseGenerateImageOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -84,8 +84,8 @@ export interface UseGenerateImageReturn { * React hook for generating images using AI models. * * Supports two transport modes: - * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) - * - **Fetcher** — Direct async function call + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call * * @example * ```tsx diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index 65da8e3dc..793e5b3a8 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -6,7 +6,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -31,7 +31,7 @@ export interface UseGenerateSpeechOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app. */ initialResumeSnapshot?: GenerationResumeSnapshot /** diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index 0a2f015cc..a84af20a0 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -8,7 +8,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -33,7 +33,7 @@ export interface UseGenerateVideoOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -232,7 +232,7 @@ export function useGenerateVideo( }, [client, options.body]) // Mount devtools and clean up on unmount. Generation runs are never - // auto-started on mount — persisted state is read-only for display. + // auto-started on mount — persisted state is read-only for display. useEffect(() => { client.mountDevtools() diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 55ead65a3..ea54e21ff 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -9,7 +9,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -37,7 +37,7 @@ export interface UseGenerationOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -109,7 +109,7 @@ export interface UseGenerationReturn { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function useGeneration< TInput extends Record, @@ -205,7 +205,7 @@ export function useGeneration< }, [client, options.body]) // Mount devtools and clean up on unmount. Generation runs are never - // auto-started on mount — persisted state is read-only for display. + // auto-started on mount — persisted state is read-only for display. useEffect(() => { client.mountDevtools() diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index 9e36d0263..0deac64bf 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -6,7 +6,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -31,7 +31,7 @@ export interface UseSummarizeOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app. */ initialResumeSnapshot?: GenerationResumeSnapshot /** diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index b752bce39..ed22ad3a5 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -6,7 +6,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -31,7 +31,7 @@ export interface UseTranscriptionOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app. */ initialResumeSnapshot?: GenerationResumeSnapshot /** diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index fa323e69e..67a8af348 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -18,7 +18,7 @@ import { EventType } from '@tanstack/ai' import type { ConnectConnectionAdapter, GenerationResumeSnapshot, - GenerationServerPersistence, + GenerationPersistence, RunAgentInputContext, } from '@tanstack/ai-client' @@ -377,7 +377,7 @@ describe('useGeneration', () => { const { adapter, connect } = createRunContextCaptureAdapter( createGenerationChunks({ id: '1' }), ) - const persistence: GenerationServerPersistence = { + const persistence: GenerationPersistence = { getItem: vi.fn(() => ({ resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, status: 'running' as const, @@ -390,7 +390,7 @@ describe('useGeneration', () => { useGeneration({ id: 'no-auto-fire', connection: adapter, - persistence: { server: persistence }, + persistence: persistence, initialResumeSnapshot: { resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, status: 'running', @@ -825,7 +825,7 @@ describe('useGenerateVideo', () => { const { adapter, connect } = createRunContextCaptureAdapter( createReplayVideoChunks(), ) - const persistence: GenerationServerPersistence = { + const persistence: GenerationPersistence = { getItem: vi.fn(() => videoResumeSnapshot), setItem: vi.fn(), removeItem: vi.fn(), @@ -835,7 +835,7 @@ describe('useGenerateVideo', () => { useGenerateVideo({ id: 'video-no-auto-fire', connection: adapter, - persistence: { server: persistence }, + persistence: persistence, initialResumeSnapshot: videoResumeSnapshot, }), ) diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index 0b8af0fd6..d99323e5d 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -15,7 +15,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -43,7 +43,7 @@ export interface UseGenerateVideoOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -253,7 +253,7 @@ export function useGenerateVideo( }) }) - // Mount devtools only. Generation runs are never auto-started on mount — + // Mount devtools only. Generation runs are never auto-started on mount — // persisted state is read-only for display. onMount(() => { client().mountDevtools() diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index bd78244fb..5098b91ec 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -16,7 +16,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -45,7 +45,7 @@ export interface UseGenerationOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -118,7 +118,7 @@ export interface UseGenerationReturn { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function useGeneration< TInput extends Record, @@ -220,7 +220,7 @@ export function useGeneration< }) }) - // Mount devtools only. Generation runs are never auto-started on mount — + // Mount devtools only. Generation runs are never auto-started on mount — // persisted state is read-only for display. onMount(() => { client().mountDevtools() diff --git a/packages/ai-solid/tests/use-generation.test.ts b/packages/ai-solid/tests/use-generation.test.ts index c365c8b10..605bfe45c 100644 --- a/packages/ai-solid/tests/use-generation.test.ts +++ b/packages/ai-solid/tests/use-generation.test.ts @@ -13,7 +13,7 @@ import { EventType } from '@tanstack/ai' import type { ConnectConnectionAdapter, GenerationResumeSnapshot, - GenerationServerPersistence, + GenerationPersistence, RunAgentInputContext, } from '@tanstack/ai-client' @@ -1140,7 +1140,7 @@ describe('useGenerateVideo', () => { const { adapter, connect } = createRunContextCaptureAdapter( createReplayVideoChunks(), ) - const persistence: GenerationServerPersistence = { + const persistence: GenerationPersistence = { getItem: vi.fn(() => videoResumeSnapshot), setItem: vi.fn(), removeItem: vi.fn(), @@ -1150,7 +1150,7 @@ describe('useGenerateVideo', () => { useGenerateVideo({ id: 'video-no-auto-fire', connection: adapter, - persistence: { server: persistence }, + persistence: persistence, initialResumeSnapshot: videoResumeSnapshot, }), ) diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index 45460fc1e..1dd954c75 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -7,7 +7,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -34,7 +34,7 @@ export interface CreateGenerateVideoOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -248,7 +248,7 @@ export function createGenerateVideo( ) } - // Mount devtools only. Generation runs are never auto-started on setup — + // Mount devtools only. Generation runs are never auto-started on setup — // persisted state is read-only for display. client.mountDevtools() diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index 5f80a7b7e..24e36e7c4 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -8,7 +8,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -36,7 +36,7 @@ export interface CreateGenerationOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -124,7 +124,7 @@ export interface CreateGenerationReturn { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function createGeneration< TInput extends Record, @@ -161,7 +161,7 @@ export function createGeneration< // `body` uses a conditional spread because `GenerationClientOptions.body` // is declared `body?: Record` (absent vs. present) under // `exactOptionalPropertyTypes`. Assigning `undefined` directly would be - // rejected — the optional caller `options.body` may be undefined, in which + // rejected — the optional caller `options.body` may be undefined, in which // case we want the key to be absent. const clientOptions: GenerationClientOptions = { id: clientId, @@ -230,7 +230,7 @@ export function createGeneration< ) } - // Mount devtools only. Generation runs are never auto-started on setup — + // Mount devtools only. Generation runs are never auto-started on setup — // persisted state is read-only for display. client.mountDevtools() diff --git a/packages/ai-svelte/tests/create-generation.test.ts b/packages/ai-svelte/tests/create-generation.test.ts index 2da5759e4..c9e4e1185 100644 --- a/packages/ai-svelte/tests/create-generation.test.ts +++ b/packages/ai-svelte/tests/create-generation.test.ts @@ -230,9 +230,7 @@ describe('createGeneration', () => { const gen = createGeneration({ id: 'no-auto-fire', connection: adapter, - persistence: { - server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, - }, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: snapshot, }) @@ -724,9 +722,7 @@ describe('createGenerateVideo', () => { const gen = createGenerateVideo({ id: 'video-no-auto-fire', connection: adapter, - persistence: { - server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, - }, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: videoResumeSnapshot, }) diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 823ab8a73..06c9f3970 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -15,7 +15,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -43,7 +43,7 @@ export interface UseGenerateVideoOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -275,7 +275,7 @@ export function useGenerateVideo( }, ) - // Mount devtools only. Generation runs are never auto-started on mount — + // Mount devtools only. Generation runs are never auto-started on mount — // persisted state is read-only for display. onMounted(() => { client.mountDevtools() diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index 6c6005d66..f49d51957 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -16,7 +16,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPendingArtifact, - GenerationPersistenceOptions, + GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -45,7 +45,7 @@ export interface UseGenerationOptions { /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions /** Server-side lightweight generation state persistence. */ - persistence?: GenerationPersistenceOptions + persistence?: GenerationPersistence /** Initial lightweight resume snapshot restored by the app (read-only state). */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -120,7 +120,7 @@ export interface UseGenerationReturn { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function useGeneration< TInput extends Record, @@ -245,7 +245,7 @@ export function useGeneration< }, ) - // Mount devtools only. Generation runs are never auto-started on mount — + // Mount devtools only. Generation runs are never auto-started on mount — // persisted state is read-only for display. onMounted(() => { client.mountDevtools() diff --git a/packages/ai-vue/tests/use-generation.test.ts b/packages/ai-vue/tests/use-generation.test.ts index a1cbe1739..7004ba995 100644 --- a/packages/ai-vue/tests/use-generation.test.ts +++ b/packages/ai-vue/tests/use-generation.test.ts @@ -261,9 +261,7 @@ describe('useGeneration', () => { useGeneration({ id: 'no-auto-fire', connection: adapter, - persistence: { - server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, - }, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: snapshot, }), ) @@ -862,9 +860,7 @@ describe('useGenerateVideo', () => { useGenerateVideo({ id: 'video-no-auto-fire', connection: adapter, - persistence: { - server: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, - }, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: videoResumeSnapshot, }), ) From ad704da37285e08322912c116f3106125c782af3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:04:53 +0200 Subject: [PATCH 04/66] refactor(persistence): infer generation store type via GenerationPersistence (no call-site generic) --- docs/persistence/generation-persistence.md | 8 ++++---- examples/ts-react-chat/src/routes/generations.image.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 82ba6bb7f..721eb3694 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -60,11 +60,11 @@ because `RunStore` is keyed by `runId`. ```tsx import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -import type { GenerationResumeSnapshot } from '@tanstack/ai-client' +import type { GenerationPersistence } from '@tanstack/ai-client' -// The same web-storage adapters the chat client uses work here — pass the -// snapshot type so the adapter stores a `GenerationResumeSnapshot`. -const snapshots = localStoragePersistence({ +// The same web-storage adapters the chat client uses work here. Annotating the +// store with `GenerationPersistence` infers the snapshot type — no generic. +const snapshots: GenerationPersistence = localStoragePersistence({ keyPrefix: 'my-app:generation:', }) diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 64ff97140..7874cd1be 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -6,14 +6,14 @@ import { fetchServerSentEvents, localStoragePersistence, } from '@tanstack/ai-client' -import type { GenerationResumeSnapshot } from '@tanstack/ai-client' +import type { GenerationPersistence } from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' // Reuse the shared web-storage adapter for the lightweight, read-only // generation resume snapshot. Only run identity, status, errors, and result // metadata are stored — never the generated image bytes. -const imageSnapshots = localStoragePersistence({ +const imageSnapshots: GenerationPersistence = localStoragePersistence({ keyPrefix: 'example:generation:', }) From 19a9fe9c9acc4ea9d6de9d774ec29d094098d9ef Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:18:31 +0200 Subject: [PATCH 05/66] refactor(persistence): value-agnostic web-storage adapter defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/generation-persistence.md | 9 +++---- .../src/routes/generations.image.tsx | 3 +-- packages/ai-client/src/storage-adapters.ts | 25 +++++++++++-------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 721eb3694..45de788d6 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -60,13 +60,10 @@ because `RunStore` is keyed by `runId`. ```tsx import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -import type { GenerationPersistence } from '@tanstack/ai-client' -// The same web-storage adapters the chat client uses work here. Annotating the -// store with `GenerationPersistence` infers the snapshot type — no generic. -const snapshots: GenerationPersistence = localStoragePersistence({ - keyPrefix: 'my-app:generation:', -}) +// The same web-storage adapters the chat client uses work here — no type +// argument or annotation needed; the `persistence` option constrains the value. +const snapshots = localStoragePersistence({ keyPrefix: 'my-app:generation:' }) export function HeroImageGenerator() { const image = useGenerateImage({ diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 7874cd1be..fa67ef1bd 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -6,14 +6,13 @@ import { fetchServerSentEvents, localStoragePersistence, } from '@tanstack/ai-client' -import type { GenerationPersistence } from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' // Reuse the shared web-storage adapter for the lightweight, read-only // generation resume snapshot. Only run identity, status, errors, and result // metadata are stored — never the generated image bytes. -const imageSnapshots: GenerationPersistence = localStoragePersistence({ +const imageSnapshots = localStoragePersistence({ keyPrefix: 'example:generation:', }) diff --git a/packages/ai-client/src/storage-adapters.ts b/packages/ai-client/src/storage-adapters.ts index bc67a358c..327350921 100644 --- a/packages/ai-client/src/storage-adapters.ts +++ b/packages/ai-client/src/storage-adapters.ts @@ -1,4 +1,4 @@ -import type { ChatPersistedState, ChatStorageAdapter } from './types' +import type { ChatStorageAdapter } from './types' export interface WebStoragePersistenceOptions { keyPrefix?: string @@ -88,12 +88,15 @@ function createWebStoragePersistence( * adapter can be constructed safely on the server. * * The `serialize` / `deserialize` codec defaults to `JSON.stringify` / - * `JSON.parse`, so the common case needs no codec. `TValue` defaults to - * {@link ChatPersistedState}, so `localStoragePersistence()` drops straight into - * the `persistence` option with no type argument. Pass a codec only for values - * JSON can't round-trip losslessly, and a type argument for non-chat storage. + * `JSON.parse`, so the common case needs no codec. `TValue` is value-agnostic + * by default, so `localStoragePersistence()` drops straight into any + * `persistence` option — chat or generation — with no type argument; the option + * you pass it to constrains the stored value. Pass a codec only for values JSON + * can't round-trip losslessly, and a type argument to lock the store's value + * type at the call site. */ -export function localStoragePersistence( +// oxlint-disable-next-line typescript/no-explicit-any -- value-agnostic default; the consuming `persistence` option constrains the value type +export function localStoragePersistence( options: WebStoragePersistenceOptions = {}, ): ChatStorageAdapter { return createWebStoragePersistence('localStorage', options) @@ -102,11 +105,12 @@ export function localStoragePersistence( /** * A `ChatStorageAdapter` backed by `window.sessionStorage` (scoped to the tab * and cleared when it closes). Identical to {@link localStoragePersistence} in - * every other respect: `ChatPersistedState` default `TValue`, `tanstack-ai:` + * every other respect: value-agnostic default `TValue`, `tanstack-ai:` * default `keyPrefix`, lazy per-operation {@link StorageUnavailableError} on * SSR, and a JSON codec that defaults to `JSON.stringify` / `JSON.parse`. */ -export function sessionStoragePersistence( +// oxlint-disable-next-line typescript/no-explicit-any -- value-agnostic default; the consuming `persistence` option constrains the value type +export function sessionStoragePersistence( options: WebStoragePersistenceOptions = {}, ): ChatStorageAdapter { return createWebStoragePersistence('sessionStorage', options) @@ -121,9 +125,10 @@ export function sessionStoragePersistence( * * No serialize/deserialize codec is needed or accepted — values are stored via * IndexedDB's native structured clone, so `Date`, `Map`, `ArrayBuffer`, etc. - * round-trip without a JSON step. `TValue` defaults to {@link ChatPersistedState}. + * round-trip without a JSON step. `TValue` is value-agnostic by default. */ -export function indexedDBPersistence( +// oxlint-disable-next-line typescript/no-explicit-any -- value-agnostic default; the consuming `persistence` option constrains the value type +export function indexedDBPersistence( options: IndexedDBPersistenceOptions = {}, ): ChatStorageAdapter { const databaseName = options.databaseName ?? 'tanstack-ai' From 415627a1b87a03d93f8873b612329380a4eeec46 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:32:14 +0200 Subject: [PATCH 06/66] docs(persistence): fix stale generation-persistence delivery guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/generation-persistence.md | 60 ++++++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 45de788d6..c0c88ea45 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -7,16 +7,29 @@ id: generation-persistence Generation persistence records run status for media generation activities. Client hooks may store a lightweight, read-only snapshot containing run -identity, status, and errors. +identity, status, and errors — useful for observability after a reload. Generated bytes never belong in browser resume state. +Two layers work together, exactly as they do for chat: + +- **State** — the run's status and result, queryable after completion, via + `withGenerationPersistence` on the server and an optional client snapshot. +- **Delivery** — re-attaching to a run whose stream is _still in flight_ after a + dropped connection, via [resumable streams](../resumable-streams/overview). + Because a generation is just a streaming response, the delivery layer applies + unchanged: add a durability adapter and a `GET` handler, and a connection + dropped mid-generation re-attaches through the same connection adapters + `useChat` uses — no generation-specific `resume()` action is needed. + ## Create the server endpoint ```ts group=generation-persistence import { generateImage, generationParamsFromRequest, + memoryStream, + resumeServerSentEventsResponse, toServerSentEventsResponse, } from '@tanstack/ai' import { openaiImage } from '@tanstack/ai-openai' @@ -29,6 +42,7 @@ const persistence = sqlitePersistence({ }) export async function POST(request: Request) { + const durability = memoryStream(request) const { input, threadId, runId } = await generationParamsFromRequest('image', request) @@ -45,12 +59,23 @@ export async function POST(request: Request) { middleware: [withGenerationPersistence(persistence)], }) - return toServerSentEventsResponse(stream) + // `withGenerationPersistence` records the run's status/result (state); + // `durability` records the stream so a reload can re-attach (delivery). + return toServerSentEventsResponse(stream, { + durability: { adapter: durability }, + }) +} + +export async function GET(request: Request) { + // Replays an in-flight run from the durability log — no provider call here. + return resumeServerSentEventsResponse({ adapter: memoryStream(request) }) } ``` Use the matching request kind for audio, TTS, video, or transcription. `withGenerationPersistence` records runs whenever a `runs` store is present. +Swap `memoryStream` for `@tanstack/ai-durable-stream`'s `durableStream` in +production, where requests span processes. Keep run ids unique across chat and generation when they share a backend, because `RunStore` is keyed by `runId`. @@ -89,10 +114,11 @@ export function HeroImageGenerator() { } ``` -The snapshot has no stream delivery offset and exposes no `resume()` action. -Generation starts only when `generate(...)` is called. The snapshot is useful -for observability after reload; it does not restart or reconnect to provider -work. +The snapshot is read-only run state, not a delivery cursor. Generation starts +only when `generate(...)` is called; the snapshot never re-runs the provider. +Reading it back after a reload tells you which run last ran and how it finished +(and gives you its `runId`). Re-attaching to a run that is _still streaming_ is +the delivery layer's job — see below. ## Media bytes are not stored yet @@ -102,8 +128,20 @@ point at media that is no longer downloadable. Durable artifact storage (artifact metadata plus blob bytes, served from your own storage) is a follow-up feature and is not part of this release. -## Delivery remains separate - -State persistence makes the run and result queryable after completion. It does -not replay an in-flight response — that is a separate transport-layer feature -(stream re-attach / delivery durability), landing in PR #955. +## Reconnecting to an in-flight run + +State persistence makes the run and result queryable after completion. To also +re-attach to a run whose stream is _still in flight_ after a dropped connection, +use the delivery layer — it is not generation-specific. The server endpoint +above already wires it: a `durability` adapter on `toServerSentEventsResponse` +plus a `GET` handler that calls `resumeServerSentEventsResponse`. On the client +there is nothing extra to do — a connection dropped mid-generation re-attaches +automatically through `fetchServerSentEvents` / `fetchHttpStream`, the same +adapters `useChat` uses. + +After a full page reload the snapshot is what carries over: read it back to show +the last run and its `runId`. The generation hooks do not auto-resume on mount +(generation runs only on `generate(...)`), so a post-reload reconnect is +something you drive yourself from the persisted `runId`. See +[Resumable Streams](../resumable-streams/overview) for the durability contract, +production adapters, and the one-time-side-effects gotcha. From 4a2b345b9272142ff4c298c6f9b48d3b1fc5c3f2 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:58:33 +0200 Subject: [PATCH 07/66] docs(persistence): rewrite generation-persistence for clarity + when-to-use --- docs/persistence/generation-persistence.md | 107 +++++++++++---------- 1 file changed, 55 insertions(+), 52 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index c0c88ea45..dd8e4a852 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -5,25 +5,33 @@ id: generation-persistence # Generation Persistence -Generation persistence records run status for media generation activities. -Client hooks may store a lightweight, read-only snapshot containing run -identity, status, and errors — useful for observability after a reload. +Media generation takes time, and video can take minutes. If the user reloads the +page or their connection drops mid-run, that run is easy to lose track of. +Generation persistence keeps a small record of each run so your app can pick +things back up. -Generated bytes never belong in browser resume state. +It helps with two things: -Two layers work together, exactly as they do for chat: +- **After a reload**, show what the last run was: its id, whether it finished, + and any error. This is a small read-only snapshot kept in the browser. +- **While a run is still streaming**, let a dropped connection re-attach to it + instead of starting over. This reuses the same resumable streams the chat + client uses. -- **State** — the run's status and result, queryable after completion, via - `withGenerationPersistence` on the server and an optional client snapshot. -- **Delivery** — re-attaching to a run whose stream is _still in flight_ after a - dropped connection, via [resumable streams](../resumable-streams/overview). - Because a generation is just a streaming response, the delivery layer applies - unchanged: add a durability adapter and a `GET` handler, and a connection - dropped mid-generation re-attaches through the same connection adapters - `useChat` uses — no generation-specific `resume()` action is needed. +## When to use it + +Use it when a run is long enough that a reload or a dropped connection actually +matters: video, batch images, long audio, transcription of a big file. For a +quick one-shot image you show and forget, you can skip it. + +It never stores the generated bytes. Only the run's identity, status, errors, +and result metadata (such as the media URL) are saved. See +[What it does not store](#what-it-does-not-store). ## Create the server endpoint +Record each run in a store, and wrap the stream so a reload can re-attach to it: + ```ts group=generation-persistence import { generateImage, @@ -59,35 +67,38 @@ export async function POST(request: Request) { middleware: [withGenerationPersistence(persistence)], }) - // `withGenerationPersistence` records the run's status/result (state); - // `durability` records the stream so a reload can re-attach (delivery). + // withGenerationPersistence records the run's status and result. + // durability records the stream so a reload can re-attach to it. return toServerSentEventsResponse(stream, { durability: { adapter: durability }, }) } export async function GET(request: Request) { - // Replays an in-flight run from the durability log — no provider call here. + // Replays an in-flight run from the durability log. No provider call here. return resumeServerSentEventsResponse({ adapter: memoryStream(request) }) } ``` Use the matching request kind for audio, TTS, video, or transcription. -`withGenerationPersistence` records runs whenever a `runs` store is present. -Swap `memoryStream` for `@tanstack/ai-durable-stream`'s `durableStream` in -production, where requests span processes. +`withGenerationPersistence` records runs whenever a `runs` store is present. In +production, swap `memoryStream` for `durableStream` from +`@tanstack/ai-durable-stream`, where requests span processes. Keep run ids unique across chat and generation when they share a backend, because `RunStore` is keyed by `runId`. -## Persist the client snapshot +## Show the last run after a reload + +Pass a storage adapter as `persistence`. The client writes a snapshot as the run +streams and reads it back on load: ```tsx import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -// The same web-storage adapters the chat client uses work here — no type -// argument or annotation needed; the `persistence` option constrains the value. +// Any web-storage adapter works here; no type argument needed. The persistence +// option fixes the stored value type for you. const snapshots = localStoragePersistence({ keyPrefix: 'my-app:generation:' }) export function HeroImageGenerator() { @@ -114,34 +125,26 @@ export function HeroImageGenerator() { } ``` -The snapshot is read-only run state, not a delivery cursor. Generation starts -only when `generate(...)` is called; the snapshot never re-runs the provider. -Reading it back after a reload tells you which run last ran and how it finished -(and gives you its `runId`). Re-attaching to a run that is _still streaming_ is -the delivery layer's job — see below. - -## Media bytes are not stored yet - -Persisted generation results reference the media URLs the provider returned. -Provider CDN URLs typically expire, so a snapshot inspected much later may -point at media that is no longer downloadable. Durable artifact storage -(artifact metadata plus blob bytes, served from your own storage) is a -follow-up feature and is not part of this release. - -## Reconnecting to an in-flight run - -State persistence makes the run and result queryable after completion. To also -re-attach to a run whose stream is _still in flight_ after a dropped connection, -use the delivery layer — it is not generation-specific. The server endpoint -above already wires it: a `durability` adapter on `toServerSentEventsResponse` -plus a `GET` handler that calls `resumeServerSentEventsResponse`. On the client -there is nothing extra to do — a connection dropped mid-generation re-attaches -automatically through `fetchServerSentEvents` / `fetchHttpStream`, the same -adapters `useChat` uses. - -After a full page reload the snapshot is what carries over: read it back to show -the last run and its `runId`. The generation hooks do not auto-resume on mount -(generation runs only on `generate(...)`), so a post-reload reconnect is -something you drive yourself from the persisted `runId`. See +`image.resumeState` holds the last run's id once a run has streamed. The +snapshot is read-only, so it never re-runs the provider. A generation starts +only when you call `generate(...)`. + +## Reconnect to a run that is still streaming + +The server endpoint above already wires this: a `durability` adapter on +`toServerSentEventsResponse`, plus a `GET` handler that replays the run from the +log. On the client there is nothing to add. A connection dropped mid-generation +re-attaches on its own through `fetchServerSentEvents` or `fetchHttpStream`, the +same adapters `useChat` uses. + +A full page reload is different. The hooks do not start a run on mount, so they +will not reconnect on their own. What survives the reload is the snapshot, which +holds the `runId`, so you can trigger a reconnect from it yourself. See [Resumable Streams](../resumable-streams/overview) for the durability contract, -production adapters, and the one-time-side-effects gotcha. +production adapters, and the one-time-side-effects note. + +## What it does not store + +The snapshot points at the media URL the provider returned, not the bytes. +Provider URLs usually expire, so a snapshot opened much later can point at media +that is gone. If you need the media to last, save the bytes to your own storage. From 1398899cd1895384f2f8d489e30e351ef2c82739 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 15:05:12 +0200 Subject: [PATCH 08/66] docs(persistence): drop redundant storage-adapter comment --- docs/persistence/generation-persistence.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index dd8e4a852..6b9e49abf 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -97,8 +97,6 @@ streams and reads it back on load: import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -// Any web-storage adapter works here; no type argument needed. The persistence -// option fixes the stored value type for you. const snapshots = localStoragePersistence({ keyPrefix: 'my-app:generation:' }) export function HeroImageGenerator() { From 946fb162a9ec7d343935bb84d0c2d9d45a161f8f Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:39:02 +1000 Subject: [PATCH 09/66] 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: 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 --- docs/persistence/generation-persistence.md | 8 +- .../ai-angular/src/inject-generate-video.ts | 2 +- packages/ai-angular/src/inject-generation.ts | 4 +- packages/ai-client/src/generation-client.ts | 193 +++++++++++++++-- packages/ai-client/src/generation-types.ts | 146 +++++++++++-- packages/ai-client/src/index.ts | 1 + .../ai-client/src/video-generation-client.ts | 195 ++++++++++++++++-- packages/ai-react/src/use-generate-audio.ts | 4 +- packages/ai-react/src/use-generate-image.ts | 4 +- packages/ai-react/src/use-generate-video.ts | 2 +- packages/ai-react/src/use-generation.ts | 4 +- packages/ai-solid/src/use-generate-video.ts | 2 +- packages/ai-solid/src/use-generation.ts | 4 +- .../src/create-generate-video.svelte.ts | 2 +- .../ai-svelte/src/create-generation.svelte.ts | 6 +- packages/ai-vue/src/use-generate-video.ts | 2 +- packages/ai-vue/src/use-generation.ts | 4 +- 17 files changed, 507 insertions(+), 76 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 6b9e49abf..f0ddc803e 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -42,7 +42,7 @@ import { } from '@tanstack/ai' import { openaiImage } from '@tanstack/ai-openai' import { withGenerationPersistence } from '@tanstack/ai-persistence' -import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' +import { sqlitePersistence } from './sqlite-persistence' const persistence = sqlitePersistence({ url: 'file:.tanstack-ai/generation.sqlite', @@ -81,8 +81,10 @@ export async function GET(request: Request) { ``` Use the matching request kind for audio, TTS, video, or transcription. -`withGenerationPersistence` records runs whenever a `runs` store is present. In -production, swap `memoryStream` for `durableStream` from +`./sqlite-persistence` is the hand-rolled adapter from +[Build your own adapter](./build-your-own-adapter) — any adapter with a `runs` +store works. `withGenerationPersistence` requires a `runs` store and records +each run in it. In production, swap `memoryStream` for `durableStream` from `@tanstack/ai-durable-stream`, where requests span processes. Keep run ids unique across chat and generation when they share a backend, diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index ce1a62a6f..30b4396e7 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -203,7 +203,7 @@ export function injectGenerateVideo( ) } - // Mount devtools only. Generation runs are never auto-started after render — + // Mount devtools only. Generation runs are never auto-started after render — // persisted state is read-only for display. afterNextRender( () => { diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index 7aef1daa9..95e95559b 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -89,7 +89,7 @@ export interface InjectGenerationResult { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function injectGeneration< TInput extends Record, @@ -214,7 +214,7 @@ export function injectGeneration< ) } - // Mount devtools only. Generation runs are never auto-started after render — + // Mount devtools only. Generation runs are never auto-started after render — // persisted state is read-only for display. afterNextRender( () => { diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index abf77cdb1..ba391c962 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -1,5 +1,7 @@ import { GENERATION_EVENTS, + createGenerationResultSnapshot, + parseGenerationResumeSnapshot, updateGenerationResumeSnapshot, } from './generation-types' import { createNoOpGenerationDevtoolsBridge } from './devtools-noop' @@ -39,7 +41,7 @@ interface GenerationCallbacks { onErrorChange?: ((error: Error | undefined) => void) | undefined onStatusChange?: ((status: GenerationClientState) => void) | undefined onResumeSnapshotChange?: - | ((snapshot: GenerationResumeSnapshot) => void) + | ((snapshot: GenerationResumeSnapshot | undefined) => void) | undefined } @@ -89,7 +91,7 @@ export class GenerationClient< private readonly devtoolsMetadata: AIDevtoolsClientMetadata private readonly devtoolsBridge: GenerationDevtoolsBridge private readonly threadId: string - private readonly serverPersistence: GenerationPersistence | undefined + private readonly resumePersistence: GenerationPersistence | undefined private body: Record private result: TOutput | null = null private input: TInput | null = null @@ -99,6 +101,8 @@ export class GenerationClient< private status: GenerationClientState = 'idle' private resumeSnapshot: GenerationResumeSnapshot | undefined private resumeSnapshotPersistenceQueue: Promise = Promise.resolve() + private resumeSnapshotHydration: Promise | undefined + private queuedSnapshotSignature: string | undefined private resumePersistenceError: Error | undefined = undefined private abortController: AbortController | null = null private readonly callbacksRef: GenerationCallbacks @@ -120,8 +124,9 @@ export class GenerationClient< this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} - this.serverPersistence = options.persistence + this.resumePersistence = options.persistence this.resumeSnapshot = options.initialResumeSnapshot + this.maybeHydrateResumeSnapshot() this.callbacksRef = { onResult: options.onResult, @@ -159,6 +164,12 @@ export class GenerationClient< } mountDevtools(): void { + // Mounting revives a disposed client. Framework hooks call this from + // their mount effect, so a dispose → remount cycle (e.g. React + // StrictMode's mount → cleanup → mount replay against the same memoized + // client) leaves the client usable again. + this.disposed = false + this.maybeHydrateResumeSnapshot() if (this.devtoolsMounted) { return } @@ -174,9 +185,9 @@ export class GenerationClient< * while already generating will be a no-op. */ async generate(input: TInput): Promise { - this.mountDevtools() if (this.disposed) return if (this.isLoading) return + this.mountDevtools() this.input = input this.progress = null @@ -205,7 +216,7 @@ export class GenerationClient< this.devtoolsBridge.ensureRunStarted(runId) this.setResult(result) this.setStatus('success') - this.completePlainFetcherResumeSnapshot() + this.completePlainFetcherResumeSnapshot(result) } } else if (this.connection) { // Streaming adapter path @@ -239,6 +250,7 @@ export class GenerationClient< const error = err instanceof Error ? err : new Error(String(err)) this.setError(error) this.setStatus('error') + this.recordResumeSnapshotError(error) this.devtoolsBridge.finishRun( this.devtoolsBridge.getActiveRunId() ?? runId, 'run:errored', @@ -333,10 +345,22 @@ export class GenerationClient< this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled') } } + // A stopped run is no longer resumable. Without this, storage keeps a + // `running` snapshot forever and a reload would chase a dead run. + if (this.resumeSnapshot && this.resumeSnapshot.status === 'running') { + this.resumeSnapshot = { + ...this.resumeSnapshot, + resumeState: null, + status: 'idle', + } + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + void this.persistResumeSnapshot(this.resumeSnapshot) + } } /** - * Clear the result, error, and return to idle state. + * Clear the result, error, and return to idle state. Also clears the + * resume snapshot, removing any persisted record for this client id. */ reset(): void { this.stop() @@ -346,6 +370,7 @@ export class GenerationClient< this.devtoolsBridge.resetRuns() this.setError(undefined) this.setStatus('idle') + this.clearResumeSnapshot() this.devtoolsBridge.emitState() } @@ -534,25 +559,127 @@ export class GenerationClient< void this.persistResumeSnapshot(this.resumeSnapshot) } - private completePlainFetcherResumeSnapshot(): void { - if (!this.resumeSnapshot) { - return - } + /** + * The plain (non-Response) fetcher path never observes stream chunks, so + * the terminal snapshot is built here from the fetcher's own result. A + * stale `error` from a previous run is intentionally dropped — this run + * succeeded. + */ + private completePlainFetcherResumeSnapshot(rawResult: unknown): void { + const previous = this.resumeSnapshot + const result = createGenerationResultSnapshot(rawResult) this.resumeSnapshot = { - ...this.resumeSnapshot, + schemaVersion: 1, resumeState: null, status: 'complete', + ...(previous?.activity ? { activity: previous.activity } : {}), + ...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0 + ? { pendingArtifacts: [...previous.pendingArtifacts] } + : {}), + ...(result + ? { result } + : previous?.result + ? { result: { ...previous.result } } + : {}), + } + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + void this.persistResumeSnapshot(this.resumeSnapshot) + } + + /** + * Records a transport-level failure (network drop, throwing callback) in + * the snapshot. Without this, only a server-emitted RUN_ERROR chunk would + * mark the snapshot `error`, leaving a persisted record that claims the + * run is still in flight. + */ + private recordResumeSnapshotError(error: Error): void { + if (this.resumeSnapshot?.status === 'error') return + if (!this.resumeSnapshot && !this.resumePersistence) return + const previous = this.resumeSnapshot + this.resumeSnapshot = { + schemaVersion: 1, + resumeState: null, + status: 'error', + ...(previous?.activity ? { activity: previous.activity } : {}), + ...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0 + ? { pendingArtifacts: [...previous.pendingArtifacts] } + : {}), + ...(previous?.result ? { result: { ...previous.result } } : {}), + error: { message: error.message }, } this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) void this.persistResumeSnapshot(this.resumeSnapshot) } + private clearResumeSnapshot(): void { + this.resumeSnapshot = undefined + this.queuedSnapshotSignature = undefined + this.callbacksRef.onResumeSnapshotChange?.(undefined) + if (!this.resumePersistence) { + return + } + this.resumeSnapshotPersistenceQueue = + this.resumeSnapshotPersistenceQueue.then( + () => this.removePersistedResumeSnapshot(), + () => this.removePersistedResumeSnapshot(), + ) + } + + /** + * Storage key for this client's snapshot. The `generation:` segment keeps + * a generation client and a chat client that share an id (and a storage + * adapter with the default key prefix) from overwriting each other. + */ + private get resumeSnapshotKey(): string { + return `generation:${this.uniqueId}` + } + + private maybeHydrateResumeSnapshot(): void { + if (!this.resumePersistence || this.resumeSnapshotHydration) return + // An explicit `initialResumeSnapshot` seed takes precedence over storage. + if (this.resumeSnapshot) return + this.resumeSnapshotHydration = this.hydrateResumeSnapshot() + } + + private async hydrateResumeSnapshot(): Promise { + let stored: unknown + try { + stored = await this.resumePersistence?.getItem(this.resumeSnapshotKey) + } catch (error) { + // A corrupt record (e.g. truncated JSON) or unavailable storage must + // not break construction; the app just starts without a snapshot. + console.warn( + '[TanStack AI] Failed to read persisted generation resume snapshot', + error, + ) + return + } + if (stored === null || stored === undefined) return + const snapshot = parseGenerationResumeSnapshot(stored) + if (!snapshot) return + // Live state wins: adopt the stored snapshot only if nothing has been + // observed since construction. + if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + this.resumeSnapshot = snapshot + this.callbacksRef.onResumeSnapshotChange?.(snapshot) + } + private async persistResumeSnapshot( snapshot: GenerationResumeSnapshot, ): Promise { - if (!this.serverPersistence) { + if (!this.resumePersistence) { + return + } + + // Skip writes that only differ in `lastEvent` — for a long streaming run + // this collapses hundreds of per-chunk writes into the handful where the + // snapshot materially changed. (`lastEvent` in storage is best-effort; + // hydration drops it anyway.) + const signature = resumeSnapshotSignature(snapshot) + if (signature === this.queuedSnapshotSignature) { return } + this.queuedSnapshotSignature = signature this.resumeSnapshotPersistenceQueue = this.resumeSnapshotPersistenceQueue.then( @@ -566,16 +693,48 @@ export class GenerationClient< snapshot: GenerationResumeSnapshot, ): Promise { try { - await this.serverPersistence?.setItem(this.threadId, snapshot) + await this.resumePersistence?.setItem(this.resumeSnapshotKey, snapshot) + this.resumePersistenceError = undefined } catch (error) { + // Warn only on the transition into failure, not once per write. + if (!this.resumePersistenceError) { + console.warn( + '[TanStack AI] Failed to persist generation resume snapshot', + error, + ) + } this.resumePersistenceError = error instanceof Error ? error : new Error(String(error)) - console.warn( - '[TanStack AI] Failed to persist generation resume snapshot', - error, - ) + // Allow the next snapshot change to retry even if it is materially + // identical to this failed write. + this.queuedSnapshotSignature = undefined } } + + private async removePersistedResumeSnapshot(): Promise { + try { + await this.resumePersistence?.removeItem(this.resumeSnapshotKey) + this.resumePersistenceError = undefined + } catch (error) { + if (!this.resumePersistenceError) { + console.warn( + '[TanStack AI] Failed to remove persisted generation resume snapshot', + error, + ) + } + this.resumePersistenceError = + error instanceof Error ? error : new Error(String(error)) + } + } +} + +/** + * Stable serialization of the snapshot minus `lastEvent`, used to detect + * material changes between persistence writes. + */ +function resumeSnapshotSignature(snapshot: GenerationResumeSnapshot): string { + const { lastEvent: _lastEvent, ...significant } = snapshot + return JSON.stringify(significant) } function completeProgressValue( diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index 95084a9a9..4ad914b90 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -93,6 +93,12 @@ export interface GenerationEventSnapshot { } export interface GenerationResumeSnapshot { + /** + * Version of the persisted snapshot shape. Written on every persisted + * snapshot so future shape changes can migrate (or reject) old records. + * Optional so hand-written seeds don't need to set it; absent means `1`. + */ + schemaVersion?: 1 resumeState: GenerationResumeState | null status: GenerationResumeStatus activity?: PersistedArtifactRef['source']['activity'] @@ -191,21 +197,24 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { devtools?: Partial /** - * Initial lightweight resume snapshot restored by framework hooks. Contains - * only observed run metadata, errors, and persisted artifact refs. It does - * not trigger any generation, but it is **not** inert: it seeds the client's - * live resume snapshot, which subsequent run events merge into and which - * `getResumeSnapshot()` returns and the client re-persists. Later reads - * therefore reflect this seed merged with observed activity, not the original - * value verbatim. + * Explicit seed for the lightweight resume snapshot, for apps that manage + * storage themselves. When set, automatic hydration from `persistence` is + * skipped. It does not trigger any generation, but it is **not** inert: it + * seeds the client's live resume snapshot, which subsequent run events merge + * into and which `getResumeSnapshot()` returns and the client re-persists. + * Later reads therefore reflect this seed merged with observed activity, not + * the original value verbatim. */ initialResumeSnapshot?: GenerationResumeSnapshot /** - * Optional storage adapter for the lightweight generation resume snapshot. - * Accepts any {@link ChatStorageAdapter} — including the shared + * Optional client-side storage adapter for the lightweight generation resume + * snapshot. Accepts any {@link ChatStorageAdapter} — including the shared * `localStoragePersistence` / `sessionStoragePersistence` / - * `indexedDBPersistence` factories. Generated media bytes are never written. + * `indexedDBPersistence` factories. The client writes the snapshot under the + * key `generation:` as a run streams, and reads it back (validated) on + * construction unless `initialResumeSnapshot` is provided. Generated media + * bytes are never written. */ persistence?: GenerationPersistence @@ -240,26 +249,35 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { onErrorChange?: (error: Error | undefined) => void /** @internal Called when generation status changes */ onStatusChange?: (status: GenerationClientState) => void - /** @internal Called when lightweight resume snapshot changes */ - onResumeSnapshotChange?: (snapshot: GenerationResumeSnapshot) => void + /** @internal Called when lightweight resume snapshot changes. Receives `undefined` when the snapshot is cleared by `reset()`. */ + onResumeSnapshotChange?: (snapshot: GenerationResumeSnapshot | undefined) => void } +/** + * Reduces one observed stream chunk into the lightweight resume snapshot. + * + * A `RUN_STARTED` chunk begins a fresh run, so stale `result` / `error` / + * `pendingArtifacts` from a previous run are dropped rather than carried into + * the new run's snapshot. + */ export function updateGenerationResumeSnapshot( previous: GenerationResumeSnapshot | null | undefined, chunk: StreamChunk, ): GenerationResumeSnapshot { const threadId = stringField(chunk, 'threadId') const runId = stringField(chunk, 'runId') - const previousArtifacts = previous?.pendingArtifacts ?? [] + const carried = chunk.type === 'RUN_STARTED' ? undefined : previous + const previousArtifacts = carried?.pendingArtifacts ?? [] const next: GenerationResumeSnapshot = { - resumeState: previous?.resumeState ?? null, - status: previous?.status ?? 'idle', - ...(previous?.activity ? { activity: previous.activity } : {}), + schemaVersion: 1, + resumeState: carried?.resumeState ?? null, + status: carried?.status ?? 'idle', + ...(carried?.activity ? { activity: carried.activity } : {}), ...(previousArtifacts.length > 0 ? { pendingArtifacts: [...previousArtifacts] } : {}), - ...(previous?.result ? { result: { ...previous.result } } : {}), - ...(previous?.error ? { error: { ...previous.error } } : {}), + ...(carried?.result ? { result: { ...carried.result } } : {}), + ...(carried?.error ? { error: { ...carried.error } } : {}), lastEvent: createGenerationEventSnapshot(chunk), } @@ -286,6 +304,16 @@ export function updateGenerationResumeSnapshot( next.activity = result.artifacts[0]?.source.activity } } + } else if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { + // Capture the job id as soon as the job exists — for a long video run + // this is the one piece of identity worth having after a reload, and + // the terminal `generation:result` may never arrive. + const jobId = isObject(chunk.value) + ? stringField(chunk.value, 'jobId') + : undefined + if (jobId) { + next.result = { ...next.result, jobId } + } } } else if (chunk.type === 'RUN_FINISHED') { next.resumeState = null @@ -299,6 +327,85 @@ export function updateGenerationResumeSnapshot( return next } +/** + * Validates an untrusted value (typically read back from a storage adapter) + * into a {@link GenerationResumeSnapshot}, or returns `undefined` when the + * value is not a usable snapshot. + * + * Storage contents are outside the type system — they may be stale, truncated, + * hand-edited, or written by a future version. Every field is re-validated + * with the same narrowing the live chunk reducer uses. `lastEvent` is not + * restored: it describes a transient stream position that has no meaning + * after a reload. + */ +export function parseGenerationResumeSnapshot( + value: unknown, +): GenerationResumeSnapshot | undefined { + if (!isObject(value)) return undefined + + const schemaVersion = Reflect.get(value, 'schemaVersion') + if (schemaVersion !== undefined && schemaVersion !== 1) return undefined + + const status = generationResumeStatusField(value, 'status') + if (!status) return undefined + + const rawResumeState = Reflect.get(value, 'resumeState') + let resumeState: GenerationResumeState | null = null + if (rawResumeState !== null && rawResumeState !== undefined) { + if (!isObject(rawResumeState)) return undefined + const threadId = stringField(rawResumeState, 'threadId') + const runId = stringField(rawResumeState, 'runId') + if (!threadId || !runId) return undefined + resumeState = { threadId, runId } + } + + const snapshot: GenerationResumeSnapshot = { + schemaVersion: 1, + resumeState, + status, + } + + const activity = persistedArtifactActivityField(value, 'activity') + if (activity) snapshot.activity = activity + + const pendingArtifacts = collectArtifactRefs( + Reflect.get(value, 'pendingArtifacts'), + ) + if (pendingArtifacts.length > 0) snapshot.pendingArtifacts = pendingArtifacts + + const result = createGenerationResultSnapshot(Reflect.get(value, 'result')) + if (result) snapshot.result = result + + const rawError = Reflect.get(value, 'error') + if (isObject(rawError)) { + const message = stringField(rawError, 'message') + if (message) { + const code = stringField(rawError, 'code') + snapshot.error = { message, ...(code ? { code } : {}) } + } + } + + return snapshot +} + +function generationResumeStatusField( + value: object, + key: string, +): GenerationResumeStatus | undefined { + const field = stringField(value, key) + if (field === undefined) return undefined + + switch (field) { + case 'idle': + case 'running': + case 'complete': + case 'error': + return field + default: + return undefined + } +} + // =========================== // Video-Specific Options // =========================== @@ -474,7 +581,8 @@ function createGenerationEventSnapshot( } } -function createGenerationResultSnapshot( +/** @internal Narrows an untrusted result payload into the persisted result snapshot shape. */ +export function createGenerationResultSnapshot( value: unknown, ): GenerationResultSnapshot | undefined { if (!isObject(value)) return undefined diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index 291ff4ec6..70e96072e 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -94,6 +94,7 @@ export type { } from './generation-types' export { GENERATION_EVENTS, + parseGenerationResumeSnapshot, updateGenerationResumeSnapshot, } from './generation-types' export { UnsupportedResponseStreamError } from './response-stream' diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 1d3433b71..ca67a120c 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -1,5 +1,7 @@ import { GENERATION_EVENTS, + createGenerationResultSnapshot, + parseGenerationResumeSnapshot, updateGenerationResumeSnapshot, } from './generation-types' import { createNoOpVideoDevtoolsBridge } from './devtools-noop' @@ -48,7 +50,7 @@ interface VideoCallbacks { onJobIdChange?: ((jobId: string | null) => void) | undefined onVideoStatusChange?: ((status: VideoStatusInfo | null) => void) | undefined onResumeSnapshotChange?: - | ((snapshot: GenerationResumeSnapshot) => void) + | ((snapshot: GenerationResumeSnapshot | undefined) => void) | undefined } @@ -96,7 +98,7 @@ export class VideoGenerationClient { private readonly devtoolsMetadata: AIDevtoolsClientMetadata private readonly devtoolsBridge: VideoDevtoolsBridge private readonly threadId: string - private readonly serverPersistence: GenerationPersistence | undefined + private readonly resumePersistence: GenerationPersistence | undefined private body: Record private result: TOutput | null = null @@ -109,6 +111,8 @@ export class VideoGenerationClient { private status: GenerationClientState = 'idle' private resumeSnapshot: GenerationResumeSnapshot | undefined private resumeSnapshotPersistenceQueue: Promise = Promise.resolve() + private resumeSnapshotHydration: Promise | undefined + private queuedSnapshotSignature: string | undefined private resumePersistenceError: Error | undefined = undefined private abortController: AbortController | null = null private readonly callbacksRef: VideoCallbacks @@ -130,8 +134,9 @@ export class VideoGenerationClient { this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} - this.serverPersistence = options.persistence + this.resumePersistence = options.persistence this.resumeSnapshot = options.initialResumeSnapshot + this.maybeHydrateResumeSnapshot() this.callbacksRef = { onResult: options.onResult, @@ -175,6 +180,12 @@ export class VideoGenerationClient { } mountDevtools(): void { + // Mounting revives a disposed client. Framework hooks call this from + // their mount effect, so a dispose → remount cycle (e.g. React + // StrictMode's mount → cleanup → mount replay against the same memoized + // client) leaves the client usable again. + this.disposed = false + this.maybeHydrateResumeSnapshot() if (this.devtoolsMounted) { return } @@ -189,9 +200,9 @@ export class VideoGenerationClient { * Only one generation can be in-flight at a time. */ async generate(input: VideoGenerateInput): Promise { - this.mountDevtools() if (this.disposed) return if (this.isLoading) return + this.mountDevtools() this.input = input this.progress = null @@ -235,6 +246,7 @@ export class VideoGenerationClient { const error = err instanceof Error ? err : new Error(String(err)) this.setError(error) this.setStatus('error') + this.recordResumeSnapshotError(error) this.devtoolsBridge.finishRun( this.devtoolsBridge.getActiveRunId() ?? runId, 'run:errored', @@ -271,7 +283,7 @@ export class VideoGenerationClient { this.devtoolsBridge.ensureRunStarted(runId) this.setResult(result) this.setStatus('success') - this.completePlainFetcherResumeSnapshot() + this.completePlainFetcherResumeSnapshot(result) } } @@ -366,10 +378,22 @@ export class VideoGenerationClient { this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled') } } + // A stopped run is no longer resumable. Without this, storage keeps a + // `running` snapshot forever and a reload would chase a dead run. + if (this.resumeSnapshot && this.resumeSnapshot.status === 'running') { + this.resumeSnapshot = { + ...this.resumeSnapshot, + resumeState: null, + status: 'idle', + } + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + void this.persistResumeSnapshot(this.resumeSnapshot) + } } /** - * Clear all state and return to idle. + * Clear all state and return to idle. Also clears the resume snapshot, + * removing any persisted record for this client id. */ reset(): void { this.stop() @@ -381,6 +405,7 @@ export class VideoGenerationClient { this.setVideoStatus(null) this.setError(undefined) this.setStatus('idle') + this.clearResumeSnapshot() this.devtoolsBridge.emitState() } @@ -620,25 +645,127 @@ export class VideoGenerationClient { void this.persistResumeSnapshot(this.resumeSnapshot) } - private completePlainFetcherResumeSnapshot(): void { - if (!this.resumeSnapshot) { - return - } + /** + * The plain (non-Response) fetcher path never observes stream chunks, so + * the terminal snapshot is built here from the fetcher's own result. A + * stale `error` from a previous run is intentionally dropped — this run + * succeeded. + */ + private completePlainFetcherResumeSnapshot(rawResult: unknown): void { + const previous = this.resumeSnapshot + const result = createGenerationResultSnapshot(rawResult) this.resumeSnapshot = { - ...this.resumeSnapshot, + schemaVersion: 1, resumeState: null, status: 'complete', + ...(previous?.activity ? { activity: previous.activity } : {}), + ...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0 + ? { pendingArtifacts: [...previous.pendingArtifacts] } + : {}), + ...(result + ? { result } + : previous?.result + ? { result: { ...previous.result } } + : {}), + } + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + void this.persistResumeSnapshot(this.resumeSnapshot) + } + + /** + * Records a transport-level failure (network drop, throwing callback) in + * the snapshot. Without this, only a server-emitted RUN_ERROR chunk would + * mark the snapshot `error`, leaving a persisted record that claims the + * run is still in flight. + */ + private recordResumeSnapshotError(error: Error): void { + if (this.resumeSnapshot?.status === 'error') return + if (!this.resumeSnapshot && !this.resumePersistence) return + const previous = this.resumeSnapshot + this.resumeSnapshot = { + schemaVersion: 1, + resumeState: null, + status: 'error', + ...(previous?.activity ? { activity: previous.activity } : {}), + ...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0 + ? { pendingArtifacts: [...previous.pendingArtifacts] } + : {}), + ...(previous?.result ? { result: { ...previous.result } } : {}), + error: { message: error.message }, } this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) void this.persistResumeSnapshot(this.resumeSnapshot) } + private clearResumeSnapshot(): void { + this.resumeSnapshot = undefined + this.queuedSnapshotSignature = undefined + this.callbacksRef.onResumeSnapshotChange?.(undefined) + if (!this.resumePersistence) { + return + } + this.resumeSnapshotPersistenceQueue = + this.resumeSnapshotPersistenceQueue.then( + () => this.removePersistedResumeSnapshot(), + () => this.removePersistedResumeSnapshot(), + ) + } + + /** + * Storage key for this client's snapshot. The `generation:` segment keeps + * a generation client and a chat client that share an id (and a storage + * adapter with the default key prefix) from overwriting each other. + */ + private get resumeSnapshotKey(): string { + return `generation:${this.uniqueId}` + } + + private maybeHydrateResumeSnapshot(): void { + if (!this.resumePersistence || this.resumeSnapshotHydration) return + // An explicit `initialResumeSnapshot` seed takes precedence over storage. + if (this.resumeSnapshot) return + this.resumeSnapshotHydration = this.hydrateResumeSnapshot() + } + + private async hydrateResumeSnapshot(): Promise { + let stored: unknown + try { + stored = await this.resumePersistence?.getItem(this.resumeSnapshotKey) + } catch (error) { + // A corrupt record (e.g. truncated JSON) or unavailable storage must + // not break construction; the app just starts without a snapshot. + console.warn( + '[TanStack AI] Failed to read persisted generation resume snapshot', + error, + ) + return + } + if (stored === null || stored === undefined) return + const snapshot = parseGenerationResumeSnapshot(stored) + if (!snapshot) return + // Live state wins: adopt the stored snapshot only if nothing has been + // observed since construction. + if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + this.resumeSnapshot = snapshot + this.callbacksRef.onResumeSnapshotChange?.(snapshot) + } + private async persistResumeSnapshot( snapshot: GenerationResumeSnapshot, ): Promise { - if (!this.serverPersistence) { + if (!this.resumePersistence) { + return + } + + // Skip writes that only differ in `lastEvent` — for a long streaming run + // this collapses hundreds of per-chunk writes into the handful where the + // snapshot materially changed. (`lastEvent` in storage is best-effort; + // hydration drops it anyway.) + const signature = videoResumeSnapshotSignature(snapshot) + if (signature === this.queuedSnapshotSignature) { return } + this.queuedSnapshotSignature = signature this.resumeSnapshotPersistenceQueue = this.resumeSnapshotPersistenceQueue.then( @@ -652,14 +779,48 @@ export class VideoGenerationClient { snapshot: GenerationResumeSnapshot, ): Promise { try { - await this.serverPersistence?.setItem(this.threadId, snapshot) + await this.resumePersistence?.setItem(this.resumeSnapshotKey, snapshot) + this.resumePersistenceError = undefined } catch (error) { + // Warn only on the transition into failure, not once per write. + if (!this.resumePersistenceError) { + console.warn( + '[TanStack AI] Failed to persist generation resume snapshot', + error, + ) + } this.resumePersistenceError = error instanceof Error ? error : new Error(String(error)) - console.warn( - '[TanStack AI] Failed to persist generation resume snapshot', - error, - ) + // Allow the next snapshot change to retry even if it is materially + // identical to this failed write. + this.queuedSnapshotSignature = undefined } } + + private async removePersistedResumeSnapshot(): Promise { + try { + await this.resumePersistence?.removeItem(this.resumeSnapshotKey) + this.resumePersistenceError = undefined + } catch (error) { + if (!this.resumePersistenceError) { + console.warn( + '[TanStack AI] Failed to remove persisted generation resume snapshot', + error, + ) + } + this.resumePersistenceError = + error instanceof Error ? error : new Error(String(error)) + } + } +} + +/** + * Stable serialization of the snapshot minus `lastEvent`, used to detect + * material changes between persistence writes. + */ +function videoResumeSnapshotSignature( + snapshot: GenerationResumeSnapshot, +): string { + const { lastEvent: _lastEvent, ...significant } = snapshot + return JSON.stringify(significant) } diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index 98122c4ec..226b66404 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -84,8 +84,8 @@ export interface UseGenerateAudioReturn { * React hook for generating audio (music, sound effects) using AI models. * * Supports two transport modes: - * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) - * - **Fetcher** — Direct async function call + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call * * @example * ```tsx diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index 452b1a838..b2c56a3ff 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -84,8 +84,8 @@ export interface UseGenerateImageReturn { * React hook for generating images using AI models. * * Supports two transport modes: - * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) - * - **Fetcher** — Direct async function call + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call * * @example * ```tsx diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index a84af20a0..a71937d07 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -232,7 +232,7 @@ export function useGenerateVideo( }, [client, options.body]) // Mount devtools and clean up on unmount. Generation runs are never - // auto-started on mount — persisted state is read-only for display. + // auto-started on mount — persisted state is read-only for display. useEffect(() => { client.mountDevtools() diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index ea54e21ff..29ba6275a 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -109,7 +109,7 @@ export interface UseGenerationReturn { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function useGeneration< TInput extends Record, @@ -205,7 +205,7 @@ export function useGeneration< }, [client, options.body]) // Mount devtools and clean up on unmount. Generation runs are never - // auto-started on mount — persisted state is read-only for display. + // auto-started on mount — persisted state is read-only for display. useEffect(() => { client.mountDevtools() diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index d99323e5d..b00589302 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -253,7 +253,7 @@ export function useGenerateVideo( }) }) - // Mount devtools only. Generation runs are never auto-started on mount — + // Mount devtools only. Generation runs are never auto-started on mount — // persisted state is read-only for display. onMount(() => { client().mountDevtools() diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index 5098b91ec..3e9c81841 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -118,7 +118,7 @@ export interface UseGenerationReturn { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function useGeneration< TInput extends Record, @@ -220,7 +220,7 @@ export function useGeneration< }) }) - // Mount devtools only. Generation runs are never auto-started on mount — + // Mount devtools only. Generation runs are never auto-started on mount — // persisted state is read-only for display. onMount(() => { client().mountDevtools() diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index 1dd954c75..2615cf733 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -248,7 +248,7 @@ export function createGenerateVideo( ) } - // Mount devtools only. Generation runs are never auto-started on setup — + // Mount devtools only. Generation runs are never auto-started on setup — // persisted state is read-only for display. client.mountDevtools() diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index 24e36e7c4..8696c72e1 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -124,7 +124,7 @@ export interface CreateGenerationReturn { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function createGeneration< TInput extends Record, @@ -161,7 +161,7 @@ export function createGeneration< // `body` uses a conditional spread because `GenerationClientOptions.body` // is declared `body?: Record` (absent vs. present) under // `exactOptionalPropertyTypes`. Assigning `undefined` directly would be - // rejected — the optional caller `options.body` may be undefined, in which + // rejected — the optional caller `options.body` may be undefined, in which // case we want the key to be absent. const clientOptions: GenerationClientOptions = { id: clientId, @@ -230,7 +230,7 @@ export function createGeneration< ) } - // Mount devtools only. Generation runs are never auto-started on setup — + // Mount devtools only. Generation runs are never auto-started on setup — // persisted state is read-only for display. client.mountDevtools() diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 06c9f3970..139c4a44d 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -275,7 +275,7 @@ export function useGenerateVideo( }, ) - // Mount devtools only. Generation runs are never auto-started on mount — + // Mount devtools only. Generation runs are never auto-started on mount — // persisted state is read-only for display. onMounted(() => { client.mountDevtools() diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index f49d51957..771207659 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -120,7 +120,7 @@ export interface UseGenerationReturn { // inference site that works even for an optional nested property), which types // the callback parameter as `TResult` and narrows `result`. Inferring the // whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See +// default, leaving the parameter `any` — a hard error under `strict`. See // issue #848. export function useGeneration< TInput extends Record, @@ -245,7 +245,7 @@ export function useGeneration< }, ) - // Mount devtools only. Generation runs are never auto-started on mount — + // Mount devtools only. Generation runs are never auto-started on mount — // persisted state is read-only for display. onMounted(() => { client.mountDevtools() From 81aa21547ad01a2de2413649e876632f2cfc1191 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:49:39 +1000 Subject: [PATCH 10/66] fix(persistence): docs, example, changeset, React hooks, and real test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rewrite docs/persistence/generation-persistence.md around the implemented behavior: hydration on mount, generation: 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 --- .changeset/generation-persistence.md | 9 +- docs/config.json | 2 +- docs/persistence/generation-persistence.md | 78 +++-- .../src/routes/generations.image.tsx | 25 +- .../ai-client/tests/generation-client.test.ts | 318 ++++++++++++++++++ .../tests/generation-resume-state.test.ts | 191 +++++++++-- packages/ai-event-client/src/index.ts | 36 -- packages/ai-react/src/index.ts | 6 + packages/ai-react/src/use-generate-audio.ts | 10 +- packages/ai-react/src/use-generate-image.ts | 10 +- packages/ai-react/src/use-generate-speech.ts | 10 +- packages/ai-react/src/use-generate-video.ts | 63 ++-- packages/ai-react/src/use-generation.ts | 51 ++- packages/ai-react/src/use-summarize.ts | 10 +- packages/ai-react/src/use-transcription.ts | 10 +- .../ai-react/tests/use-generation.test.ts | 83 ++++- 16 files changed, 749 insertions(+), 163 deletions(-) diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index 93299f845..ae8d6f29a 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -1,6 +1,5 @@ --- '@tanstack/ai-client': minor -'@tanstack/ai-event-client': minor '@tanstack/ai-react': minor '@tanstack/ai-solid': minor '@tanstack/ai-vue': minor @@ -8,8 +7,10 @@ '@tanstack/ai-angular': minor --- -Add client-side generation persistence: a lightweight, read-only resume snapshot for media generation activities. +Add client-side generation persistence: a lightweight resume snapshot for media generation activities. -Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence` storage adapter and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus observed `pendingArtifacts` / `resultArtifacts`). As a run streams, the client builds a `GenerationResumeSnapshot` — run identity, status, errors, and result metadata, but **never** the generated media bytes — and writes it to the adapter. The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too. On reload the snapshot is surfaced for observability; it exposes no `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called. +Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence` storage adapter and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus `pendingArtifacts` / `resultArtifacts`, which stay empty until the server-side artifact pipeline ships in a follow-up). -This pairs with the existing `withGenerationPersistence` server middleware, which records run status in the shared `RunStore`. +As a run streams, the client builds a `GenerationResumeSnapshot` — run identity, status, errors, and result metadata, but **never** the generated media bytes — and writes it to the adapter under the key `generation:`, skipping writes with no material change. On construction the client reads the snapshot back (validated with the new `parseGenerationResumeSnapshot`) unless an explicit `initialResumeSnapshot` seed is provided, so the last run's outcome survives a reload. `stop()` and transport-level failures record terminal snapshots, and `reset()` clears the persisted record. The snapshot never exposes a `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called. + +The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too. This pairs with the existing `withGenerationPersistence` server middleware, which records run status in the shared `RunStore`. diff --git a/docs/config.json b/docs/config.json index 3fb4f1189..b8cee337f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -259,7 +259,7 @@ { "label": "Generation Persistence", "to": "persistence/generation-persistence", - "addedAt": "2026-07-23" + "addedAt": "2026-07-28" }, { "label": "Controls", diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index f0ddc803e..d3199a376 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -12,8 +12,9 @@ things back up. It helps with two things: -- **After a reload**, show what the last run was: its id, whether it finished, - and any error. This is a small read-only snapshot kept in the browser. +- **After a reload**, show what the last run was: whether it finished, what + failed, and metadata like the result id or video job id. This is a small + snapshot kept in browser storage and read back automatically on mount. - **While a run is still streaming**, let a dropped connection re-attach to it instead of starting over. This reuses the same resumable streams the chat client uses. @@ -25,7 +26,7 @@ matters: video, batch images, long audio, transcription of a big file. For a quick one-shot image you show and forget, you can skip it. It never stores the generated bytes. Only the run's identity, status, errors, -and result metadata (such as the media URL) are saved. See +and result metadata (like the result id, model, or video job id) are saved. See [What it does not store](#what-it-does-not-store). ## Create the server endpoint @@ -51,23 +52,20 @@ const persistence = sqlitePersistence({ export async function POST(request: Request) { const durability = memoryStream(request) - const { input, threadId, runId } = - await generationParamsFromRequest('image', request) + const { input } = await generationParamsFromRequest('image', request) if (typeof input.prompt !== 'string') { throw new Error('This endpoint accepts text image prompts only.') } const stream = generateImage({ - ...(threadId ? { threadId } : {}), - ...(runId ? { runId } : {}), adapter: openaiImage('gpt-image-2'), prompt: input.prompt, stream: true, middleware: [withGenerationPersistence(persistence)], }) - // withGenerationPersistence records the run's status and result. + // withGenerationPersistence records the run's status and usage. // durability records the stream so a reload can re-attach to it. return toServerSentEventsResponse(stream, { durability: { adapter: durability }, @@ -84,22 +82,21 @@ Use the matching request kind for audio, TTS, video, or transcription. `./sqlite-persistence` is the hand-rolled adapter from [Build your own adapter](./build-your-own-adapter) — any adapter with a `runs` store works. `withGenerationPersistence` requires a `runs` store and records -each run in it. In production, swap `memoryStream` for `durableStream` from +each run in it, keyed by the request id it generates for the run. In +production, swap `memoryStream` for `durableStream` from `@tanstack/ai-durable-stream`, where requests span processes. -Keep run ids unique across chat and generation when they share a backend, -because `RunStore` is keyed by `runId`. - ## Show the last run after a reload -Pass a storage adapter as `persistence`. The client writes a snapshot as the run -streams and reads it back on load: +Pass a storage adapter as `persistence`, and give the hook a stable `id`. The +client writes a snapshot as the run streams and reads it back (validated) when +the component mounts: ```tsx import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -const snapshots = localStoragePersistence({ keyPrefix: 'my-app:generation:' }) +const snapshots = localStoragePersistence({ keyPrefix: 'my-app:' }) export function HeroImageGenerator() { const image = useGenerateImage({ @@ -119,15 +116,43 @@ export function HeroImageGenerator() { Generate - {image.resumeState ?

Last run: {image.resumeState.runId}

: null} + {image.resumeState ? ( +

Run {image.resumeState.runId} is streaming…

+ ) : null} + {image.resumeSnapshot?.status === 'complete' ? ( +

Last run finished{image.resumeSnapshot.result?.id ? ` (${image.resumeSnapshot.result.id})` : ''}.

+ ) : null} + {image.resumeSnapshot?.error ? ( +

Last run failed: {image.resumeSnapshot.error.message}

+ ) : null} ) } ``` -`image.resumeState` holds the last run's id once a run has streamed. The -snapshot is read-only, so it never re-runs the provider. A generation starts -only when you call `generate(...)`. +A few things to know: + +- **`resumeSnapshot`** is the whole record: `status` (`idle` / `running` / + `complete` / `error`), an `error` if the run failed, and result metadata. + After a reload it holds the last run's outcome. +- **`resumeState`** is non-null only while a run is in flight — it is the + identity (`threadId` / `runId`) of the streaming run, and it is cleared when + the run ends. Use `resumeSnapshot`, not `resumeState`, to display a finished + run. +- **The `id` is the storage key.** The snapshot is written under + `generation:` (plus the adapter's `keyPrefix`), so a stable `id` is what + makes the record findable after a reload. Omitting `id` generates a fresh + key per mount. The `generation:` segment also keeps a chat client with the + same id from colliding with the generation record. +- `stop()` marks the persisted record no longer resumable, and `reset()` + deletes it. +- The snapshot never triggers work. A generation starts only when you call + `generate(...)`. + +If your app manages storage itself (custom backends, SSR-provided state), read +the value yourself and pass it as `initialResumeSnapshot` — that skips the +automatic read. Validate untrusted values with `parseGenerationResumeSnapshot` +from `@tanstack/ai-client`. ## Reconnect to a run that is still streaming @@ -137,14 +162,17 @@ log. On the client there is nothing to add. A connection dropped mid-generation re-attaches on its own through `fetchServerSentEvents` or `fetchHttpStream`, the same adapters `useChat` uses. -A full page reload is different. The hooks do not start a run on mount, so they -will not reconnect on their own. What survives the reload is the snapshot, which -holds the `runId`, so you can trigger a reconnect from it yourself. See +A full page reload is different: the hooks never start or resume a run on +mount, and the snapshot alone cannot re-attach to the stream — it records that +a run was in flight, not a stream position. Treat a `running` snapshot after a +reload as informational ("a run was still going when the page closed"). See [Resumable Streams](../resumable-streams/overview) for the durability contract, production adapters, and the one-time-side-effects note. ## What it does not store -The snapshot points at the media URL the provider returned, not the bytes. -Provider URLs usually expire, so a snapshot opened much later can point at media -that is gone. If you need the media to last, save the bytes to your own storage. +The snapshot stores run identity and result metadata — ids, model, status, a +video `jobId`, an expiry timestamp — never the generated bytes, and it does not +carry the provider's media URL. If you need the media itself to survive a +reload, save it to your own storage from `onResult`; durable artifact storage +is coming as a follow-up. diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index fa67ef1bd..ad5d8c582 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -9,11 +9,13 @@ import { import { resolveMediaPrompt } from '@tanstack/ai' import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' -// Reuse the shared web-storage adapter for the lightweight, read-only -// generation resume snapshot. Only run identity, status, errors, and result -// metadata are stored — never the generated image bytes. +// Reuse the shared web-storage adapter for the lightweight generation resume +// snapshot. Only run identity, status, errors, and result metadata are stored +// — never the generated image bytes. The client namespaces its record under +// `generation:`, and reads it back on mount so the last run's outcome +// survives a full page reload. const imageSnapshots = localStoragePersistence({ - keyPrefix: 'example:generation:', + keyPrefix: 'example:', }) function StreamingImageGeneration() { @@ -89,17 +91,24 @@ function PersistedImageGeneration() { persistence: imageSnapshots, }) + const snapshot = hookReturn.resumeSnapshot return (
-

- Resume status: {hookReturn.status} -

{hookReturn.resumeState ? (

- Last run:{' '} + Run in flight:{' '} {hookReturn.resumeState.runId}

+ ) : snapshot ? ( +

+ Last run:{' '} + + {snapshot.status} + {snapshot.error ? ` — ${snapshot.error.message}` : ''} + {snapshot.result?.model ? ` (${snapshot.result.model})` : ''} + +

) : (

No persisted run yet.

)} diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index 99f1fcd41..7ecd8ecc0 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -1451,4 +1451,322 @@ describe('GenerationClient', () => { }) }) }) + + describe('resume snapshot lifecycle', () => { + function createMapPersistence(seed?: Record): { + store: Map + persistence: GenerationPersistence + } { + const store = new Map(Object.entries(seed ?? {})) + const persistence = { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: unknown) => { + store.set(key, value) + }), + removeItem: vi.fn((key: string) => { + store.delete(key) + }), + // The Map-backed fake is looser than GenerationPersistence's value + // type on purpose: hydration must survive arbitrary stored shapes. + } as unknown as GenerationPersistence + return { store, persistence } + } + + const storedSnapshot: GenerationResumeSnapshot = { + schemaVersion: 1, + resumeState: null, + status: 'complete', + result: { id: 'result-1', model: 'image-model' }, + } + + it('hydrates a stored snapshot under generation: on construction', async () => { + const { persistence } = createMapPersistence({ + 'generation:hero': storedSnapshot, + }) + const onResumeSnapshotChange = vi.fn() + const client = new GenerationClient({ + id: 'hero', + connection: createMockConnection([]), + persistence, + onResumeSnapshotChange, + }) + + await waitForCondition(() => { + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'complete', + result: { id: 'result-1' }, + }) + }) + expect(persistence.getItem).toHaveBeenCalledWith('generation:hero') + expect(onResumeSnapshotChange).toHaveBeenCalledWith( + expect.objectContaining({ status: 'complete' }), + ) + }) + + it('skips hydration when an explicit initialResumeSnapshot seed is provided', async () => { + const { persistence } = createMapPersistence({ + 'generation:hero': storedSnapshot, + }) + const seed: GenerationResumeSnapshot = { + resumeState: null, + status: 'error', + error: { message: 'seeded' }, + } + const client = new GenerationClient({ + id: 'hero', + connection: createMockConnection([]), + persistence, + initialResumeSnapshot: seed, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(persistence.getItem).not.toHaveBeenCalled() + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'error', + error: { message: 'seeded' }, + }) + }) + + it('ignores invalid stored values and read failures without throwing', async () => { + const warningSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { persistence } = createMapPersistence({ + 'generation:hero': { status: 'not-a-status' }, + }) + const client = new GenerationClient({ + id: 'hero', + connection: createMockConnection([]), + persistence, + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(client.getResumeSnapshot()).toBeUndefined() + + const throwingPersistence = { + getItem: vi.fn(() => { + throw new Error('corrupt JSON') + }), + setItem: vi.fn(), + removeItem: vi.fn(), + } as unknown as GenerationPersistence + const client2 = new GenerationClient({ + id: 'hero', + connection: createMockConnection([]), + persistence: throwingPersistence, + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(client2.getResumeSnapshot()).toBeUndefined() + warningSpy.mockRestore() + }) + + it('marks the snapshot no longer resumable when stop() aborts a run', async () => { + const gate = createDeferred() + const connection: ConnectConnectionAdapter = { + async *connect() { + yield { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk + await gate.promise + }, + } + const { store, persistence } = createMapPersistence() + const client = new GenerationClient({ + id: 'hero', + connection, + persistence, + }) + + const generatePromise = client.generate({ prompt: 'test' }) + await waitForCondition(() => { + expect(client.getResumeSnapshot()?.status).toBe('running') + }) + client.stop() + gate.resolve(undefined) + await generatePromise + + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'idle', + resumeState: null, + }) + await waitForCondition(() => { + expect(store.get('generation:hero')).toMatchObject({ + status: 'idle', + resumeState: null, + }) + }) + }) + + it('records a transport-level failure as an error snapshot', async () => { + const connection: ConnectConnectionAdapter = { + // eslint-disable-next-line require-yield -- the stream fails before producing any chunk + async *connect() { + throw new Error('network dropped') + }, + } + const { store, persistence } = createMapPersistence() + const client = new GenerationClient({ + id: 'hero', + connection, + persistence, + }) + + await client.generate({ prompt: 'test' }) + + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'error', + resumeState: null, + error: { message: 'network dropped' }, + }) + await waitForCondition(() => { + expect(store.get('generation:hero')).toMatchObject({ + status: 'error', + error: { message: 'network dropped' }, + }) + }) + }) + + it('records a complete snapshot for plain fetcher runs with no seed', async () => { + const { store, persistence } = createMapPersistence() + const client = new GenerationClient({ + id: 'hero', + fetcher: async () => ({ id: 'result-9', model: 'image-model' }), + persistence, + }) + + await client.generate({ prompt: 'test' }) + + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'complete', + resumeState: null, + result: { id: 'result-9', model: 'image-model' }, + }) + await waitForCondition(() => { + expect(store.get('generation:hero')).toMatchObject({ + status: 'complete', + }) + }) + }) + + it('reset() clears the snapshot and removes the persisted record', async () => { + const { store, persistence } = createMapPersistence() + const onResumeSnapshotChange = vi.fn() + const client = new GenerationClient({ + id: 'hero', + fetcher: async () => ({ id: 'result-9' }), + persistence, + onResumeSnapshotChange, + }) + + await client.generate({ prompt: 'test' }) + await waitForCondition(() => { + expect(store.has('generation:hero')).toBe(true) + }) + + client.reset() + + expect(client.getResumeSnapshot()).toBeUndefined() + expect(onResumeSnapshotChange).toHaveBeenLastCalledWith(undefined) + await waitForCondition(() => { + expect(persistence.removeItem).toHaveBeenCalledWith('generation:hero') + expect(store.has('generation:hero')).toBe(false) + }) + }) + + it('skips writes whose snapshot only differs in lastEvent', async () => { + const { persistence } = createMapPersistence() + const client = new GenerationClient({ + id: 'hero', + connection: createMockConnection([ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:progress', + value: { progress: 10 }, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:progress', + value: { progress: 20 }, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + finishReason: 'stop', + timestamp: Date.now(), + }, + ]), + persistence, + }) + + await client.generate({ prompt: 'test' }) + await waitForCondition(() => { + // One write for the running state, one for the terminal state — the + // two progress chunks change nothing material. + expect(persistence.setItem).toHaveBeenCalledTimes(2) + }) + }) + + it('revives after dispose when devtools remount (StrictMode replay)', async () => { + const connectSpy = vi.fn(async function* () { + yield { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + finishReason: 'stop', + timestamp: Date.now(), + } satisfies StreamChunk + }) + const client = new GenerationClient({ + connection: { connect: connectSpy }, + }) + + // StrictMode: mount → cleanup → mount against the same memoized client. + client.mountDevtools() + client.dispose() + client.mountDevtools() + + await client.generate({ prompt: 'test' }) + expect(connectSpy).toHaveBeenCalledTimes(1) + expect(client.getStatus()).toBe('success') + }) + + it('ignores generate() on a client that is disposed and not remounted', async () => { + const connectSpy = vi.fn(async function* () { + yield { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk + }) + const client = new GenerationClient({ + connection: { connect: connectSpy }, + }) + + client.mountDevtools() + client.dispose() + + await client.generate({ prompt: 'test' }) + expect(connectSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/ai-client/tests/generation-resume-state.test.ts b/packages/ai-client/tests/generation-resume-state.test.ts index 74f8fad78..fb3593ae9 100644 --- a/packages/ai-client/tests/generation-resume-state.test.ts +++ b/packages/ai-client/tests/generation-resume-state.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { EventType } from '@tanstack/ai/client' import { GENERATION_EVENTS, + parseGenerationResumeSnapshot, updateGenerationResumeSnapshot, } from '../src/generation-types' import type { PersistedArtifactRef, StreamChunk } from '@tanstack/ai/client' @@ -177,39 +178,39 @@ describe('generation resume state reducer', () => { expect(JSON.stringify(snapshot)).not.toContain('b64Json') }) - it('omits non-durable and oversized result URLs from resume snapshots', () => { - const oversizedUrl = `https://example.com/${'x'.repeat(4096)}` + it('keeps a durable artifact externalUrl and strips non-durable or oversized ones', () => { + const durableUrl = 'https://cdn.example.com/artifacts/image.png' + const durable = reduceChunks([ + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.ARTIFACTS, + value: [{ ...artifactRef, externalUrl: durableUrl }], + threadId: 'thread-1', + runId: 'run-1', + timestamp: 1, + }, + ]) + expect(durable.pendingArtifacts?.[0]?.externalUrl).toBe(durableUrl) + const unsafeUrls = [ 'data:image/png;base64,raw-image-bytes', 'blob:https://example.com/raw-image-bytes', - oversizedUrl, + `https://example.com/${'x'.repeat(4096)}`, + 'not a url', ] - - for (const url of unsafeUrls) { + for (const externalUrl of unsafeUrls) { const snapshot = reduceChunks([ { type: EventType.CUSTOM, - name: GENERATION_EVENTS.RESULT, - value: { - id: 'result-1', - model: 'image-model', - url, - artifacts: [artifactRef], - }, + name: GENERATION_EVENTS.ARTIFACTS, + value: [{ ...artifactRef, externalUrl }], threadId: 'thread-1', runId: 'run-1', - timestamp: 2, + timestamp: 1, }, ]) - - expect(snapshot.result).toMatchObject({ - id: 'result-1', - model: 'image-model', - artifacts: [artifactRef], - }) - expect(snapshot.result).not.toHaveProperty('url') - expect(JSON.stringify(snapshot)).not.toContain('raw-image-bytes') - expect(JSON.stringify(snapshot)).not.toContain(oversizedUrl) + expect(snapshot.pendingArtifacts).toEqual([artifactRef]) + expect(snapshot.pendingArtifacts?.[0]).not.toHaveProperty('externalUrl') } }) @@ -242,7 +243,7 @@ describe('generation resume state reducer', () => { }) }) - it('does not model explicit stop as durable cancel state', () => { + it('captures the video job id from video:job:created before any result arrives', () => { const snapshot = reduceChunks([ { type: EventType.RUN_STARTED, @@ -250,10 +251,150 @@ describe('generation resume state reducer', () => { runId: 'run-1', timestamp: 1, }, + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.VIDEO_JOB_CREATED, + value: { jobId: 'job-42' }, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 2, + }, ]) expect(snapshot.status).toBe('running') - expect(snapshot).not.toHaveProperty('cancelled') - expect(snapshot).not.toHaveProperty('cancelEndpoint') + expect(snapshot.result?.jobId).toBe('job-42') + }) + + it('merges an initial seed and lets later run events update it', () => { + const seed: GenerationResumeSnapshot = { + resumeState: null, + status: 'error', + error: { message: 'previous failure' }, + pendingArtifacts: [artifactRef], + } + + const midRun = reduceChunks( + [ + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.PROGRESS, + value: { progress: 10 }, + threadId: 'thread-1', + runId: 'run-2', + timestamp: 1, + }, + ], + seed, + ) + // Without a RUN_STARTED boundary the seed's fields are carried forward. + expect(midRun.resumeState).toEqual({ threadId: 'thread-1', runId: 'run-2' }) + expect(midRun.error).toEqual({ message: 'previous failure' }) + expect(midRun.pendingArtifacts).toEqual([artifactRef]) + }) + + it('drops stale error, result, and artifacts when a new run starts', () => { + const seed: GenerationResumeSnapshot = { + resumeState: null, + status: 'error', + error: { message: 'previous failure' }, + result: { id: 'old-result' }, + pendingArtifacts: [artifactRef], + } + + const snapshot = reduceChunks( + [ + { + type: EventType.RUN_STARTED, + threadId: 'thread-1', + runId: 'run-2', + timestamp: 1, + }, + ], + seed, + ) + + expect(snapshot.status).toBe('running') + expect(snapshot.resumeState).toEqual({ + threadId: 'thread-1', + runId: 'run-2', + }) + expect(snapshot.error).toBeUndefined() + expect(snapshot.result).toBeUndefined() + expect(snapshot.pendingArtifacts).toBeUndefined() + }) +}) + +describe('parseGenerationResumeSnapshot', () => { + it('round-trips a reducer-produced snapshot through JSON, dropping lastEvent', () => { + const produced = reduceChunks([ + { + type: EventType.RUN_STARTED, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 1, + }, + { + type: EventType.CUSTOM, + name: GENERATION_EVENTS.RESULT, + value: { id: 'result-1', model: 'image-model', artifacts: [artifactRef] }, + threadId: 'thread-1', + runId: 'run-1', + timestamp: 2, + }, + { + type: EventType.RUN_FINISHED, + threadId: 'thread-1', + runId: 'run-1', + finishReason: 'stop', + timestamp: 3, + }, + ]) + + const parsed = parseGenerationResumeSnapshot( + JSON.parse(JSON.stringify(produced)), + ) + expect(parsed).toBeDefined() + expect(parsed?.status).toBe('complete') + expect(parsed?.resumeState).toBeNull() + expect(parsed?.result).toEqual(produced.result) + expect(parsed?.lastEvent).toBeUndefined() + }) + + it('rejects garbage, invalid statuses, malformed resume state, and future schema versions', () => { + expect(parseGenerationResumeSnapshot(undefined)).toBeUndefined() + expect(parseGenerationResumeSnapshot('running')).toBeUndefined() + expect(parseGenerationResumeSnapshot({})).toBeUndefined() + expect( + parseGenerationResumeSnapshot({ resumeState: null, status: 'paused' }), + ).toBeUndefined() + expect( + parseGenerationResumeSnapshot({ + resumeState: { threadId: 'thread-1' }, + status: 'running', + }), + ).toBeUndefined() + expect( + parseGenerationResumeSnapshot({ + schemaVersion: 2, + resumeState: null, + status: 'idle', + }), + ).toBeUndefined() + }) + + it('strips unknown fields and re-validates artifact refs from storage', () => { + const parsed = parseGenerationResumeSnapshot({ + resumeState: { threadId: 'thread-1', runId: 'run-1' }, + status: 'running', + pendingArtifacts: [artifactRef, { b64Json: 'raw-bytes' }], + error: { message: 'boom', code: 'E1', extra: 'dropped' }, + injected: 'dropped', + }) + + expect(parsed).toBeDefined() + expect(parsed?.pendingArtifacts).toEqual([artifactRef]) + expect(parsed?.error).toEqual({ message: 'boom', code: 'E1' }) + expect(JSON.stringify(parsed)).not.toContain('raw-bytes') + expect(JSON.stringify(parsed)).not.toContain('dropped') }) }) diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 7465168be..062faabdd 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -614,8 +614,6 @@ export interface SummarizeUsageEvent extends BaseEventContext { /** Emitted when an image request starts. */ export interface ImageRequestStartedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string prompt: string @@ -632,8 +630,6 @@ export interface ImageRequestStartedEvent extends BaseEventContext { /** Emitted when an image request completes. */ export interface ImageRequestCompletedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string images: Array<{ url?: string; b64Json?: string }> @@ -643,8 +639,6 @@ export interface ImageRequestCompletedEvent extends BaseEventContext { /** Emitted when image usage metrics are available. */ export interface ImageUsageEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string model: string usage: TokenUsage } @@ -656,8 +650,6 @@ export interface ImageUsageEvent extends BaseEventContext { /** Emitted when a speech request starts. */ export interface SpeechRequestStartedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string text: string @@ -669,8 +661,6 @@ export interface SpeechRequestStartedEvent extends BaseEventContext { /** Emitted when a speech request completes. */ export interface SpeechRequestCompletedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string audio: string @@ -683,8 +673,6 @@ export interface SpeechRequestCompletedEvent extends BaseEventContext { /** Emitted when speech usage metrics are available. */ export interface SpeechUsageEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string model: string usage: TokenUsage } @@ -696,8 +684,6 @@ export interface SpeechUsageEvent extends BaseEventContext { /** Emitted when a transcription request starts. */ export interface TranscriptionRequestStartedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string language?: string @@ -708,8 +694,6 @@ export interface TranscriptionRequestStartedEvent extends BaseEventContext { /** Emitted when a transcription request completes. */ export interface TranscriptionRequestCompletedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string text: string @@ -720,8 +704,6 @@ export interface TranscriptionRequestCompletedEvent extends BaseEventContext { /** Emitted when transcription usage metrics are available. */ export interface TranscriptionUsageEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string model: string usage: TokenUsage } @@ -733,8 +715,6 @@ export interface TranscriptionUsageEvent extends BaseEventContext { /** Emitted when an audio generation request starts. */ export interface AudioRequestStartedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string prompt: string @@ -763,8 +743,6 @@ export type AudioRequestCompletedAudio = /** Emitted when an audio generation request completes. */ export interface AudioRequestCompletedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string audio: AudioRequestCompletedAudio @@ -774,8 +752,6 @@ export interface AudioRequestCompletedEvent extends BaseEventContext { /** Emitted when an audio generation request fails. */ export interface AudioRequestErrorEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string error: { message: string; name?: string } @@ -785,8 +761,6 @@ export interface AudioRequestErrorEvent extends BaseEventContext { /** Emitted when a speech generation request fails. */ export interface SpeechRequestErrorEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string error: { message: string; name?: string } @@ -796,8 +770,6 @@ export interface SpeechRequestErrorEvent extends BaseEventContext { /** Emitted when a transcription request fails. */ export interface TranscriptionRequestErrorEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string error: { message: string; name?: string } @@ -807,8 +779,6 @@ export interface TranscriptionRequestErrorEvent extends BaseEventContext { /** Emitted when audio usage metrics are available. */ export interface AudioUsageEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string model: string usage: TokenUsage } @@ -820,8 +790,6 @@ export interface AudioUsageEvent extends BaseEventContext { /** Emitted when a video request starts. */ export interface VideoRequestStartedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string requestType: 'create' | 'status' | 'url' @@ -834,8 +802,6 @@ export interface VideoRequestStartedEvent extends BaseEventContext { /** Emitted when a video request completes. */ export interface VideoRequestCompletedEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string provider: string model: string requestType: 'create' | 'status' | 'url' @@ -850,8 +816,6 @@ export interface VideoRequestCompletedEvent extends BaseEventContext { /** Emitted when video usage metrics are available. */ export interface VideoUsageEvent extends BaseEventContext { requestId: string - threadId?: string - runId?: string model: string usage: TokenUsage } diff --git a/packages/ai-react/src/index.ts b/packages/ai-react/src/index.ts index 75bff7c3c..47db566a2 100644 --- a/packages/ai-react/src/index.ts +++ b/packages/ai-react/src/index.ts @@ -107,4 +107,10 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, + type GenerationPersistence, + type GenerationResumeSnapshot, + type GenerationResumeState, + type GenerationResumeStatus, + type GenerationPendingArtifact, } from '@tanstack/ai-client' +export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index 226b66404..5d826f073 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -30,9 +30,9 @@ export interface UseGenerateAudioOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app. */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when audio is generated. Can optionally return a transformed value. @@ -72,11 +72,11 @@ export interface UseGenerateAudioReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: GenerationResumeSnapshot | undefined - /** Current resumable run/cursor state, if one is available */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Array } diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index b2c56a3ff..cc54606bf 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -30,9 +30,9 @@ export interface UseGenerateImageOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app. */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when images are generated. Can optionally return a transformed value. @@ -72,11 +72,11 @@ export interface UseGenerateImageReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: GenerationResumeSnapshot | undefined - /** Current resumable run/cursor state, if one is available */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Array } diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index 793e5b3a8..47c3f27be 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -30,9 +30,9 @@ export interface UseGenerateSpeechOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app. */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when speech is generated. Can optionally return a transformed value. @@ -72,11 +72,11 @@ export interface UseGenerateSpeechReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: GenerationResumeSnapshot | undefined - /** Current resumable run/cursor state, if one is available */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Array } diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index a71937d07..63b320ab2 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -32,9 +32,9 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when video generation completes. Can optionally return a transformed value. @@ -82,11 +82,11 @@ export interface UseGenerateVideoReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: GenerationResumeSnapshot | undefined - /** Current resumable run/cursor state, if one is available */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Array } @@ -150,6 +150,7 @@ export function useGenerateVideo( const optionsRef = useRef(options) optionsRef.current = options + const disposedRef = useRef(false) const client = useMemo(() => { const opts = optionsRef.current @@ -181,27 +182,41 @@ export function useGenerateVideo( result: VideoGenerateResult, ) => TOutput | null | void, onError: (e: Error) => { - optionsRef.current.onError?.(e) + if (!disposedRef.current) optionsRef.current.onError?.(e) }, onProgress: (p: number, m?: string) => { - optionsRef.current.onProgress?.(p, m) + if (!disposedRef.current) optionsRef.current.onProgress?.(p, m) }, onChunk: (c: StreamChunk) => { - optionsRef.current.onChunk?.(c) + if (!disposedRef.current) optionsRef.current.onChunk?.(c) }, onJobCreated: (id: string) => { - optionsRef.current.onJobCreated?.(id) + if (!disposedRef.current) optionsRef.current.onJobCreated?.(id) }, onStatusUpdate: (s: VideoStatusInfo) => { - optionsRef.current.onStatusUpdate?.(s) + if (!disposedRef.current) optionsRef.current.onStatusUpdate?.(s) + }, + onResultChange: (r: TOutput | null) => { + if (!disposedRef.current) setResult(r) + }, + onLoadingChange: (l: boolean) => { + if (!disposedRef.current) setIsLoading(l) + }, + onErrorChange: (e: Error | undefined) => { + if (!disposedRef.current) setError(e) + }, + onStatusChange: (s: GenerationClientState) => { + if (!disposedRef.current) setStatus(s) + }, + onJobIdChange: (id: string | null) => { + if (!disposedRef.current) setJobId(id) + }, + onVideoStatusChange: (s: VideoStatusInfo | null) => { + if (!disposedRef.current) setVideoStatus(s) + }, + onResumeSnapshotChange: (snapshot: GenerationResumeSnapshot | undefined) => { + if (!disposedRef.current) setResumeSnapshot(snapshot) }, - onResultChange: setResult, - onLoadingChange: setIsLoading, - onErrorChange: setError, - onStatusChange: setStatus, - onJobIdChange: setJobId, - onVideoStatusChange: setVideoStatus, - onResumeSnapshotChange: setResumeSnapshot, } if (opts.connection) { @@ -232,11 +247,14 @@ export function useGenerateVideo( }, [client, options.body]) // Mount devtools and clean up on unmount. Generation runs are never - // auto-started on mount — persisted state is read-only for display. + // auto-started on mount — persisted state is only displayed. Mounting + // revives the client after a StrictMode dispose → remount replay. useEffect(() => { + disposedRef.current = false client.mountDevtools() return () => { + disposedRef.current = true client.dispose() } }, [client]) @@ -268,7 +286,12 @@ export function useGenerateVideo( reset, resumeSnapshot, resumeState: resumeSnapshot?.resumeState ?? null, - pendingArtifacts: resumeSnapshot?.pendingArtifacts ?? [], - resultArtifacts: resumeSnapshot?.result?.artifacts ?? [], + pendingArtifacts: resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, + resultArtifacts: resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, } } + +// Stable fallbacks so consumers can safely depend on these arrays in effect +// dependency lists when no snapshot exists. +const EMPTY_PENDING_ARTIFACTS: Array = [] +const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 29ba6275a..7adab922d 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -36,9 +36,9 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. @@ -78,11 +78,11 @@ export interface UseGenerationReturn { reset: () => void /** Lightweight generation state snapshot, if one is available */ resumeSnapshot: GenerationResumeSnapshot | undefined - /** Observed run/cursor metadata from the snapshot (read-only state) */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Array } @@ -134,6 +134,7 @@ export function useGeneration< const optionsRef = useRef(options) optionsRef.current = options + const disposedRef = useRef(false) const client = useMemo(() => { const opts = optionsRef.current @@ -162,19 +163,29 @@ export function useGeneration< result: TResult, ) => TOutput | null | void, onError: (e: Error) => { - optionsRef.current.onError?.(e) + if (!disposedRef.current) optionsRef.current.onError?.(e) }, onProgress: (p: number, m?: string) => { - optionsRef.current.onProgress?.(p, m) + if (!disposedRef.current) optionsRef.current.onProgress?.(p, m) }, onChunk: (c: StreamChunk) => { - optionsRef.current.onChunk?.(c) + if (!disposedRef.current) optionsRef.current.onChunk?.(c) + }, + onResultChange: (r) => { + if (!disposedRef.current) setResult(r) + }, + onLoadingChange: (l) => { + if (!disposedRef.current) setIsLoading(l) + }, + onErrorChange: (e) => { + if (!disposedRef.current) setError(e) + }, + onStatusChange: (s) => { + if (!disposedRef.current) setStatus(s) + }, + onResumeSnapshotChange: (snapshot) => { + if (!disposedRef.current) setResumeSnapshot(snapshot) }, - onResultChange: setResult, - onLoadingChange: setIsLoading, - onErrorChange: setError, - onStatusChange: setStatus, - onResumeSnapshotChange: setResumeSnapshot, } if (opts.connection) { @@ -205,11 +216,14 @@ export function useGeneration< }, [client, options.body]) // Mount devtools and clean up on unmount. Generation runs are never - // auto-started on mount — persisted state is read-only for display. + // auto-started on mount — persisted state is only displayed. Mounting + // revives the client after a StrictMode dispose → remount replay. useEffect(() => { + disposedRef.current = false client.mountDevtools() return () => { + disposedRef.current = true client.dispose() } }, [client]) @@ -239,7 +253,12 @@ export function useGeneration< reset, resumeSnapshot, resumeState: resumeSnapshot?.resumeState ?? null, - pendingArtifacts: resumeSnapshot?.pendingArtifacts ?? [], - resultArtifacts: resumeSnapshot?.result?.artifacts ?? [], + pendingArtifacts: resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, + resultArtifacts: resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, } } + +// Stable fallbacks so consumers can safely depend on these arrays in effect +// dependency lists when no snapshot exists. +const EMPTY_PENDING_ARTIFACTS: Array = [] +const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index 0deac64bf..a73688a82 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -30,9 +30,9 @@ export interface UseSummarizeOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app. */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when summarization is complete. Can optionally return a transformed value. @@ -72,11 +72,11 @@ export interface UseSummarizeReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: GenerationResumeSnapshot | undefined - /** Current resumable run/cursor state, if one is available */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Array } diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index ed22ad3a5..0ba2732f0 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -30,9 +30,9 @@ export interface UseTranscriptionOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app. */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when transcription is complete. Can optionally return a transformed value. @@ -72,11 +72,11 @@ export interface UseTranscriptionReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: GenerationResumeSnapshot | undefined - /** Current resumable run/cursor state, if one is available */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Array } diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index 67a8af348..39584b36c 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -1,3 +1,4 @@ +import { StrictMode } from 'react' import { renderHook, waitFor, act } from '@testing-library/react' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { useGeneration } from '../src/use-generation' @@ -403,18 +404,94 @@ describe('useGeneration', () => { }) expect(connect).not.toHaveBeenCalled() - // Persisted state is read-only for display; the client never reads it - // back to drive a resume, so getItem is not consulted on mount. + // The explicit initialResumeSnapshot seed takes precedence over storage, + // so automatic hydration (getItem) is skipped entirely. expect(persistence.getItem).not.toHaveBeenCalled() expect(result.current.isLoading).toBe(false) expect(result.current.status).toBe('idle') - // The persisted snapshot is still exposed as read-only state. + // The persisted snapshot is still exposed as display state. expect(result.current.resumeState).toEqual({ threadId: 'thread-resume', runId: 'run-resume', }) }) }) + + describe('persistence', () => { + it('hydrates the resume snapshot from storage on mount without starting a run', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const persistence: GenerationPersistence = { + getItem: vi.fn(() => ({ + resumeState: null, + status: 'complete' as const, + result: { id: 'result-1', model: 'image-model' }, + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderHook(() => + useGeneration({ + id: 'hydrated', + connection: adapter, + persistence: persistence, + }), + ) + + await waitFor(() => { + expect(result.current.resumeSnapshot).toMatchObject({ + status: 'complete', + result: { id: 'result-1' }, + }) + }) + expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrated') + expect(connect).not.toHaveBeenCalled() + expect(result.current.status).toBe('idle') + }) + + it('generates normally under React StrictMode (dispose → remount replay)', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: 'strict-1' }), + ) + + const { result } = renderHook( + () => useGeneration({ connection: adapter }), + { wrapper: StrictMode }, + ) + + await act(async () => { + await result.current.generate({ prompt: 'test' }) + }) + + expect(connect).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(result.current.status).toBe('success') + expect(result.current.result).toEqual({ id: 'strict-1' }) + }) + }) + + it('exposes sanitized artifact refs observed from a streamed result', async () => { + const adapter = createMockConnectionAdapter({ + chunks: createReplayVideoChunks(), + }) + + const { result } = renderHook(() => + useGeneration({ connection: adapter }), + ) + + await act(async () => { + await result.current.generate({ prompt: 'replay' }) + }) + + await waitFor(() => { + expect(result.current.resultArtifacts).toEqual([replayedVideoArtifact]) + expect(result.current.pendingArtifacts).toEqual([replayedVideoArtifact]) + expect(result.current.resumeSnapshot?.status).toBe('complete') + }) + }) + }) }) describe('useGenerateImage', () => { From eae47bb9b7ea3278013f4ad59cad02a64bef1985 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:59:29 +1000 Subject: [PATCH 11/66] 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: with no media bytes, hydrated after reload with no auto-run, and removed by reset(). --- testing/e2e/src/routeTree.gen.ts | 43 ++++++++++ .../src/routes/api.generation-persistence.ts | 67 ++++++++++++++++ .../e2e/src/routes/generation-persistence.tsx | 78 +++++++++++++++++++ .../e2e/tests/generation-persistence.spec.ts | 64 +++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 testing/e2e/src/routes/api.generation-persistence.ts create mode 100644 testing/e2e/src/routes/generation-persistence.tsx create mode 100644 testing/e2e/tests/generation-persistence.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 4abac986d..a883ceba1 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as PersistenceDurabilityRouteImport } from './routes/persistence- import { Route as MiddlewareTestRouteImport } from './routes/middleware-test' import { Route as MarkdownCjkRouteImport } from './routes/markdown-cjk' import { Route as InterruptsTestRouteImport } from './routes/interrupts-test' +import { Route as GenerationPersistenceRouteImport } from './routes/generation-persistence' import { Route as ForeignInterruptRouteImport } from './routes/foreign-interrupt' import { Route as DevtoolsToolsRouteImport } from './routes/devtools-tools' import { Route as DevtoolsStructuredRouteImport } from './routes/devtools-structured' @@ -52,6 +53,7 @@ import { Route as ApiMaxToolCallsWireRouteImport } from './routes/api.max-tool-c import { Route as ApiLazyToolsWireRouteImport } from './routes/api.lazy-tools-wire' import { Route as ApiInterruptsTestRouteImport } from './routes/api.interrupts-test' import { Route as ApiImageRouteImport } from './routes/api.image' +import { Route as ApiGenerationPersistenceRouteImport } from './routes/api.generation-persistence' import { Route as ApiForeignInterruptRouteImport } from './routes/api.foreign-interrupt' import { Route as ApiDurableDeliveryRouteImport } from './routes/api.durable-delivery' import { Route as ApiDevtoolsMemoryRouteImport } from './routes/api.devtools-memory' @@ -93,6 +95,11 @@ const InterruptsTestRoute = InterruptsTestRouteImport.update({ path: '/interrupts-test', getParentRoute: () => rootRouteImport, } as any) +const GenerationPersistenceRoute = GenerationPersistenceRouteImport.update({ + id: '/generation-persistence', + path: '/generation-persistence', + getParentRoute: () => rootRouteImport, +} as any) const ForeignInterruptRoute = ForeignInterruptRouteImport.update({ id: '/foreign-interrupt', path: '/foreign-interrupt', @@ -288,6 +295,12 @@ const ApiImageRoute = ApiImageRouteImport.update({ path: '/api/image', getParentRoute: () => rootRouteImport, } as any) +const ApiGenerationPersistenceRoute = + ApiGenerationPersistenceRouteImport.update({ + id: '/api/generation-persistence', + path: '/api/generation-persistence', + getParentRoute: () => rootRouteImport, + } as any) const ApiForeignInterruptRoute = ApiForeignInterruptRouteImport.update({ id: '/api/foreign-interrupt', path: '/api/foreign-interrupt', @@ -376,6 +389,7 @@ export interface FileRoutesByFullPath { '/devtools-structured': typeof DevtoolsStructuredRoute '/devtools-tools': typeof DevtoolsToolsRoute '/foreign-interrupt': typeof ForeignInterruptRoute + '/generation-persistence': typeof GenerationPersistenceRoute '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute @@ -391,6 +405,7 @@ export interface FileRoutesByFullPath { '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/generation-persistence': typeof ApiGenerationPersistenceRoute '/api/image': typeof ApiImageRouteWithChildren '/api/interrupts-test': typeof ApiInterruptsTestRoute '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -436,6 +451,7 @@ export interface FileRoutesByTo { '/devtools-structured': typeof DevtoolsStructuredRoute '/devtools-tools': typeof DevtoolsToolsRoute '/foreign-interrupt': typeof ForeignInterruptRoute + '/generation-persistence': typeof GenerationPersistenceRoute '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute @@ -451,6 +467,7 @@ export interface FileRoutesByTo { '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/generation-persistence': typeof ApiGenerationPersistenceRoute '/api/image': typeof ApiImageRouteWithChildren '/api/interrupts-test': typeof ApiInterruptsTestRoute '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -497,6 +514,7 @@ export interface FileRoutesById { '/devtools-structured': typeof DevtoolsStructuredRoute '/devtools-tools': typeof DevtoolsToolsRoute '/foreign-interrupt': typeof ForeignInterruptRoute + '/generation-persistence': typeof GenerationPersistenceRoute '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute @@ -512,6 +530,7 @@ export interface FileRoutesById { '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute + '/api/generation-persistence': typeof ApiGenerationPersistenceRoute '/api/image': typeof ApiImageRouteWithChildren '/api/interrupts-test': typeof ApiInterruptsTestRoute '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -559,6 +578,7 @@ export interface FileRouteTypes { | '/devtools-structured' | '/devtools-tools' | '/foreign-interrupt' + | '/generation-persistence' | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' @@ -574,6 +594,7 @@ export interface FileRouteTypes { | '/api/devtools-memory' | '/api/durable-delivery' | '/api/foreign-interrupt' + | '/api/generation-persistence' | '/api/image' | '/api/interrupts-test' | '/api/lazy-tools-wire' @@ -619,6 +640,7 @@ export interface FileRouteTypes { | '/devtools-structured' | '/devtools-tools' | '/foreign-interrupt' + | '/generation-persistence' | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' @@ -634,6 +656,7 @@ export interface FileRouteTypes { | '/api/devtools-memory' | '/api/durable-delivery' | '/api/foreign-interrupt' + | '/api/generation-persistence' | '/api/image' | '/api/interrupts-test' | '/api/lazy-tools-wire' @@ -679,6 +702,7 @@ export interface FileRouteTypes { | '/devtools-structured' | '/devtools-tools' | '/foreign-interrupt' + | '/generation-persistence' | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' @@ -694,6 +718,7 @@ export interface FileRouteTypes { | '/api/devtools-memory' | '/api/durable-delivery' | '/api/foreign-interrupt' + | '/api/generation-persistence' | '/api/image' | '/api/interrupts-test' | '/api/lazy-tools-wire' @@ -740,6 +765,7 @@ export interface RootRouteChildren { DevtoolsStructuredRoute: typeof DevtoolsStructuredRoute DevtoolsToolsRoute: typeof DevtoolsToolsRoute ForeignInterruptRoute: typeof ForeignInterruptRoute + GenerationPersistenceRoute: typeof GenerationPersistenceRoute InterruptsTestRoute: typeof InterruptsTestRoute MarkdownCjkRoute: typeof MarkdownCjkRoute MiddlewareTestRoute: typeof MiddlewareTestRoute @@ -755,6 +781,7 @@ export interface RootRouteChildren { ApiDevtoolsMemoryRoute: typeof ApiDevtoolsMemoryRoute ApiDurableDeliveryRoute: typeof ApiDurableDeliveryRoute ApiForeignInterruptRoute: typeof ApiForeignInterruptRoute + ApiGenerationPersistenceRoute: typeof ApiGenerationPersistenceRoute ApiImageRoute: typeof ApiImageRouteWithChildren ApiInterruptsTestRoute: typeof ApiInterruptsTestRoute ApiLazyToolsWireRoute: typeof ApiLazyToolsWireRoute @@ -822,6 +849,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InterruptsTestRouteImport parentRoute: typeof rootRouteImport } + '/generation-persistence': { + id: '/generation-persistence' + path: '/generation-persistence' + fullPath: '/generation-persistence' + preLoaderRoute: typeof GenerationPersistenceRouteImport + parentRoute: typeof rootRouteImport + } '/foreign-interrupt': { id: '/foreign-interrupt' path: '/foreign-interrupt' @@ -1088,6 +1122,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiImageRouteImport parentRoute: typeof rootRouteImport } + '/api/generation-persistence': { + id: '/api/generation-persistence' + path: '/api/generation-persistence' + fullPath: '/api/generation-persistence' + preLoaderRoute: typeof ApiGenerationPersistenceRouteImport + parentRoute: typeof rootRouteImport + } '/api/foreign-interrupt': { id: '/api/foreign-interrupt' path: '/api/foreign-interrupt' @@ -1265,6 +1306,7 @@ const rootRouteChildren: RootRouteChildren = { DevtoolsStructuredRoute: DevtoolsStructuredRoute, DevtoolsToolsRoute: DevtoolsToolsRoute, ForeignInterruptRoute: ForeignInterruptRoute, + GenerationPersistenceRoute: GenerationPersistenceRoute, InterruptsTestRoute: InterruptsTestRoute, MarkdownCjkRoute: MarkdownCjkRoute, MiddlewareTestRoute: MiddlewareTestRoute, @@ -1280,6 +1322,7 @@ const rootRouteChildren: RootRouteChildren = { ApiDevtoolsMemoryRoute: ApiDevtoolsMemoryRoute, ApiDurableDeliveryRoute: ApiDurableDeliveryRoute, ApiForeignInterruptRoute: ApiForeignInterruptRoute, + ApiGenerationPersistenceRoute: ApiGenerationPersistenceRoute, ApiImageRoute: ApiImageRouteWithChildren, ApiInterruptsTestRoute: ApiInterruptsTestRoute, ApiLazyToolsWireRoute: ApiLazyToolsWireRoute, diff --git a/testing/e2e/src/routes/api.generation-persistence.ts b/testing/e2e/src/routes/api.generation-persistence.ts new file mode 100644 index 000000000..89fb133c3 --- /dev/null +++ b/testing/e2e/src/routes/api.generation-persistence.ts @@ -0,0 +1,67 @@ +import { createFileRoute } from '@tanstack/react-router' +import { toServerSentEventsResponse } from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Provider-free harness route for the generation-persistence reload story. + * Streams a FIXED generation AG-UI sequence (started → progress → result → + * finished) instead of calling an image model, so the e2e is deterministic + * with nothing to mock. + * + * Exempt from the aimock policy: this route streams a fixed AG-UI sequence and + * never reaches an LLM provider's HTTP layer, so there is nothing to mock. + */ + +// 1x1 transparent PNG — small enough to prove media bytes are NOT persisted. +const TINY_PNG_B64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + +function imageRun(threadId: string, runId: string): AsyncIterable { + return (async function* () { + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'CUSTOM', + name: 'generation:progress', + value: { progress: 50, message: 'Painting pixels' }, + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { + id: 'image-1', + model: 'mock-image-model', + images: [{ b64Json: TINY_PNG_B64 }], + }, + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'RUN_FINISHED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + })() +} + +export const Route = createFileRoute('/api/generation-persistence')({ + server: { + handlers: { + POST: async ({ request }) => { + const runId = request.headers.get('X-Run-Id') ?? `run-${Date.now()}` + const threadId = + request.headers.get('X-Thread-Id') ?? 'generation-persistence' + return toServerSentEventsResponse(imageRun(threadId, runId)) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/generation-persistence.tsx b/testing/e2e/src/routes/generation-persistence.tsx new file mode 100644 index 000000000..f9f22f40f --- /dev/null +++ b/testing/e2e/src/routes/generation-persistence.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { + fetchServerSentEvents, + localStoragePersistence, + useGenerateImage, +} from '@tanstack/ai-react' + +/** + * Browser-refresh persistence harness for generation hooks (client half). + * + * A `localStoragePersistence` adapter stores the lightweight resume snapshot + * under `tanstack-ai:generation:`. A full `page.reload()` hydrates the + * snapshot back into the hook — run status, error, and result metadata survive, + * while the generated image bytes do not (they are never written). The + * provider-free endpoint is `/api/generation-persistence`. + */ + +const snapshots = localStoragePersistence() +const connection = fetchServerSentEvents('/api/generation-persistence') + +export const Route = createFileRoute('/generation-persistence')({ + component: GenerationPersistencePage, +}) + +function GenerationPersistencePage() { + const image = useGenerateImage({ + id: 'generation-persistence', + connection, + persistence: snapshots, + }) + + // The page is SSR'd; the spec must not click the server-rendered button + // before React attaches handlers. This flag flips only after hydration. + const [hydrated, setHydrated] = useState(false) + useEffect(() => setHydrated(true), []) + + return ( +
+

Generation persistence

+ {hydrated ?
: null} + + + +
{image.status}
+
+ {image.resumeSnapshot?.status ?? 'none'} +
+
+ {image.resumeSnapshot?.result?.id ?? 'none'} +
+
+ {image.resumeSnapshot?.error?.message ?? 'none'} +
+ + {image.result?.images.map((img, i) => ( + generated + ))} +
+ ) +} diff --git a/testing/e2e/tests/generation-persistence.spec.ts b/testing/e2e/tests/generation-persistence.spec.ts new file mode 100644 index 000000000..b1a6305d9 --- /dev/null +++ b/testing/e2e/tests/generation-persistence.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test' + +/** + * Generation resume-snapshot persistence (browser refresh). + * + * Proves the story wired by `localStoragePersistence` + `useGenerateImage({ + * persistence, id })`: as a run streams, the client writes a lightweight + * snapshot under `tanstack-ai:generation:`, and a full `page.reload()` + * hydrates it back — run status and result metadata survive, generated bytes + * do not, and no run is auto-started. + * + * Provider-free: `/api/generation-persistence` streams a fixed AG-UI sequence, + * so there is no LLM in the loop and nothing to mock (exempt from the aimock + * policy). + */ + +const STORAGE_KEY = 'tanstack-ai:generation:generation-persistence' + +test.describe('generation persistence (browser refresh)', () => { + test('persists the snapshot, hydrates it after reload, and clears it on reset', async ({ + page, + }) => { + await page.goto('/generation-persistence') + await expect(page.getByTestId('hydration-marker')).toBeAttached() + await expect(page.getByTestId('snapshot-status')).toHaveText('none') + + await page.getByTestId('generate-button').click() + await expect(page.getByTestId('snapshot-status')).toHaveText('complete') + await expect(page.getByTestId('snapshot-result-id')).toHaveText('image-1') + await expect(page.getByTestId('generated-image')).toHaveCount(1) + + // The persisted record holds metadata only — never the image bytes. + const stored = await page.evaluate( + (key) => window.localStorage.getItem(key), + STORAGE_KEY, + ) + expect(stored).not.toBeNull() + expect(stored).toContain('"status":"complete"') + expect(stored).not.toContain('b64Json') + expect(stored).not.toContain('iVBOR') + + // Reload: the snapshot hydrates from storage; nothing auto-runs and the + // image itself (never persisted) is gone. + await page.reload() + await expect(page.getByTestId('hydration-marker')).toBeAttached() + await expect(page.getByTestId('snapshot-status')).toHaveText('complete') + await expect(page.getByTestId('snapshot-result-id')).toHaveText('image-1') + await expect(page.getByTestId('client-status')).toHaveText('idle') + await expect(page.getByTestId('generated-image')).toHaveCount(0) + + // Reset clears the in-memory snapshot and deletes the persisted record. + await page.getByTestId('reset-button').click() + await expect(page.getByTestId('snapshot-status')).toHaveText('none') + await expect + .poll(() => + page.evaluate((key) => window.localStorage.getItem(key), STORAGE_KEY), + ) + .toBeNull() + + await page.reload() + await expect(page.getByTestId('hydration-marker')).toBeAttached() + await expect(page.getByTestId('snapshot-status')).toHaveText('none') + }) +}) From 72220ea7b64ef85b277bf9391316ca21e618b2da Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:00:49 +1000 Subject: [PATCH 12/66] docs(skills): cover generation resume snapshots in client-persistence + media-generation skills --- .../ai-core/client-persistence/SKILL.md | 29 +++++++++++++++++++ .../skills/ai-core/media-generation/SKILL.md | 26 +++++++++++------ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index 3e6eb9b3b..7fa1601f5 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -7,6 +7,8 @@ description: > client cache). Reload restore, pending interrupts, mid-stream rejoin with delivery durability. Use for SPA reload durability — NOT server history alone. + Also covers generation hooks (useGenerateImage etc.): the same adapters + persist a lightweight resume snapshot under generation:. No extra package: the adapters ship in the framework packages. type: sub-skill library: tanstack-ai @@ -110,6 +112,33 @@ chat's identity _is_ its `threadId`. Without a stable one, each load is a new chat. Generate it server-side or from a route param the user owns; do not randomize per mount. +## Generation hooks: lightweight resume snapshots + +The generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGeneration`, +`useSummarize`, `useTranscription`, …) take the **same adapters** via their +`persistence` option, but store something much smaller than chat: a +`GenerationResumeSnapshot` — run identity, status, error, and result metadata +(ids, model, video `jobId`), **never the generated media bytes**. + +```tsx +const image = useGenerateImage({ + id: 'hero-image', // stable — the storage key is `generation:` + connection: fetchServerSentEvents('/api/generate/image'), + persistence: localStoragePersistence(), +}) +// After a reload: image.resumeSnapshot?.status is the last run's outcome. +// image.resumeState is non-null only WHILE a run is streaming. +``` + +- Hydration is automatic on mount and validated + (`parseGenerationResumeSnapshot`); an explicit `initialResumeSnapshot` seed + skips it. +- `stop()` marks the record no longer resumable; `reset()` deletes it. +- Nothing auto-runs from a persisted snapshot — `generate(...)` is always + explicit. +- The `generation:` key segment means a chat and a generation client can share + an id and an adapter without colliding. + ## Common mistakes ### HIGH: No `threadId` diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index 268a85f74..2c471e43c 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -569,15 +569,23 @@ returns `usage` and emits a `video:usage` devtools event when fal reports it. All generation hooks return the same shape: -| Property | Type | Description | -| ----------- | -------------------------- | ------------------------------------------------ | -| `generate` | `(input) => Promise` | Trigger generation | -| `result` | `T \| null` | Result (optionally transformed via `onResult`) | -| `isLoading` | `boolean` | Whether generation is in progress | -| `error` | `Error \| undefined` | Current error | -| `status` | `GenerationClientState` | `'idle' \| 'generating' \| 'success' \| 'error'` | -| `stop` | `() => void` | Abort current generation | -| `reset` | `() => void` | Clear state, return to idle | +| Property | Type | Description | +| ---------------- | -------------------------- | ------------------------------------------------ | +| `generate` | `(input) => Promise` | Trigger generation | +| `result` | `T \| null` | Result (optionally transformed via `onResult`) | +| `isLoading` | `boolean` | Whether generation is in progress | +| `error` | `Error \| undefined` | Current error | +| `status` | `GenerationClientState` | `'idle' \| 'generating' \| 'success' \| 'error'` | +| `stop` | `() => void` | Abort current generation | +| `reset` | `() => void` | Clear state (and any persisted snapshot) | +| `resumeSnapshot` | `GenerationResumeSnapshot \| undefined` | Last run's lightweight record (see below) | +| `resumeState` | `GenerationResumeState \| null` | Identity of the run WHILE it streams; null after | + +Hooks also accept `persistence` (a browser storage adapter such as +`localStoragePersistence()`) plus a stable `id`: the client then writes the +lightweight resume snapshot under `generation:` as the run streams and +hydrates it back on mount, so `resumeSnapshot` survives a reload (metadata +only — never media bytes). See `ai-core/client-persistence` for details. Provide either `connection` (streaming SSE transport) or `fetcher` (direct async call / server function returning `Response`). Use `onResult` From d6681141738aac6d80809dcd274d54dbea767022 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:04:12 +1000 Subject: [PATCH 13/66] fix(persistence): framework sweeps for Solid/Vue/Svelte/Angular + hydration ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- packages/ai-angular/src/index.ts | 6 + .../ai-angular/src/inject-generate-video.ts | 2 + packages/ai-angular/src/inject-generation.ts | 10 +- .../tests/inject-generation.test.ts | 129 ++++++++++++++++++ packages/ai-client/src/generation-client.ts | 5 +- .../ai-client/src/video-generation-client.ts | 5 +- packages/ai-solid/src/index.ts | 6 + packages/ai-solid/src/use-generate-video.ts | 50 ++++--- packages/ai-solid/src/use-generation.ts | 50 ++++--- .../ai-solid/tests/use-generation.test.ts | 120 ++++++++++++++++ .../src/create-generate-video.svelte.ts | 16 ++- .../ai-svelte/src/create-generation.svelte.ts | 16 ++- packages/ai-svelte/src/index.ts | 6 + .../ai-svelte/tests/create-generation.test.ts | 90 ++++++++++++ packages/ai-vue/src/index.ts | 6 + packages/ai-vue/src/use-generate-video.ts | 25 ++-- packages/ai-vue/src/use-generation.ts | 25 ++-- packages/ai-vue/tests/use-generation.test.ts | 91 ++++++++++++ 18 files changed, 587 insertions(+), 71 deletions(-) diff --git a/packages/ai-angular/src/index.ts b/packages/ai-angular/src/index.ts index 2c8dbef90..fc92c73d2 100644 --- a/packages/ai-angular/src/index.ts +++ b/packages/ai-angular/src/index.ts @@ -109,4 +109,10 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, + type GenerationPersistence, + type GenerationResumeSnapshot, + type GenerationResumeState, + type GenerationResumeStatus, + type GenerationPendingArtifact, } from '@tanstack/ai-client' +export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index 30b4396e7..4c6fdbe43 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -37,7 +37,9 @@ export interface InjectGenerateVideoOptions { id?: string body?: ReactiveOption> devtools?: AIDevtoolsDisplayOptions + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot onResult?: (result: VideoGenerateResult) => TOutput | null | void onError?: (error: Error) => void diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index 95e95559b..75e6381fd 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -40,9 +40,9 @@ export interface InjectGenerationOptions { body?: ReactiveOption> /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. @@ -77,11 +77,11 @@ export interface InjectGenerationResult { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: Signal - /** Observed run/cursor metadata from the snapshot (read-only state) */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: Signal - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Signal> - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Signal> } diff --git a/packages/ai-angular/tests/inject-generation.test.ts b/packages/ai-angular/tests/inject-generation.test.ts index 350dcdbb6..b00c97cd5 100644 --- a/packages/ai-angular/tests/inject-generation.test.ts +++ b/packages/ai-angular/tests/inject-generation.test.ts @@ -10,6 +10,7 @@ import { injectGenerateVideo } from '../src/inject-generate-video' import type { StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter, + GenerationPersistence, GenerationResumeSnapshot, RunAgentInputContext, } from '@tanstack/ai-client' @@ -69,6 +70,44 @@ const videoResumeSnapshot: GenerationResumeSnapshot = { status: 'running', } +/** + * Storage adapter backed by a Map, so a test can seed a persisted record and + * then assert on what the client read, wrote, and removed. + */ +function createMapPersistence(seed?: Record): { + persistence: GenerationPersistence + store: Map + getItem: ReturnType + setItem: ReturnType + removeItem: ReturnType +} { + const store = new Map( + Object.entries(seed ?? {}), + ) + const getItem = vi.fn((key: string) => store.get(key) ?? null) + const setItem = vi.fn((key: string, value: GenerationResumeSnapshot) => { + store.set(key, value) + }) + const removeItem = vi.fn((key: string) => { + store.delete(key) + }) + return { + persistence: { getItem, setItem, removeItem }, + store, + getItem, + setItem, + removeItem, + } +} + +// Hydration and snapshot removal both run through awaited promise chains, so +// drain the microtask queue rather than awaiting a single tick. +async function flushPromises(): Promise { + for (let i = 0; i < 5; i++) { + await Promise.resolve() + } +} + function createRunContextCaptureAdapter(chunks: Array): { adapter: ConnectConnectionAdapter connect: ReturnType @@ -144,6 +183,66 @@ describe('injectGeneration', () => { // The persisted snapshot remains exposed as read-only state. expect(result.resumeState()).toEqual(snapshot.resumeState) }) + + it('hydrates a persisted snapshot from storage on construction', async () => { + const { adapter, connect } = createRunContextCaptureAdapter([]) + const { persistence, getItem } = createMapPersistence({ + 'generation:hydrate-me': { + resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, + status: 'running', + }, + }) + const { result } = renderInjectGeneration({ + id: 'hydrate-me', + connection: adapter, + persistence, + }) + + await flushPromises() + + expect(getItem).toHaveBeenCalledTimes(1) + expect(getItem).toHaveBeenCalledWith('generation:hydrate-me') + // Hydration only surfaces state; it never restarts the run. + expect(connect).not.toHaveBeenCalled() + expect(result.resumeSnapshot()).toEqual({ + schemaVersion: 1, + resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, + status: 'running', + }) + expect(result.resumeState()).toEqual({ + threadId: 'thread-stored', + runId: 'run-stored', + }) + }) + + it('clears the snapshot and removes the persisted record on reset', async () => { + const snapshot: GenerationResumeSnapshot = { + resumeState: { threadId: 'thread-reset', runId: 'run-reset' }, + status: 'running', + } + const { adapter } = createRunContextCaptureAdapter([]) + const { persistence, removeItem, store } = createMapPersistence({ + 'generation:reset-me': snapshot, + }) + const { result } = renderInjectGeneration({ + id: 'reset-me', + connection: adapter, + persistence, + initialResumeSnapshot: snapshot, + }) + + expect(result.resumeSnapshot()).toEqual(snapshot) + + result.reset() + await flushPromises() + + expect(result.resumeSnapshot()).toBeUndefined() + expect(result.resumeState()).toBeNull() + expect(result.pendingArtifacts()).toEqual([]) + expect(result.resultArtifacts()).toEqual([]) + expect(removeItem).toHaveBeenCalledWith('generation:reset-me') + expect(store.has('generation:reset-me')).toBe(false) + }) }) describe('injectGenerateVideo', () => { @@ -168,4 +267,34 @@ describe('injectGenerateVideo', () => { expect(result.resumeSnapshot()).toEqual(videoResumeSnapshot) expect(result.resumeState()).toEqual(videoResumeSnapshot.resumeState) }) + + it('hydrates from storage and clears the persisted record on reset', async () => { + const { adapter, connect } = createRunContextCaptureAdapter([]) + const { persistence, getItem, removeItem, store } = createMapPersistence({ + 'generation:video-hydrate': videoResumeSnapshot, + }) + const { result } = renderInjectGenerateVideo({ + id: 'video-hydrate', + connection: adapter, + persistence, + }) + + await flushPromises() + + expect(getItem).toHaveBeenCalledTimes(1) + expect(getItem).toHaveBeenCalledWith('generation:video-hydrate') + expect(connect).not.toHaveBeenCalled() + expect(result.resumeSnapshot()).toEqual({ + schemaVersion: 1, + ...videoResumeSnapshot, + }) + + result.reset() + await flushPromises() + + expect(result.resumeSnapshot()).toBeUndefined() + expect(result.resumeState()).toBeNull() + expect(removeItem).toHaveBeenCalledWith('generation:video-hydrate') + expect(store.has('generation:video-hydrate')).toBe(false) + }) }) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index ba391c962..fead619b1 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -126,7 +126,6 @@ export class GenerationClient< this.body = options.body ?? {} this.resumePersistence = options.persistence this.resumeSnapshot = options.initialResumeSnapshot - this.maybeHydrateResumeSnapshot() this.callbacksRef = { onResult: options.onResult, @@ -144,6 +143,10 @@ export class GenerationClient< this.devtoolsBridge = ( options.devtoolsBridgeFactory ?? createNoOpGenerationDevtoolsBridge )(this.buildDevtoolsBridgeOptions()) + + // After callbacksRef is assigned: hydration may fire + // onResumeSnapshotChange synchronously if an adapter resolves sync. + this.maybeHydrateResumeSnapshot() } private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions { diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index ca67a120c..e50be403b 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -136,7 +136,6 @@ export class VideoGenerationClient { this.body = options.body ?? {} this.resumePersistence = options.persistence this.resumeSnapshot = options.initialResumeSnapshot - this.maybeHydrateResumeSnapshot() this.callbacksRef = { onResult: options.onResult, @@ -158,6 +157,10 @@ export class VideoGenerationClient { this.devtoolsBridge = ( options.devtoolsBridgeFactory ?? createNoOpVideoDevtoolsBridge )(this.buildDevtoolsBridgeOptions()) + + // After callbacksRef is assigned: hydration may fire + // onResumeSnapshotChange synchronously if an adapter resolves sync. + this.maybeHydrateResumeSnapshot() } private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions { diff --git a/packages/ai-solid/src/index.ts b/packages/ai-solid/src/index.ts index 86b39b403..ae43ec04c 100644 --- a/packages/ai-solid/src/index.ts +++ b/packages/ai-solid/src/index.ts @@ -94,4 +94,10 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, + type GenerationPersistence, + type GenerationResumeSnapshot, + type GenerationResumeState, + type GenerationResumeStatus, + type GenerationPendingArtifact, } from '@tanstack/ai-client' +export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index b00589302..799091492 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -2,11 +2,11 @@ import { VideoGenerationClient } from '@tanstack/ai-client' import { createVideoDevtoolsBridge } from '@tanstack/ai-client/devtools' import { createEffect, - createMemo, createSignal, createUniqueId, onCleanup, onMount, + untrack, } from 'solid-js' import type { StreamChunk } from '@tanstack/ai' import type { @@ -42,9 +42,9 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when video generation completes. Can optionally return a transformed value. @@ -92,11 +92,11 @@ export interface UseGenerateVideoReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: Accessor - /** Observed run/cursor metadata from the snapshot (read-only state) */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: Accessor - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Accessor> - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Accessor> } @@ -161,7 +161,12 @@ export function useGenerateVideo( >(options.initialResumeSnapshot) let disposed = false - const client = createMemo(() => { + // Built once. `untrack` keeps the option reads below from subscribing + // construction to `options.persistence` / `options.initialResumeSnapshot` / + // `options.devtools` / `options.body`: a re-run would build a second client + // and orphan the first (only the live one is disposed on cleanup). Later + // `options.body` changes are pushed through `updateOptions` instead. + const client = untrack((): VideoGenerationClient => { // Conditional spread on `body`: VideoGenerationClientOptions.body // is a strict optional; EOPT forbids passing `T | undefined`. const baseOptions = { @@ -243,38 +248,38 @@ export function useGenerateVideo( throw new Error( 'useGenerateVideo requires either a connection or fetcher option', ) - }, [clientId]) + }) // Sync body changes without recreating client createEffect(() => { const currentBody = options.body - client().updateOptions({ + client.updateOptions({ ...(currentBody !== undefined && { body: currentBody }), }) }) - // Mount devtools only. Generation runs are never auto-started on mount — - // persisted state is read-only for display. + // Mount devtools only. Generation runs are never auto-started on mount — a + // persisted snapshot is hydrated for display, never replayed. onMount(() => { - client().mountDevtools() + client.mountDevtools() }) // Cleanup on unmount: stop any in-flight requests and unregister devtools onCleanup(() => { disposed = true - client().dispose() + client.dispose() }) const generate = async (input: VideoGenerateInput) => { - await client().generate(input) + await client.generate(input) } const stop = () => { - client().stop() + client.stop() } const reset = () => { - client().reset() + client.reset() } return { @@ -289,7 +294,16 @@ export function useGenerateVideo( reset, resumeSnapshot, resumeState: () => resumeSnapshot()?.resumeState ?? null, - pendingArtifacts: () => resumeSnapshot()?.pendingArtifacts ?? [], - resultArtifacts: () => resumeSnapshot()?.result?.artifacts ?? [], + pendingArtifacts: () => + resumeSnapshot()?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, + resultArtifacts: () => + resumeSnapshot()?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, } } + +// Stable fallbacks so the accessors keep returning the same array identity +// while no snapshot exists. (A `createMemo` would buy nothing on top of this: +// a populated array's identity already comes from the snapshot object, and +// memos are one-shot under Solid's SSR build.) +const EMPTY_PENDING_ARTIFACTS: Array = [] +const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index 3e9c81841..3bf90d5a3 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -2,11 +2,11 @@ import { GenerationClient } from '@tanstack/ai-client' import { createGenerationDevtoolsBridge } from '@tanstack/ai-client/devtools' import { createEffect, - createMemo, createSignal, createUniqueId, onCleanup, onMount, + untrack, } from 'solid-js' import type { StreamChunk } from '@tanstack/ai' import type { @@ -44,9 +44,9 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. @@ -86,11 +86,11 @@ export interface UseGenerationReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: Accessor - /** Observed run/cursor metadata from the snapshot (read-only state) */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: Accessor - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: Accessor> - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: Accessor> } @@ -142,7 +142,12 @@ export function useGeneration< >(options.initialResumeSnapshot) let disposed = false - const client = createMemo(() => { + // Built once. `untrack` keeps the option reads below from subscribing + // construction to `options.persistence` / `options.initialResumeSnapshot` / + // `options.devtools` / `options.body`: a re-run would build a second client + // and orphan the first (only the live one is disposed on cleanup). Later + // `options.body` changes are pushed through `updateOptions` instead. + const client = untrack((): GenerationClient => { // Conditional spread on `body`: `GenerationClientOptions.body` is a // strict optional (`body?: Record`) and EOPT forbids // assigning the source `T | undefined` directly. @@ -210,38 +215,38 @@ export function useGeneration< throw new Error( 'useGeneration requires either a connection or fetcher option', ) - }, [clientId]) + }) // Sync body changes without recreating client createEffect(() => { const currentBody = options.body - client().updateOptions({ + client.updateOptions({ ...(currentBody !== undefined && { body: currentBody }), }) }) - // Mount devtools only. Generation runs are never auto-started on mount — - // persisted state is read-only for display. + // Mount devtools only. Generation runs are never auto-started on mount — a + // persisted snapshot is hydrated for display, never replayed. onMount(() => { - client().mountDevtools() + client.mountDevtools() }) // Cleanup on unmount: stop any in-flight requests and unregister devtools onCleanup(() => { disposed = true - client().dispose() + client.dispose() }) const generate = async (input: TInput) => { - await client().generate(input) + await client.generate(input) } const stop = () => { - client().stop() + client.stop() } const reset = () => { - client().reset() + client.reset() } return { @@ -254,7 +259,16 @@ export function useGeneration< reset, resumeSnapshot, resumeState: () => resumeSnapshot()?.resumeState ?? null, - pendingArtifacts: () => resumeSnapshot()?.pendingArtifacts ?? [], - resultArtifacts: () => resumeSnapshot()?.result?.artifacts ?? [], + pendingArtifacts: () => + resumeSnapshot()?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, + resultArtifacts: () => + resumeSnapshot()?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, } } + +// Stable fallbacks so the accessors keep returning the same array identity +// while no snapshot exists. (A `createMemo` would buy nothing on top of this: +// a populated array's identity already comes from the snapshot object, and +// memos are one-shot under Solid's SSR build.) +const EMPTY_PENDING_ARTIFACTS: Array = [] +const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-solid/tests/use-generation.test.ts b/packages/ai-solid/tests/use-generation.test.ts index 605bfe45c..a7c078686 100644 --- a/packages/ai-solid/tests/use-generation.test.ts +++ b/packages/ai-solid/tests/use-generation.test.ts @@ -1159,6 +1159,8 @@ describe('useGenerateVideo', () => { await new Promise((resolve) => setTimeout(resolve, 0)) expect(connect).not.toHaveBeenCalled() + // An explicit `initialResumeSnapshot` seed wins over storage, so the + // client skips hydration entirely here. expect(persistence.getItem).not.toHaveBeenCalled() expect(result.isLoading()).toBe(false) expect(result.status()).toBe('idle') @@ -1182,6 +1184,124 @@ describe('useGenerateVideo', () => { }) }) +describe('resume snapshot persistence', () => { + // Map-backed stand-in for a real storage adapter, so a test can both assert + // on the calls and read back what actually landed in storage. + function createMapPersistence( + seed: Array<[string, GenerationResumeSnapshot]> = [], + ): { + persistence: GenerationPersistence + store: Map + } { + const store = new Map(seed) + return { + store, + persistence: { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: GenerationResumeSnapshot) => { + store.set(key, value) + }), + removeItem: vi.fn((key: string) => { + store.delete(key) + }), + }, + } + } + + // Hydration and the persistence write queue are async; give both a turn. + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + + it('hydrates a persisted snapshot into resumeSnapshot after mount', async () => { + const stored: GenerationResumeSnapshot = { + schemaVersion: 1, + resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, + status: 'running', + } + const { persistence } = createMapPersistence([ + ['generation:hydrate-me', stored], + ]) + const adapter = createMockConnectionAdapter() + + const { result } = renderHook(() => + useGeneration({ + id: 'hydrate-me', + connection: adapter, + persistence, + }), + ) + + expect(result.resumeSnapshot()).toBeUndefined() + + await flush() + + expect(persistence.getItem).toHaveBeenCalledTimes(1) + expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrate-me') + expect(result.resumeSnapshot()).toEqual(stored) + expect(result.resumeState()).toEqual(stored.resumeState) + // Hydrating is display-only — it never starts a run. + expect(result.status()).toBe('idle') + expect(result.isLoading()).toBe(false) + }) + + it('hydrates a persisted snapshot for useGenerateVideo', async () => { + const stored: GenerationResumeSnapshot = { + schemaVersion: 1, + resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, + status: 'running', + } + const { persistence } = createMapPersistence([ + ['generation:video-hydrate-me', stored], + ]) + const adapter = createMockConnectionAdapter() + + const { result } = renderHook(() => + useGenerateVideo({ + id: 'video-hydrate-me', + connection: adapter, + persistence, + }), + ) + + await flush() + + expect(persistence.getItem).toHaveBeenCalledWith( + 'generation:video-hydrate-me', + ) + expect(result.resumeSnapshot()).toEqual(stored) + expect(result.status()).toBe('idle') + }) + + it('clears resumeSnapshot and removes the persisted record on reset', async () => { + const { persistence, store } = createMapPersistence() + + const { result } = renderHook(() => + useGeneration({ + id: 'reset-me', + fetcher: async () => ({ id: '1' }), + persistence, + }), + ) + + await result.generate({ prompt: 'test' }) + await flush() + + expect(persistence.setItem).toHaveBeenCalledWith( + 'generation:reset-me', + expect.objectContaining({ status: 'complete' }), + ) + expect(result.resumeSnapshot()?.status).toBe('complete') + + result.reset() + + expect(result.resumeSnapshot()).toBeUndefined() + + await flush() + + expect(persistence.removeItem).toHaveBeenCalledWith('generation:reset-me') + expect(store.has('generation:reset-me')).toBe(false) + }) +}) + describe('onResult transform', () => { it('should transform result when onResult returns a value (fetcher)', async () => { // Inference (issue #848): `onResult`'s parameter is contextually typed from diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index 2615cf733..53491d22f 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -33,9 +33,9 @@ export interface CreateGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when video generation completes. Can optionally return a transformed value. @@ -87,11 +87,11 @@ export interface CreateGenerateVideoReturn { updateBody: (body: Record) => void /** Lightweight generation resume snapshot, if one is available */ readonly resumeSnapshot: GenerationResumeSnapshot | undefined - /** Observed run/cursor metadata from the snapshot (read-only state) */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ readonly resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ readonly pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ readonly resultArtifacts: Array } @@ -258,6 +258,12 @@ export function createGenerateVideo( // Users should call video.dispose() in their component's cleanup if needed. const generate = async (input: VideoGenerateInput) => { + // Svelte has no remount effect to revive a disposed client (the other + // frameworks revive via mountDevtools() in their mount effects), so an + // explicit generate() after dispose() is the Svelte revive path: bring + // the client and the reactive bindings back together. + disposed = false + client.mountDevtools() await client.generate(input) } diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index 8696c72e1..4fe1a6da6 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -35,9 +35,9 @@ export interface CreateGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. @@ -81,11 +81,11 @@ export interface CreateGenerationReturn { updateBody: (body: Record) => void /** Lightweight generation resume snapshot, if one is available */ readonly resumeSnapshot: GenerationResumeSnapshot | undefined - /** Observed run/cursor metadata from the snapshot (read-only state) */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ readonly resumeState: GenerationResumeState | null - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ readonly pendingArtifacts: Array - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ readonly resultArtifacts: Array } @@ -240,6 +240,12 @@ export function createGeneration< // Users should call gen.dispose() in their component's cleanup if needed. const generate = async (input: TInput) => { + // Svelte has no remount effect to revive a disposed client (the other + // frameworks revive via mountDevtools() in their mount effects), so an + // explicit generate() after dispose() is the Svelte revive path: bring + // the client and the reactive bindings back together. + disposed = false + client.mountDevtools() await client.generate(input) } diff --git a/packages/ai-svelte/src/index.ts b/packages/ai-svelte/src/index.ts index efe617ebe..8ac12363d 100644 --- a/packages/ai-svelte/src/index.ts +++ b/packages/ai-svelte/src/index.ts @@ -98,4 +98,10 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, + type GenerationPersistence, + type GenerationResumeSnapshot, + type GenerationResumeState, + type GenerationResumeStatus, + type GenerationPendingArtifact, } from '@tanstack/ai-client' +export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-svelte/tests/create-generation.test.ts b/packages/ai-svelte/tests/create-generation.test.ts index c9e4e1185..24a7b4052 100644 --- a/packages/ai-svelte/tests/create-generation.test.ts +++ b/packages/ai-svelte/tests/create-generation.test.ts @@ -103,6 +103,32 @@ function createRunContextCaptureAdapter(chunks: Array): { return { adapter, connect, runContexts } } +/** + * Storage adapter backed by a plain Map, seeded with raw (untyped) records the + * way real storage hands them back — the client re-validates whatever it reads. + */ +function createMapPersistence(seed: Record = {}) { + const store = new Map(Object.entries(seed)) + return { + store, + getItem: vi.fn(async (key: string) => { + return store.get(key) as GenerationResumeSnapshot | undefined + }), + setItem: vi.fn(async (key: string, value: GenerationResumeSnapshot) => { + store.set(key, value) + }), + removeItem: vi.fn(async (key: string) => { + store.delete(key) + }), + } +} + +// Snapshot hydration and removal both run through promise queues detached from +// the caller, so tests wait a macrotask for them to settle. +function flushAsync(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + // Helper to create error stream chunks function createErrorChunks(message: string): Array { return [ @@ -237,6 +263,8 @@ describe('createGeneration', () => { await Promise.resolve() expect(connect).not.toHaveBeenCalled() + // An explicit `initialResumeSnapshot` seed takes precedence over + // storage, so the client skips its hydration read entirely. expect(getItem).not.toHaveBeenCalled() expect(gen.isLoading).toBe(false) expect(gen.status).toBe('idle') @@ -245,6 +273,68 @@ describe('createGeneration', () => { }) }) + describe('resume snapshot persistence', () => { + it('hydrates a persisted snapshot into resumeSnapshot on creation', async () => { + const persistence = createMapPersistence({ + 'generation:hydrate-me': { + schemaVersion: 1, + resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, + status: 'running', + }, + }) + + const gen = createGeneration({ + id: 'hydrate-me', + connection: createMockConnectionAdapter(), + persistence, + }) + + // Hydration is async — the storage read is awaited off the constructor. + expect(gen.resumeSnapshot).toBeUndefined() + await flushAsync() + + expect(persistence.getItem).toHaveBeenCalledTimes(1) + expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrate-me') + expect(gen.resumeSnapshot).toEqual({ + schemaVersion: 1, + resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, + status: 'running', + }) + expect(gen.resumeState).toEqual({ + threadId: 'thread-stored', + runId: 'run-stored', + }) + }) + + it('clears resumeSnapshot and removes the persisted record on reset', async () => { + const persistence = createMapPersistence({ + 'generation:reset-me': { + schemaVersion: 1, + resumeState: null, + status: 'complete', + }, + }) + + const gen = createGeneration({ + id: 'reset-me', + connection: createMockConnectionAdapter(), + persistence, + }) + + await flushAsync() + expect(gen.resumeSnapshot).toBeDefined() + + gen.reset() + + expect(gen.resumeSnapshot).toBeUndefined() + expect(gen.resumeState).toBeNull() + + await flushAsync() + expect(persistence.removeItem).toHaveBeenCalledWith('generation:reset-me') + expect(persistence.store.has('generation:reset-me')).toBe(false) + }) + }) + describe('stop and reset', () => { it('should stop generation and return to idle', async () => { let resolvePromise: (value: any) => void diff --git a/packages/ai-vue/src/index.ts b/packages/ai-vue/src/index.ts index 86b39b403..ae43ec04c 100644 --- a/packages/ai-vue/src/index.ts +++ b/packages/ai-vue/src/index.ts @@ -94,4 +94,10 @@ export { type VideoGenerateInput, type VideoGenerateResult, type VideoStatusInfo, + type GenerationPersistence, + type GenerationResumeSnapshot, + type GenerationResumeState, + type GenerationResumeStatus, + type GenerationPendingArtifact, } from '@tanstack/ai-client' +export type { PersistedArtifactRef } from '@tanstack/ai/client' diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 139c4a44d..1f703eec6 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -42,9 +42,9 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when video generation completes. Can optionally return a transformed value. @@ -92,11 +92,11 @@ export interface UseGenerateVideoReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: DeepReadonly> - /** Observed run/cursor metadata from the snapshot (read-only state) */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: DeepReadonly> - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: DeepReadonly>> - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: DeepReadonly>> } @@ -161,10 +161,10 @@ export function useGenerateVideo( options.initialResumeSnapshot?.resumeState ?? null, ) const pendingArtifacts = shallowRef>( - options.initialResumeSnapshot?.pendingArtifacts ?? [], + options.initialResumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, ) const resultArtifacts = shallowRef>( - options.initialResumeSnapshot?.result?.artifacts ?? [], + options.initialResumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, ) let disposed = false @@ -174,8 +174,10 @@ export function useGenerateVideo( if (disposed) return resumeSnapshot.value = snapshot resumeState.value = snapshot?.resumeState ?? null - pendingArtifacts.value = snapshot?.pendingArtifacts ?? [] - resultArtifacts.value = snapshot?.result?.artifacts ?? [] + pendingArtifacts.value = + snapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS + resultArtifacts.value = + snapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS } // Conditional spread on `body`: `VideoGenerationClientOptions.body` is a @@ -319,3 +321,8 @@ export function useGenerateVideo( resultArtifacts: readonly(resultArtifacts), } } + +// Shared fallbacks so a snapshot change that leaves the artifact lists empty +// reassigns the same reference and doesn't trigger dependent watchers. +const EMPTY_PENDING_ARTIFACTS: Array = [] +const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index 771207659..8338eff5d 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -44,9 +44,9 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Server-side lightweight generation state persistence. */ + /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ persistence?: GenerationPersistence - /** Initial lightweight resume snapshot restored by the app (read-only state). */ + /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** * Callback when a result is received. Can optionally return a transformed value. @@ -86,11 +86,11 @@ export interface UseGenerationReturn { reset: () => void /** Lightweight generation resume snapshot, if one is available */ resumeSnapshot: DeepReadonly> - /** Observed run/cursor metadata from the snapshot (read-only state) */ + /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: DeepReadonly> - /** Pending persisted artifact references observed during generation/replay */ + /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ pendingArtifacts: DeepReadonly>> - /** Final persisted artifact references observed from a replayed result */ + /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ resultArtifacts: DeepReadonly>> } @@ -146,10 +146,10 @@ export function useGeneration< options.initialResumeSnapshot?.resumeState ?? null, ) const pendingArtifacts = shallowRef>( - options.initialResumeSnapshot?.pendingArtifacts ?? [], + options.initialResumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, ) const resultArtifacts = shallowRef>( - options.initialResumeSnapshot?.result?.artifacts ?? [], + options.initialResumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, ) let disposed = false @@ -159,8 +159,10 @@ export function useGeneration< if (disposed) return resumeSnapshot.value = snapshot resumeState.value = snapshot?.resumeState ?? null - pendingArtifacts.value = snapshot?.pendingArtifacts ?? [] - resultArtifacts.value = snapshot?.result?.artifacts ?? [] + pendingArtifacts.value = + snapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS + resultArtifacts.value = + snapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS } // Conditional spread on `body`: `GenerationClientOptions.body` is a strict @@ -287,3 +289,8 @@ export function useGeneration< resultArtifacts: readonly(resultArtifacts), } } + +// Shared fallbacks so a snapshot change that leaves the artifact lists empty +// reassigns the same reference and doesn't trigger dependent watchers. +const EMPTY_PENDING_ARTIFACTS: Array = [] +const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-vue/tests/use-generation.test.ts b/packages/ai-vue/tests/use-generation.test.ts index 7004ba995..1b7b70985 100644 --- a/packages/ai-vue/tests/use-generation.test.ts +++ b/packages/ai-vue/tests/use-generation.test.ts @@ -12,6 +12,7 @@ import { createMockConnectionAdapter } from './test-utils' import type { StreamChunk, TTSResult, TranscriptionResult } from '@tanstack/ai' import type { ConnectConnectionAdapter, + GenerationPersistence, GenerationResumeSnapshot, RunAgentInputContext, } from '@tanstack/ai-client' @@ -121,6 +122,25 @@ function createRunContextCaptureAdapter(chunks: Array): { return { adapter, connect, runContexts } } +/** Map-backed storage adapter standing in for localStorage/IndexedDB. */ +function createMapPersistence( + seed?: Record, +): GenerationPersistence & { store: Map } { + const store = new Map( + Object.entries(seed ?? {}), + ) + return { + store, + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: GenerationResumeSnapshot) => { + store.set(key, value) + }), + removeItem: vi.fn((key: string) => { + store.delete(key) + }), + } +} + // Helper to create error stream chunks function createErrorChunks(message: string): Array { return [ @@ -334,6 +354,77 @@ describe('useGeneration', () => { }) }) + describe('resume snapshot persistence', () => { + it('hydrates a persisted snapshot into resumeSnapshot on mount', async () => { + const stored: GenerationResumeSnapshot = { + schemaVersion: 1, + resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, + status: 'running', + } + const persistence = createMapPersistence({ + 'generation:hydrate-me': stored, + }) + + const { result } = renderHook(() => + useGeneration({ + id: 'hydrate-me', + fetcher: async () => ({ id: '1' }), + persistence, + }), + ) + + // Hydration is async: it starts at construction and resolves before the + // first flush, so the snapshot is only visible after awaiting. + expect(result.resumeSnapshot.value).toBeUndefined() + await flushPromises() + await nextTick() + + expect(persistence.getItem).toHaveBeenCalledTimes(1) + expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrate-me') + expect(result.resumeSnapshot.value).toEqual(stored) + expect(result.resumeState.value).toEqual(stored.resumeState) + // Hydration alone must not start a run. + expect(result.isLoading.value).toBe(false) + expect(result.status.value).toBe('idle') + }) + + it('clears resumeSnapshot and removes the persisted record on reset', async () => { + const persistence = createMapPersistence() + + const { result } = renderHook(() => + useGeneration({ + id: 'reset-me', + connection: createMockConnectionAdapter({ + chunks: createGenerationChunks({ id: '1' }), + }), + persistence, + }), + ) + + await result.generate({ prompt: 'test' }) + await flushPromises() + await nextTick() + + expect(result.resumeSnapshot.value).toBeDefined() + expect(persistence.setItem).toHaveBeenCalledWith( + 'generation:reset-me', + expect.objectContaining({ status: 'complete' }), + ) + expect(persistence.store.get('generation:reset-me')).toBeDefined() + + result.reset() + await flushPromises() + await nextTick() + + expect(result.resumeSnapshot.value).toBeUndefined() + expect(result.resumeState.value).toBeNull() + expect(result.pendingArtifacts.value).toEqual([]) + expect(result.resultArtifacts.value).toEqual([]) + expect(persistence.removeItem).toHaveBeenCalledWith('generation:reset-me') + expect(persistence.store.has('generation:reset-me')).toBe(false) + }) + }) + describe('error handling', () => { it('should require either connection or fetcher', () => { expect(() => { From a92bd99ed75209eb3c626489cb3baeefc81f7038 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:13:47 +0000 Subject: [PATCH 14/66] ci: apply automated fixes --- .../tests/inject-generation.test.ts | 4 +++- packages/ai-client/src/generation-types.ts | 4 +++- .../tests/generation-resume-state.test.ts | 6 ++++- packages/ai-react/src/use-generate-video.ts | 10 ++++++--- packages/ai-react/src/use-generation.ts | 6 +++-- .../skills/ai-core/media-generation/SKILL.md | 22 +++++++++---------- .../e2e/src/routes/generation-persistence.tsx | 7 +++--- 7 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/ai-angular/tests/inject-generation.test.ts b/packages/ai-angular/tests/inject-generation.test.ts index b00c97cd5..05fc05c56 100644 --- a/packages/ai-angular/tests/inject-generation.test.ts +++ b/packages/ai-angular/tests/inject-generation.test.ts @@ -74,7 +74,9 @@ const videoResumeSnapshot: GenerationResumeSnapshot = { * Storage adapter backed by a Map, so a test can seed a persisted record and * then assert on what the client read, wrote, and removed. */ -function createMapPersistence(seed?: Record): { +function createMapPersistence( + seed?: Record, +): { persistence: GenerationPersistence store: Map getItem: ReturnType diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index 4ad914b90..f7229276f 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -250,7 +250,9 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { /** @internal Called when generation status changes */ onStatusChange?: (status: GenerationClientState) => void /** @internal Called when lightweight resume snapshot changes. Receives `undefined` when the snapshot is cleared by `reset()`. */ - onResumeSnapshotChange?: (snapshot: GenerationResumeSnapshot | undefined) => void + onResumeSnapshotChange?: ( + snapshot: GenerationResumeSnapshot | undefined, + ) => void } /** diff --git a/packages/ai-client/tests/generation-resume-state.test.ts b/packages/ai-client/tests/generation-resume-state.test.ts index fb3593ae9..ab8408d74 100644 --- a/packages/ai-client/tests/generation-resume-state.test.ts +++ b/packages/ai-client/tests/generation-resume-state.test.ts @@ -336,7 +336,11 @@ describe('parseGenerationResumeSnapshot', () => { { type: EventType.CUSTOM, name: GENERATION_EVENTS.RESULT, - value: { id: 'result-1', model: 'image-model', artifacts: [artifactRef] }, + value: { + id: 'result-1', + model: 'image-model', + artifacts: [artifactRef], + }, threadId: 'thread-1', runId: 'run-1', timestamp: 2, diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index 63b320ab2..bdaaaf07c 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -214,7 +214,9 @@ export function useGenerateVideo( onVideoStatusChange: (s: VideoStatusInfo | null) => { if (!disposedRef.current) setVideoStatus(s) }, - onResumeSnapshotChange: (snapshot: GenerationResumeSnapshot | undefined) => { + onResumeSnapshotChange: ( + snapshot: GenerationResumeSnapshot | undefined, + ) => { if (!disposedRef.current) setResumeSnapshot(snapshot) }, } @@ -286,8 +288,10 @@ export function useGenerateVideo( reset, resumeSnapshot, resumeState: resumeSnapshot?.resumeState ?? null, - pendingArtifacts: resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, - resultArtifacts: resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, + pendingArtifacts: + resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, + resultArtifacts: + resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, } } diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 7adab922d..e192f670d 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -253,8 +253,10 @@ export function useGeneration< reset, resumeSnapshot, resumeState: resumeSnapshot?.resumeState ?? null, - pendingArtifacts: resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, - resultArtifacts: resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, + pendingArtifacts: + resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, + resultArtifacts: + resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, } } diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index 2c471e43c..c88144c8f 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -569,17 +569,17 @@ returns `usage` and emits a `video:usage` devtools event when fal reports it. All generation hooks return the same shape: -| Property | Type | Description | -| ---------------- | -------------------------- | ------------------------------------------------ | -| `generate` | `(input) => Promise` | Trigger generation | -| `result` | `T \| null` | Result (optionally transformed via `onResult`) | -| `isLoading` | `boolean` | Whether generation is in progress | -| `error` | `Error \| undefined` | Current error | -| `status` | `GenerationClientState` | `'idle' \| 'generating' \| 'success' \| 'error'` | -| `stop` | `() => void` | Abort current generation | -| `reset` | `() => void` | Clear state (and any persisted snapshot) | -| `resumeSnapshot` | `GenerationResumeSnapshot \| undefined` | Last run's lightweight record (see below) | -| `resumeState` | `GenerationResumeState \| null` | Identity of the run WHILE it streams; null after | +| Property | Type | Description | +| ---------------- | --------------------------------------- | ------------------------------------------------ | +| `generate` | `(input) => Promise` | Trigger generation | +| `result` | `T \| null` | Result (optionally transformed via `onResult`) | +| `isLoading` | `boolean` | Whether generation is in progress | +| `error` | `Error \| undefined` | Current error | +| `status` | `GenerationClientState` | `'idle' \| 'generating' \| 'success' \| 'error'` | +| `stop` | `() => void` | Abort current generation | +| `reset` | `() => void` | Clear state (and any persisted snapshot) | +| `resumeSnapshot` | `GenerationResumeSnapshot \| undefined` | Last run's lightweight record (see below) | +| `resumeState` | `GenerationResumeState \| null` | Identity of the run WHILE it streams; null after | Hooks also accept `persistence` (a browser storage adapter such as `localStoragePersistence()`) plus a stable `id`: the client then writes the diff --git a/testing/e2e/src/routes/generation-persistence.tsx b/testing/e2e/src/routes/generation-persistence.tsx index f9f22f40f..0de9147e8 100644 --- a/testing/e2e/src/routes/generation-persistence.tsx +++ b/testing/e2e/src/routes/generation-persistence.tsx @@ -42,9 +42,7 @@ function GenerationPersistencePage() { @@ -69,7 +67,8 @@ function GenerationPersistencePage() { data-testid="generated-image" alt="generated" src={ - img.url ?? (img.b64Json ? `data:image/png;base64,${img.b64Json}` : '') + img.url ?? + (img.b64Json ? `data:image/png;base64,${img.b64Json}` : '') } /> ))} From 65269eacab98147811906749d0ff5f27b61e74db Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:24:37 +1000 Subject: [PATCH 15/66] refactor(ai-react): thread TInput through UseGenerationReturn, drop generate casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UseGenerationReturn gains a defaulted second generic (TInput extends Record = Record) so generate is typed (input: TInput) => Promise. 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 references keep compiling via the default. --- packages/ai-react/src/use-generate-audio.ts | 7 +------ packages/ai-react/src/use-generate-image.ts | 7 +------ packages/ai-react/src/use-generate-speech.ts | 7 +------ packages/ai-react/src/use-generation.ts | 15 +++++++++++---- packages/ai-react/src/use-summarize.ts | 7 +------ packages/ai-react/src/use-transcription.ts | 7 +------ 6 files changed, 16 insertions(+), 34 deletions(-) diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index 5d826f073..f1e0df0a6 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -132,10 +132,5 @@ export function useGenerateAudio( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: AudioGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index cc54606bf..c1619858a 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -134,10 +134,5 @@ export function useGenerateImage( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: ImageGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index 47c3f27be..3442f8c26 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -128,10 +128,5 @@ export function useGenerateSpeech( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: SpeechGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index e192f670d..7c9222871 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -60,10 +60,14 @@ export interface UseGenerationOptions { * Return type for the useGeneration hook. * * @template TOutput - The output type (after optional transform) + * @template TInput - The input type accepted by `generate` (defaults to any object) */ -export interface UseGenerationReturn { +export interface UseGenerationReturn< + TOutput, + TInput extends Record = Record, +> { /** Trigger a generation request */ - generate: (input: Record) => Promise + generate: (input: TInput) => Promise /** The generation result, or null if not yet generated */ result: TOutput | null /** Whether a generation is currently in progress */ @@ -119,7 +123,10 @@ export function useGeneration< options: Omit, 'onResult'> & { onResult?: (result: TResult) => TTransformed }, -): UseGenerationReturn> { +): UseGenerationReturn< + InferGenerationOutputFromReturn, + TInput +> { type TOutput = InferGenerationOutputFromReturn const hookId = useId() const clientId = options.id || hookId @@ -244,7 +251,7 @@ export function useGeneration< }, [client]) return { - generate: generate as (input: Record) => Promise, + generate, result, isLoading, error, diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index a73688a82..7072601e3 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -131,10 +131,5 @@ export function useSummarize( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: SummarizeGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index 0ba2732f0..3c830f12f 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -133,10 +133,5 @@ export function useTranscription( TTransformed >({ ...options, devtools }) - return { - ...generation, - generate: generation.generate as ( - input: TranscriptionGenerateInput, - ) => Promise, - } + return generation } From bdb2e8921267c41ed104dfaae41e74eac20bb4e2 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:31:48 +1000 Subject: [PATCH 16/66] refactor: thread TInput through generation return types in solid/vue/svelte/angular Same fix as fbc3dc331 for the remaining four frameworks: the base return interface (UseGenerationReturn / CreateGenerationReturn / InjectGenerationResult) gains a defaulted second generic (TInput extends Record = Record) so generate is typed (input: TInput) => Promise. 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. --- .../ai-angular/src/inject-generate-audio.ts | 7 +------ .../ai-angular/src/inject-generate-image.ts | 7 +------ .../ai-angular/src/inject-generate-speech.ts | 7 +------ packages/ai-angular/src/inject-generation.ts | 20 +++++++++++++------ packages/ai-angular/src/inject-summarize.ts | 7 +------ .../ai-angular/src/inject-transcription.ts | 7 +------ packages/ai-solid/src/use-generate-audio.ts | 7 +------ packages/ai-solid/src/use-generate-image.ts | 7 +------ packages/ai-solid/src/use-generate-speech.ts | 7 +------ packages/ai-solid/src/use-generation.ts | 15 ++++++++++---- packages/ai-solid/src/use-summarize.ts | 7 +------ packages/ai-solid/src/use-transcription.ts | 7 +------ .../src/create-generate-audio.svelte.ts | 2 +- .../src/create-generate-image.svelte.ts | 2 +- .../src/create-generate-speech.svelte.ts | 2 +- .../ai-svelte/src/create-generation.svelte.ts | 13 ++++++++---- .../ai-svelte/src/create-summarize.svelte.ts | 2 +- .../src/create-transcription.svelte.ts | 4 +--- packages/ai-vue/src/use-generate-audio.ts | 7 +------ packages/ai-vue/src/use-generate-image.ts | 7 +------ packages/ai-vue/src/use-generate-speech.ts | 7 +------ packages/ai-vue/src/use-generation.ts | 15 ++++++++++---- packages/ai-vue/src/use-summarize.ts | 7 +------ packages/ai-vue/src/use-transcription.ts | 7 +------ 24 files changed, 65 insertions(+), 115 deletions(-) diff --git a/packages/ai-angular/src/inject-generate-audio.ts b/packages/ai-angular/src/inject-generate-audio.ts index fbb57607c..e8cfe19c4 100644 --- a/packages/ai-angular/src/inject-generate-audio.ts +++ b/packages/ai-angular/src/inject-generate-audio.ts @@ -125,10 +125,5 @@ export function injectGenerateAudio( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: AudioGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-angular/src/inject-generate-image.ts b/packages/ai-angular/src/inject-generate-image.ts index 16b08e8e6..e54d66006 100644 --- a/packages/ai-angular/src/inject-generate-image.ts +++ b/packages/ai-angular/src/inject-generate-image.ts @@ -49,10 +49,5 @@ export function injectGenerateImage( ...options, devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: ImageGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-angular/src/inject-generate-speech.ts b/packages/ai-angular/src/inject-generate-speech.ts index d78b804ec..7c03b8221 100644 --- a/packages/ai-angular/src/inject-generate-speech.ts +++ b/packages/ai-angular/src/inject-generate-speech.ts @@ -50,10 +50,5 @@ export function injectGenerateSpeech( ...options, devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: SpeechGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index 75e6381fd..afa736d62 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -60,9 +60,18 @@ export interface InjectGenerationOptions { onChunk?: (chunk: StreamChunk) => void } -export interface InjectGenerationResult { +/** + * Return type for the injectGeneration function. + * + * @template TOutput - The output type (after optional transform) + * @template TInput - The input type accepted by `generate` (defaults to any object) + */ +export interface InjectGenerationResult< + TOutput, + TInput extends Record = Record, +> { /** Trigger a generation request */ - generate: (input: Record) => Promise + generate: (input: TInput) => Promise /** The generation result, or null if not yet generated */ result: Signal /** Whether a generation is currently in progress */ @@ -100,7 +109,8 @@ export function injectGeneration< onResult?: (result: TResult) => TTransformed }, ): InjectGenerationResult< - InferGenerationOutputFromReturn + InferGenerationOutputFromReturn, + TInput > { assertInInjectionContext(injectGeneration) @@ -228,9 +238,7 @@ export function injectGeneration< }) return { - generate: ((input: TInput) => client.generate(input)) as ( - input: Record, - ) => Promise, + generate: (input: TInput) => client.generate(input), result: result.asReadonly(), isLoading: isLoading.asReadonly(), error: error.asReadonly(), diff --git a/packages/ai-angular/src/inject-summarize.ts b/packages/ai-angular/src/inject-summarize.ts index db6b1c6e8..0bb1e7e86 100644 --- a/packages/ai-angular/src/inject-summarize.ts +++ b/packages/ai-angular/src/inject-summarize.ts @@ -49,10 +49,5 @@ export function injectSummarize( ...options, devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: SummarizeGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-angular/src/inject-transcription.ts b/packages/ai-angular/src/inject-transcription.ts index b7bc832af..561ce0008 100644 --- a/packages/ai-angular/src/inject-transcription.ts +++ b/packages/ai-angular/src/inject-transcription.ts @@ -53,10 +53,5 @@ export function injectTranscription( ...options, devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: TranscriptionGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-solid/src/use-generate-audio.ts b/packages/ai-solid/src/use-generate-audio.ts index fdca21fac..6130d1711 100644 --- a/packages/ai-solid/src/use-generate-audio.ts +++ b/packages/ai-solid/src/use-generate-audio.ts @@ -117,10 +117,5 @@ export function useGenerateAudio( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: AudioGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-solid/src/use-generate-image.ts b/packages/ai-solid/src/use-generate-image.ts index 5a85ba6f3..87f1c9c3c 100644 --- a/packages/ai-solid/src/use-generate-image.ts +++ b/packages/ai-solid/src/use-generate-image.ts @@ -125,10 +125,5 @@ export function useGenerateImage( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: ImageGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-solid/src/use-generate-speech.ts b/packages/ai-solid/src/use-generate-speech.ts index 96fd5a5cc..e8cff6113 100644 --- a/packages/ai-solid/src/use-generate-speech.ts +++ b/packages/ai-solid/src/use-generate-speech.ts @@ -118,10 +118,5 @@ export function useGenerateSpeech( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: SpeechGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index 3bf90d5a3..f39dd62b8 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -68,10 +68,14 @@ export interface UseGenerationOptions { * Return type for the useGeneration hook. * * @template TOutput - The output type (possibly transformed from the raw result) + * @template TInput - The input type accepted by `generate` (defaults to any object) */ -export interface UseGenerationReturn { +export interface UseGenerationReturn< + TOutput, + TInput extends Record = Record, +> { /** Trigger a generation request */ - generate: (input: Record) => Promise + generate: (input: TInput) => Promise /** The generation result, or null if not yet generated */ result: Accessor /** Whether a generation is currently in progress */ @@ -128,7 +132,10 @@ export function useGeneration< options: Omit, 'onResult'> & { onResult?: (result: TResult) => TTransformed }, -): UseGenerationReturn> { +): UseGenerationReturn< + InferGenerationOutputFromReturn, + TInput +> { type TOutput = InferGenerationOutputFromReturn const hookId = createUniqueId() const clientId = options.id || hookId @@ -250,7 +257,7 @@ export function useGeneration< } return { - generate: generate as (input: Record) => Promise, + generate, result, isLoading, error, diff --git a/packages/ai-solid/src/use-summarize.ts b/packages/ai-solid/src/use-summarize.ts index 804d7cd6d..01f992d4c 100644 --- a/packages/ai-solid/src/use-summarize.ts +++ b/packages/ai-solid/src/use-summarize.ts @@ -123,10 +123,5 @@ export function useSummarize( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: SummarizeGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-solid/src/use-transcription.ts b/packages/ai-solid/src/use-transcription.ts index fadb5aabf..e67476d4a 100644 --- a/packages/ai-solid/src/use-transcription.ts +++ b/packages/ai-solid/src/use-transcription.ts @@ -129,10 +129,5 @@ export function useTranscription( TTransformed >({ ...options, devtools }) - return { - ...generation, - generate: generation.generate as ( - input: TranscriptionGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-svelte/src/create-generate-audio.svelte.ts b/packages/ai-svelte/src/create-generate-audio.svelte.ts index 87ded3f2a..12191c14a 100644 --- a/packages/ai-svelte/src/create-generate-audio.svelte.ts +++ b/packages/ai-svelte/src/create-generate-audio.svelte.ts @@ -131,7 +131,7 @@ export function createGenerateAudio( get status() { return gen.status }, - generate: gen.generate as (input: AudioGenerateInput) => Promise, + generate: gen.generate, stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, diff --git a/packages/ai-svelte/src/create-generate-image.svelte.ts b/packages/ai-svelte/src/create-generate-image.svelte.ts index e42b70d55..cd8310924 100644 --- a/packages/ai-svelte/src/create-generate-image.svelte.ts +++ b/packages/ai-svelte/src/create-generate-image.svelte.ts @@ -140,7 +140,7 @@ export function createGenerateImage( get status() { return gen.status }, - generate: gen.generate as (input: ImageGenerateInput) => Promise, + generate: gen.generate, stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, diff --git a/packages/ai-svelte/src/create-generate-speech.svelte.ts b/packages/ai-svelte/src/create-generate-speech.svelte.ts index 90daf58e4..4f14cab56 100644 --- a/packages/ai-svelte/src/create-generate-speech.svelte.ts +++ b/packages/ai-svelte/src/create-generate-speech.svelte.ts @@ -126,7 +126,7 @@ export function createGenerateSpeech( get status() { return gen.status }, - generate: gen.generate as (input: SpeechGenerateInput) => Promise, + generate: gen.generate, stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index 4fe1a6da6..2863dd055 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -59,8 +59,12 @@ export interface CreateGenerationOptions { * Return type for the createGeneration function. * * @template TOutput - The output type (after optional transform) + * @template TInput - The input type accepted by `generate` (defaults to any object) */ -export interface CreateGenerationReturn { +export interface CreateGenerationReturn< + TOutput, + TInput extends Record = Record, +> { /** The generation result, or null if not yet generated */ readonly result: TOutput | null /** Whether a generation is currently in progress */ @@ -70,7 +74,7 @@ export interface CreateGenerationReturn { /** Current state of the generation client */ readonly status: GenerationClientState /** Trigger a generation request */ - generate: (input: Record) => Promise + generate: (input: TInput) => Promise /** Abort the current generation */ stop: () => void /** Clear result, error, and return to idle */ @@ -135,7 +139,8 @@ export function createGeneration< onResult?: (result: TResult) => TTransformed }, ): CreateGenerationReturn< - InferGenerationOutputFromReturn + InferGenerationOutputFromReturn, + TInput > { type TOutput = InferGenerationOutputFromReturn const clientId = @@ -279,7 +284,7 @@ export function createGeneration< get status() { return status }, - generate: generate as (input: Record) => Promise, + generate, stop, reset, dispose, diff --git a/packages/ai-svelte/src/create-summarize.svelte.ts b/packages/ai-svelte/src/create-summarize.svelte.ts index 5d582de21..a25f64d54 100644 --- a/packages/ai-svelte/src/create-summarize.svelte.ts +++ b/packages/ai-svelte/src/create-summarize.svelte.ts @@ -135,7 +135,7 @@ export function createSummarize( get status() { return gen.status }, - generate: gen.generate as (input: SummarizeGenerateInput) => Promise, + generate: gen.generate, stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, diff --git a/packages/ai-svelte/src/create-transcription.svelte.ts b/packages/ai-svelte/src/create-transcription.svelte.ts index f6a7fdfae..1e1bd35b3 100644 --- a/packages/ai-svelte/src/create-transcription.svelte.ts +++ b/packages/ai-svelte/src/create-transcription.svelte.ts @@ -144,9 +144,7 @@ export function createTranscription( get status() { return gen.status }, - generate: gen.generate as ( - input: TranscriptionGenerateInput, - ) => Promise, + generate: gen.generate, stop: gen.stop, reset: gen.reset, updateBody: gen.updateBody, diff --git a/packages/ai-vue/src/use-generate-audio.ts b/packages/ai-vue/src/use-generate-audio.ts index 48fb347d6..5655daa6a 100644 --- a/packages/ai-vue/src/use-generate-audio.ts +++ b/packages/ai-vue/src/use-generate-audio.ts @@ -117,10 +117,5 @@ export function useGenerateAudio( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: AudioGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-vue/src/use-generate-image.ts b/packages/ai-vue/src/use-generate-image.ts index f40c88767..61684a503 100644 --- a/packages/ai-vue/src/use-generate-image.ts +++ b/packages/ai-vue/src/use-generate-image.ts @@ -127,10 +127,5 @@ export function useGenerateImage( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: ImageGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-vue/src/use-generate-speech.ts b/packages/ai-vue/src/use-generate-speech.ts index 574d58517..210c84592 100644 --- a/packages/ai-vue/src/use-generate-speech.ts +++ b/packages/ai-vue/src/use-generate-speech.ts @@ -120,10 +120,5 @@ export function useGenerateSpeech( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: SpeechGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index 8338eff5d..70c1fe9be 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -68,10 +68,14 @@ export interface UseGenerationOptions { * Return type for the useGeneration hook. * * @template TOutput - The output type (after optional transform) + * @template TInput - The input type accepted by `generate` (defaults to any object) */ -export interface UseGenerationReturn { +export interface UseGenerationReturn< + TOutput, + TInput extends Record = Record, +> { /** Trigger a generation request */ - generate: (input: Record) => Promise + generate: (input: TInput) => Promise /** The generation result, or null if not yet generated */ result: DeepReadonly> /** Whether a generation is currently in progress */ @@ -130,7 +134,10 @@ export function useGeneration< options: Omit, 'onResult'> & { onResult?: (result: TResult) => TTransformed }, -): UseGenerationReturn> { +): UseGenerationReturn< + InferGenerationOutputFromReturn, + TInput +> { type TOutput = InferGenerationOutputFromReturn const hookId = useId() const clientId = options.id || hookId @@ -272,7 +279,7 @@ export function useGeneration< } return { - generate: generate as (input: Record) => Promise, + generate, // `readonly()` distributes `DeepReadonly`/`UnwrapNestedRefs` over the // `TOutput` conditional, which TS can't prove equal to the declared // `DeepReadonly>` while `TTransformed` is free. diff --git a/packages/ai-vue/src/use-summarize.ts b/packages/ai-vue/src/use-summarize.ts index e49a5a1f2..672ccbfc4 100644 --- a/packages/ai-vue/src/use-summarize.ts +++ b/packages/ai-vue/src/use-summarize.ts @@ -123,10 +123,5 @@ export function useSummarize( devtools, }) - return { - ...generation, - generate: generation.generate as ( - input: SummarizeGenerateInput, - ) => Promise, - } + return generation } diff --git a/packages/ai-vue/src/use-transcription.ts b/packages/ai-vue/src/use-transcription.ts index 69410e039..b375a202b 100644 --- a/packages/ai-vue/src/use-transcription.ts +++ b/packages/ai-vue/src/use-transcription.ts @@ -128,10 +128,5 @@ export function useTranscription( TTransformed >({ ...options, devtools }) - return { - ...generation, - generate: generation.generate as ( - input: TranscriptionGenerateInput, - ) => Promise, - } + return generation } From d1a56aa39178d65caf638d090778f57f951132c4 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:33 +1000 Subject: [PATCH 17/66] refactor(persistence): restore typed storage-adapter defaults (drop TValue = any) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(). Doc, example, and e2e call sites updated; runtime behavior unchanged. --- .changeset/generation-persistence.md | 2 +- docs/persistence/generation-persistence.md | 7 +++- .../src/routes/generations.image.tsx | 7 ++-- packages/ai-client/src/storage-adapters.ts | 34 +++++++++---------- .../e2e/src/routes/generation-persistence.tsx | 3 +- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index ae8d6f29a..0b1014a4d 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -13,4 +13,4 @@ Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `u As a run streams, the client builds a `GenerationResumeSnapshot` — run identity, status, errors, and result metadata, but **never** the generated media bytes — and writes it to the adapter under the key `generation:`, skipping writes with no material change. On construction the client reads the snapshot back (validated with the new `parseGenerationResumeSnapshot`) unless an explicit `initialResumeSnapshot` seed is provided, so the last run's outcome survives a reload. `stop()` and transport-level failures record terminal snapshots, and `reset()` clears the persisted record. The snapshot never exposes a `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called. -The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too. This pairs with the existing `withGenerationPersistence` server middleware, which records run status in the shared `RunStore`. +The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too — inferred contextually when written inline; a standalone generation store takes an explicit `localStoragePersistence()` type argument. This pairs with the existing `withGenerationPersistence` server middleware, which records run status in the shared `RunStore`. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index d3199a376..f41c0cfc4 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -95,8 +95,13 @@ the component mounts: ```tsx import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' +import type { GenerationResumeSnapshot } from '@tanstack/ai-react' -const snapshots = localStoragePersistence({ keyPrefix: 'my-app:' }) +// The explicit type argument is only needed for a standalone store like this +// one; written inline in the hook options, the value type is inferred. +const snapshots = localStoragePersistence({ + keyPrefix: 'my-app:', +}) export function HeroImageGenerator() { const image = useGenerateImage({ diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index ad5d8c582..7bbff2401 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -1,7 +1,10 @@ import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useGenerateImage } from '@tanstack/ai-react' -import type { UseGenerateImageReturn } from '@tanstack/ai-react' +import type { + GenerationResumeSnapshot, + UseGenerateImageReturn, +} from '@tanstack/ai-react' import { fetchServerSentEvents, localStoragePersistence, @@ -14,7 +17,7 @@ import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' // — never the generated image bytes. The client namespaces its record under // `generation:`, and reads it back on mount so the last run's outcome // survives a full page reload. -const imageSnapshots = localStoragePersistence({ +const imageSnapshots = localStoragePersistence({ keyPrefix: 'example:', }) diff --git a/packages/ai-client/src/storage-adapters.ts b/packages/ai-client/src/storage-adapters.ts index 327350921..3c402704e 100644 --- a/packages/ai-client/src/storage-adapters.ts +++ b/packages/ai-client/src/storage-adapters.ts @@ -1,4 +1,4 @@ -import type { ChatStorageAdapter } from './types' +import type { ChatPersistedState, ChatStorageAdapter } from './types' export interface WebStoragePersistenceOptions { keyPrefix?: string @@ -88,15 +88,14 @@ function createWebStoragePersistence( * adapter can be constructed safely on the server. * * The `serialize` / `deserialize` codec defaults to `JSON.stringify` / - * `JSON.parse`, so the common case needs no codec. `TValue` is value-agnostic - * by default, so `localStoragePersistence()` drops straight into any - * `persistence` option — chat or generation — with no type argument; the option - * you pass it to constrains the stored value. Pass a codec only for values JSON - * can't round-trip losslessly, and a type argument to lock the store's value - * type at the call site. + * `JSON.parse`, so the common case needs no codec. `TValue` defaults to the + * chat persisted-state shape; passed **inline** to any `persistence` option + * (chat or generation) the value type is inferred contextually, so no type + * argument is needed either way. Only a *standalone* store built for + * generations needs the explicit argument: + * `localStoragePersistence()`. */ -// oxlint-disable-next-line typescript/no-explicit-any -- value-agnostic default; the consuming `persistence` option constrains the value type -export function localStoragePersistence( +export function localStoragePersistence( options: WebStoragePersistenceOptions = {}, ): ChatStorageAdapter { return createWebStoragePersistence('localStorage', options) @@ -105,12 +104,12 @@ export function localStoragePersistence( /** * A `ChatStorageAdapter` backed by `window.sessionStorage` (scoped to the tab * and cleared when it closes). Identical to {@link localStoragePersistence} in - * every other respect: value-agnostic default `TValue`, `tanstack-ai:` - * default `keyPrefix`, lazy per-operation {@link StorageUnavailableError} on - * SSR, and a JSON codec that defaults to `JSON.stringify` / `JSON.parse`. + * every other respect: chat persisted-state default `TValue` (inferred + * contextually when passed inline), `tanstack-ai:` default `keyPrefix`, lazy + * per-operation {@link StorageUnavailableError} on SSR, and a JSON codec that + * defaults to `JSON.stringify` / `JSON.parse`. */ -// oxlint-disable-next-line typescript/no-explicit-any -- value-agnostic default; the consuming `persistence` option constrains the value type -export function sessionStoragePersistence( +export function sessionStoragePersistence( options: WebStoragePersistenceOptions = {}, ): ChatStorageAdapter { return createWebStoragePersistence('sessionStorage', options) @@ -125,10 +124,11 @@ export function sessionStoragePersistence( * * No serialize/deserialize codec is needed or accepted — values are stored via * IndexedDB's native structured clone, so `Date`, `Map`, `ArrayBuffer`, etc. - * round-trip without a JSON step. `TValue` is value-agnostic by default. + * round-trip without a JSON step. `TValue` defaults to the chat + * persisted-state shape and is inferred contextually when passed inline; a + * standalone store for generations takes the explicit argument. */ -// oxlint-disable-next-line typescript/no-explicit-any -- value-agnostic default; the consuming `persistence` option constrains the value type -export function indexedDBPersistence( +export function indexedDBPersistence( options: IndexedDBPersistenceOptions = {}, ): ChatStorageAdapter { const databaseName = options.databaseName ?? 'tanstack-ai' diff --git a/testing/e2e/src/routes/generation-persistence.tsx b/testing/e2e/src/routes/generation-persistence.tsx index 0de9147e8..c3ac54e94 100644 --- a/testing/e2e/src/routes/generation-persistence.tsx +++ b/testing/e2e/src/routes/generation-persistence.tsx @@ -5,6 +5,7 @@ import { localStoragePersistence, useGenerateImage, } from '@tanstack/ai-react' +import type { GenerationResumeSnapshot } from '@tanstack/ai-react' /** * Browser-refresh persistence harness for generation hooks (client half). @@ -16,7 +17,7 @@ import { * provider-free endpoint is `/api/generation-persistence`. */ -const snapshots = localStoragePersistence() +const snapshots = localStoragePersistence() const connection = fetchServerSentEvents('/api/generation-persistence') export const Route = createFileRoute('/generation-persistence')({ From a9c4c19855f77ef5fe35f2632eb8cd19537f8657 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 28 Jul 2026 11:32:33 +0200 Subject: [PATCH 18/66] 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//), 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. --- .changeset/generation-persistence.md | 10 +- docs/persistence/generation-persistence.md | 106 +++- packages/ai-event-client/src/index.ts | 36 ++ packages/ai-persistence/package.json | 3 + packages/ai-persistence/src/index.ts | 30 +- packages/ai-persistence/src/memory.ts | 238 +++++++- packages/ai-persistence/src/middleware.ts | 561 +++++++++++++++++- packages/ai-persistence/src/retrieve.ts | 45 ++ packages/ai-persistence/src/types.ts | 144 ++++- .../tests/generation-artifacts.test.ts | 496 ++++++++++++++++ packages/ai-persistence/tests/memory.test.ts | 2 + .../tests/persistence-types.test-d.ts | 15 +- packages/ai-utils/src/base64.ts | 7 + packages/ai-utils/src/index.ts | 6 +- .../ai/src/activities/generateAudio/index.ts | 18 +- .../ai/src/activities/generateImage/index.ts | 18 +- .../ai/src/activities/generateSpeech/index.ts | 23 +- .../activities/generateTranscription/index.ts | 23 +- .../ai/src/activities/middleware/index.ts | 2 + packages/ai/src/activities/middleware/run.ts | 31 + .../ai/src/activities/middleware/types.ts | 32 + .../activities/stream-generation-result.ts | 32 +- packages/ai/src/index.ts | 2 + pnpm-lock.yaml | 4 + 24 files changed, 1825 insertions(+), 59 deletions(-) create mode 100644 packages/ai-persistence/src/retrieve.ts create mode 100644 packages/ai-persistence/tests/generation-artifacts.test.ts diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index 0b1014a4d..9413cf39f 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -1,5 +1,9 @@ --- +'@tanstack/ai': minor +'@tanstack/ai-utils': minor +'@tanstack/ai-persistence': minor '@tanstack/ai-client': minor +'@tanstack/ai-event-client': minor '@tanstack/ai-react': minor '@tanstack/ai-solid': minor '@tanstack/ai-vue': minor @@ -7,9 +11,13 @@ '@tanstack/ai-angular': minor --- +Add generation persistence: a lightweight client resume snapshot plus optional durable storage of the generated media bytes. + +**Media byte storage (server).** 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//`), records an `ArtifactRecord`, attaches `PersistedArtifactRef`s to the result, and emits a `generation:artifacts` event so the client records them. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options and run their result through middleware result transforms. `@tanstack/ai-utils` adds `base64ToUint8Array`. + Add client-side generation persistence: a lightweight resume snapshot for media generation activities. -Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence` storage adapter and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus `pendingArtifacts` / `resultArtifacts`, which stay empty until the server-side artifact pipeline ships in a follow-up). +Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence` storage adapter and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus `pendingArtifacts` / `resultArtifacts`, populated from the server-side artifact pipeline above when an `artifacts` + `blobs` backend is configured). As a run streams, the client builds a `GenerationResumeSnapshot` — run identity, status, errors, and result metadata, but **never** the generated media bytes — and writes it to the adapter under the key `generation:`, skipping writes with no material change. On construction the client reads the snapshot back (validated with the new `parseGenerationResumeSnapshot`) unless an explicit `initialResumeSnapshot` seed is provided, so the last run's outcome survives a reload. `stop()` and transport-level failures record terminal snapshots, and `reset()` clears the persisted record. The snapshot never exposes a `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index f41c0cfc4..bbcb4f724 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -10,11 +10,13 @@ page or their connection drops mid-run, that run is easy to lose track of. Generation persistence keeps a small record of each run so your app can pick things back up. -It helps with two things: +It helps with three things: - **After a reload**, show what the last run was: whether it finished, what failed, and metadata like the result id or video job id. This is a small snapshot kept in browser storage and read back automatically on mount. +- **Keep the generated files.** On the server, save the generated bytes to your + own storage so they outlive the provider's expiring URLs. - **While a run is still streaming**, let a dropped connection re-attach to it instead of starting over. This reuses the same resumable streams the chat client uses. @@ -22,12 +24,14 @@ It helps with two things: ## When to use it Use it when a run is long enough that a reload or a dropped connection actually -matters: video, batch images, long audio, transcription of a big file. For a -quick one-shot image you show and forget, you can skip it. +matters, or when you need to keep the output: video, batch images, long audio, +transcription of a big file. For a quick one-shot image you show and forget, you +can skip it. -It never stores the generated bytes. Only the run's identity, status, errors, -and result metadata (like the result id, model, or video job id) are saved. See -[What it does not store](#what-it-does-not-store). +The browser snapshot never holds the generated bytes, only run identity, status, +errors, and result metadata (like the result id, model, or video job id). +Storing the bytes themselves is a server-side opt-in, shown in +[Store the generated bytes](#store-the-generated-bytes). ## Create the server endpoint @@ -86,6 +90,83 @@ each run in it, keyed by the request id it generates for the run. In production, swap `memoryStream` for `durableStream` from `@tanstack/ai-durable-stream`, where requests span processes. +## Store the generated bytes + +Provider URLs for generated media expire. To keep the output, give your +persistence backend an `artifacts` store (metadata) and a `blobs` store (the +bytes). When both are present, `withGenerationPersistence` writes each generated +file's bytes to the blob store, records an `ArtifactRecord`, and attaches +durable references to the result. `memoryPersistence()` ships both stores, so it +works out of the box; any backend that implements `ArtifactStore` and +`BlobStore` works the same way. + +The bytes land under the blob key `artifacts//`. To fetch a +generated file later (to render it, download it, or hand it to another request), +add a `GET` route that reads the artifact back with the `retrieveArtifact` / +`retrieveBlob` helpers and streams it from your own origin: + +```ts group=generation-bytes +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + memoryPersistence, + retrieveArtifact, + retrieveBlob, + withGenerationPersistence, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() + +export async function POST(request: Request) { + const { input } = await generationParamsFromRequest('image', request) + + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + const stream = generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + stream: true, + middleware: [withGenerationPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} + +// Serve a stored artifact's bytes by id. This is a plain file endpoint: it +// serves one stored file, it does not resume a run or rebuild a conversation. +export async function GET(request: Request) { + const artifactId = new URL(request.url).searchParams.get('id') + if (!artifactId) return new Response('missing id', { status: 400 }) + + const artifact = await retrieveArtifact(persistence, artifactId) + if (!artifact) return new Response('not found', { status: 404 }) + + const blob = await retrieveBlob(persistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + }, + }) +} +``` + +`memoryPersistence` keeps everything in process memory, which is right for +development and tests; point `artifacts` / `blobs` at a durable backend for +production. Control what gets captured with `withGenerationPersistence`'s +`extractArtifacts` (return your own descriptors) and `nameArtifact` (name each +file) options. On the client, the observed references show up on the generation +hook as `resultArtifacts` (and `pendingArtifacts` while streaming), so the UI +can link straight to this serve route. + ## Show the last run after a reload Pass a storage adapter as `persistence`, and give the hook a stable `id`. The @@ -174,10 +255,11 @@ reload as informational ("a run was still going when the page closed"). See [Resumable Streams](../resumable-streams/overview) for the durability contract, production adapters, and the one-time-side-effects note. -## What it does not store +## What the browser snapshot holds -The snapshot stores run identity and result metadata — ids, model, status, a -video `jobId`, an expiry timestamp — never the generated bytes, and it does not -carry the provider's media URL. If you need the media itself to survive a -reload, save it to your own storage from `onResult`; durable artifact storage -is coming as a follow-up. +The browser snapshot holds run identity and result metadata (ids, model, +status, a video `jobId`, an expiry timestamp), never the generated bytes, and it +does not carry the provider's media URL. To keep the media itself, persist the +bytes on the server with an `artifacts` + `blobs` backend (see +[Store the generated bytes](#store-the-generated-bytes)) and serve them from +your own origin. diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 062faabdd..7465168be 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -614,6 +614,8 @@ export interface SummarizeUsageEvent extends BaseEventContext { /** Emitted when an image request starts. */ export interface ImageRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string prompt: string @@ -630,6 +632,8 @@ export interface ImageRequestStartedEvent extends BaseEventContext { /** Emitted when an image request completes. */ export interface ImageRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string images: Array<{ url?: string; b64Json?: string }> @@ -639,6 +643,8 @@ export interface ImageRequestCompletedEvent extends BaseEventContext { /** Emitted when image usage metrics are available. */ export interface ImageUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } @@ -650,6 +656,8 @@ export interface ImageUsageEvent extends BaseEventContext { /** Emitted when a speech request starts. */ export interface SpeechRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string text: string @@ -661,6 +669,8 @@ export interface SpeechRequestStartedEvent extends BaseEventContext { /** Emitted when a speech request completes. */ export interface SpeechRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string audio: string @@ -673,6 +683,8 @@ export interface SpeechRequestCompletedEvent extends BaseEventContext { /** Emitted when speech usage metrics are available. */ export interface SpeechUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } @@ -684,6 +696,8 @@ export interface SpeechUsageEvent extends BaseEventContext { /** Emitted when a transcription request starts. */ export interface TranscriptionRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string language?: string @@ -694,6 +708,8 @@ export interface TranscriptionRequestStartedEvent extends BaseEventContext { /** Emitted when a transcription request completes. */ export interface TranscriptionRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string text: string @@ -704,6 +720,8 @@ export interface TranscriptionRequestCompletedEvent extends BaseEventContext { /** Emitted when transcription usage metrics are available. */ export interface TranscriptionUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } @@ -715,6 +733,8 @@ export interface TranscriptionUsageEvent extends BaseEventContext { /** Emitted when an audio generation request starts. */ export interface AudioRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string prompt: string @@ -743,6 +763,8 @@ export type AudioRequestCompletedAudio = /** Emitted when an audio generation request completes. */ export interface AudioRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string audio: AudioRequestCompletedAudio @@ -752,6 +774,8 @@ export interface AudioRequestCompletedEvent extends BaseEventContext { /** Emitted when an audio generation request fails. */ export interface AudioRequestErrorEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string error: { message: string; name?: string } @@ -761,6 +785,8 @@ export interface AudioRequestErrorEvent extends BaseEventContext { /** Emitted when a speech generation request fails. */ export interface SpeechRequestErrorEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string error: { message: string; name?: string } @@ -770,6 +796,8 @@ export interface SpeechRequestErrorEvent extends BaseEventContext { /** Emitted when a transcription request fails. */ export interface TranscriptionRequestErrorEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string error: { message: string; name?: string } @@ -779,6 +807,8 @@ export interface TranscriptionRequestErrorEvent extends BaseEventContext { /** Emitted when audio usage metrics are available. */ export interface AudioUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } @@ -790,6 +820,8 @@ export interface AudioUsageEvent extends BaseEventContext { /** Emitted when a video request starts. */ export interface VideoRequestStartedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string requestType: 'create' | 'status' | 'url' @@ -802,6 +834,8 @@ export interface VideoRequestStartedEvent extends BaseEventContext { /** Emitted when a video request completes. */ export interface VideoRequestCompletedEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string provider: string model: string requestType: 'create' | 'status' | 'url' @@ -816,6 +850,8 @@ export interface VideoRequestCompletedEvent extends BaseEventContext { /** Emitted when video usage metrics are available. */ export interface VideoUsageEvent extends BaseEventContext { requestId: string + threadId?: string + runId?: string model: string usage: TokenUsage } diff --git a/packages/ai-persistence/package.json b/packages/ai-persistence/package.json index f86e0563d..670a52c35 100644 --- a/packages/ai-persistence/package.json +++ b/packages/ai-persistence/package.json @@ -48,6 +48,9 @@ "test:types": "tsc", "test:oxlint": "oxlint src --type-aware" }, + "dependencies": { + "@tanstack/ai-utils": "workspace:*" + }, "peerDependencies": { "@tanstack/ai": "workspace:*", "vitest": "^4.1.10" diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index 9007aa83b..d86ea793a 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -23,6 +23,16 @@ export type { ChatTranscriptPersistence, ChatPersistence, ChatWithInterruptsPersistence, + // Generation artifact + blob store contracts + ArtifactRecord, + ArtifactStore, + BlobBody, + BlobRecord, + BlobObject, + BlobListPage, + BlobPutOptions, + BlobListOptions, + BlobStore, AIPersistence, AIPersistenceOverrides, ComposedAIPersistenceStores, @@ -33,14 +43,30 @@ export type { // AIPersistenceStores is intentionally NOT re-exported — use a named chat // shape or AIPersistence<{ messages: MessageStore, … }>. -// Middleware (state only — locks live in @tanstack/ai as withLocks) +// Core artifact wire types (re-exported for convenience) +export type { + PersistedArtifactActivity, + PersistedArtifactRef, + PersistedArtifactRole, +} from '@tanstack/ai' + +// Middleware (chat state — locks live in @tanstack/ai as withLocks) export { withPersistence, withGenerationPersistence } from './middleware' +export type { + WithPersistenceOptions, + GenerationArtifactDescriptor, + GenerationArtifactExtractionInput, + GenerationArtifactNameInput, +} from './middleware' // Server helper: rehydrate a thread's messages for a client load export { reconstructChat } from './reconstruct' export type { ReconstructChatOptions } from './reconstruct' -// Reference in-memory implementation (state stores only) +// Server helpers: retrieve a persisted generation artifact + its bytes +export { retrieveArtifact, retrieveBlob, artifactBlobKey } from './retrieve' + +// Reference in-memory implementation export { memoryPersistence } from './memory' // Interrupt controller diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts index 70f8368f1..e885837aa 100644 --- a/packages/ai-persistence/src/memory.ts +++ b/packages/ai-persistence/src/memory.ts @@ -1,7 +1,13 @@ import { defineAIPersistence } from './types' import type { ModelMessage } from '@tanstack/ai' import type { - ChatPersistence, + ArtifactRecord, + ArtifactStore, + BlobBody, + BlobListOptions, + BlobObject, + BlobRecord, + BlobStore, InterruptRecord, InterruptStore, MessageStore, @@ -169,20 +175,224 @@ class MemoryMetadataStore implements MetadataStore { } } +class MemoryArtifactStore implements ArtifactStore { + private readonly artifacts = new Map() + save(record: ArtifactRecord): Promise { + this.artifacts.set(record.artifactId, { ...record }) + return Promise.resolve() + } + get(artifactId: string): Promise { + return Promise.resolve(this.artifacts.get(artifactId) ?? null) + } + list(runId: string): Promise> { + return Promise.resolve( + [...this.artifacts.values()].filter((a) => a.runId === runId), + ) + } + delete(artifactId: string): Promise { + this.artifacts.delete(artifactId) + return Promise.resolve() + } + deleteForRun(runId: string): Promise { + for (const artifact of this.artifacts.values()) { + if (artifact.runId === runId) this.artifacts.delete(artifact.artifactId) + } + return Promise.resolve() + } +} + +interface MemoryBlobEntry { + record: BlobRecord + bytes: Uint8Array +} + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +function copyBytes(bytes: Uint8Array): Uint8Array { + return new Uint8Array(bytes) +} + +function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength) + new Uint8Array(buffer).set(bytes) + return buffer +} + +async function bytesFromStream( + stream: ReadableStream, +): Promise { + const reader = stream.getReader() + const chunks: Array = [] + let total = 0 + try { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(copyBytes(value)) + total += value.byteLength + } + } finally { + reader.releaseLock() + } + + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +async function bytesFromBlobBody(body: BlobBody): Promise { + if (typeof body === 'string') { + return textEncoder.encode(body) + } + if (body instanceof ArrayBuffer) { + return new Uint8Array(body.slice(0)) + } + if (ArrayBuffer.isView(body)) { + return copyBytes( + new Uint8Array(body.buffer, body.byteOffset, body.byteLength), + ) + } + if (typeof Blob !== 'undefined' && body instanceof Blob) { + return new Uint8Array(await body.arrayBuffer()) + } + if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) { + return bytesFromStream(body) + } + throw new TypeError('Unsupported blob body.') +} + +function blobRecordSnapshot(record: BlobRecord): BlobRecord { + return { + ...record, + ...(record.customMetadata + ? { customMetadata: { ...record.customMetadata } } + : {}), + } +} + +function blobObject(record: BlobRecord, bytes: Uint8Array): BlobObject { + const copied = copyBytes(bytes) + return { + ...blobRecordSnapshot(record), + body: new ReadableStream({ + start(controller) { + controller.enqueue(copyBytes(copied)) + controller.close() + }, + }), + arrayBuffer: () => Promise.resolve(bytesToArrayBuffer(copied)), + text: () => Promise.resolve(textDecoder.decode(copied)), + } +} + +class MemoryBlobStore implements BlobStore { + private readonly blobs = new Map() + private nextEtag = 1 + + async put( + key: string, + body: BlobBody, + options?: { + contentType?: string + customMetadata?: Record + }, + ): Promise { + const bytes = await bytesFromBlobBody(body) + const existing = this.blobs.get(key) + const now = Date.now() + const record: BlobRecord = { + key, + size: bytes.byteLength, + etag: String(this.nextEtag++), + contentType: + options?.contentType ?? + (typeof Blob !== 'undefined' && body instanceof Blob + ? body.type || undefined + : undefined), + customMetadata: options?.customMetadata + ? { ...options.customMetadata } + : undefined, + createdAt: existing?.record.createdAt ?? now, + updatedAt: now, + } + this.blobs.set(key, { record, bytes: copyBytes(bytes) }) + return blobRecordSnapshot(record) + } + + get(key: string): Promise { + const entry = this.blobs.get(key) + return Promise.resolve(entry ? blobObject(entry.record, entry.bytes) : null) + } + + head(key: string): Promise { + const entry = this.blobs.get(key) + return Promise.resolve(entry ? blobRecordSnapshot(entry.record) : null) + } + + delete(key: string): Promise { + this.blobs.delete(key) + return Promise.resolve() + } + + list(options?: BlobListOptions): Promise<{ + objects: Array + cursor?: string + truncated?: boolean + }> { + const limit = options?.limit + if (limit === 0) { + return Promise.resolve({ objects: [], truncated: false }) + } + const keys = [...this.blobs.keys()] + .filter((key) => key.startsWith(options?.prefix ?? '')) + .filter((key) => options?.cursor === undefined || key > options.cursor) + .sort() + const pageKeys = limit === undefined ? keys : keys.slice(0, limit) + const objects = pageKeys.map((key) => { + const blob = this.blobs.get(key) + if (blob === undefined) { + throw new Error(`Missing blob for listed key: ${key}`) + } + return blobRecordSnapshot(blob.record) + }) + const truncated = limit !== undefined && keys.length > limit + return Promise.resolve({ + objects, + ...(truncated ? { cursor: pageKeys.at(-1), truncated } : {}), + }) + } +} + +interface MemoryPersistenceStores { + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore + artifacts: ArtifactStore + blobs: BlobStore +} + /** - * In-process reference backend for **chat** state stores. + * In-process reference backend for the full state + generation store set. * - * Returns {@link ChatPersistence} (messages + runs + interrupts + metadata). - * Locks are not included — use `InMemoryLockStore` + `withLocks` from - * `@tanstack/ai` when a test or single-process app needs coordination. + * Returns messages + runs + interrupts + metadata + artifacts + blobs. Locks + * are not included — use `InMemoryLockStore` + `withLocks` from `@tanstack/ai` + * when a test or single-process app needs coordination. */ -export function memoryPersistence(): ChatPersistence { - return defineAIPersistence({ - stores: { - messages: new MemoryMessageStore(), - runs: new MemoryRunStore(), - interrupts: new MemoryInterruptStore(), - metadata: new MemoryMetadataStore(), - }, - }) +export function memoryPersistence() { + const stores: MemoryPersistenceStores = { + messages: new MemoryMessageStore(), + runs: new MemoryRunStore(), + interrupts: new MemoryInterruptStore(), + metadata: new MemoryMetadataStore(), + artifacts: new MemoryArtifactStore(), + blobs: new MemoryBlobStore(), + } + return defineAIPersistence({ stores }) } diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index cd86cfab9..f1b5bfc5e 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -1,4 +1,5 @@ import { defineChatMiddleware } from '@tanstack/ai' +import { base64ToUint8Array } from '@tanstack/ai-utils' import { InterruptsCapability, PersistenceCapability, @@ -23,6 +24,9 @@ import type { GenerationMiddleware, GenerationMiddlewareContext, ModelMessage, + PersistedArtifactActivity, + PersistedArtifactRef, + PersistedArtifactRole, RunAgentResumeItem, StreamChunk, ToolApprovalResolution, @@ -31,10 +35,55 @@ import type { import type { AIPersistence, AIPersistenceStores, + ArtifactRecord, + BlobBody, ChatTranscriptStores, InterruptRecord, RunStore, } from './types' +import { artifactBlobKey } from './retrieve' + +export interface WithPersistenceOptions { + extractArtifacts?: ( + input: GenerationArtifactExtractionInput, + ) => + | Array + | Promise> + nameArtifact?: (input: GenerationArtifactNameInput) => string +} + +export interface GenerationArtifactDescriptor { + role: PersistedArtifactRole + path: string + mediaType?: PersistedArtifactRef['source']['mediaType'] + mimeType?: string + bytes?: BlobBody + url?: string + json?: unknown + name?: string + jobId?: string + expiresAt?: string | Date +} + +export interface GenerationArtifactExtractionInput { + activity: PersistedArtifactActivity + provider: string + model: string + threadId: string + runId: string + inputs: unknown + result: unknown +} + +export interface GenerationArtifactNameInput { + descriptor: GenerationArtifactDescriptor + activity: PersistedArtifactActivity + provider: string + model: string + threadId: string + runId: string + index: number +} interface RunStateEntry { merged: boolean @@ -259,18 +308,483 @@ function interruptPayload(interrupt: unknown): Record { : { value: interrupt } } +// --------------------------------------------------------------------------- +// Generation artifact extraction / persistence +// --------------------------------------------------------------------------- + +function isArtifactRef(value: unknown): value is PersistedArtifactRef { + const record = objectValue(value) + return !!record && typeof record.artifactId === 'string' +} + +function mediaActivity( + activity: GenerationMiddlewareContext['activity'], +): PersistedArtifactActivity | undefined { + return activity === 'image' || + activity === 'audio' || + activity === 'tts' || + activity === 'video' || + activity === 'transcription' + ? activity + : undefined +} + +function parseDataUrl( + value: string, +): { mimeType: string; bytes: Uint8Array } | undefined { + const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(value) + if (!match) return undefined + const mimeType = match[1] || 'application/octet-stream' + const payload = decodeURIComponent(match[3] ?? '') + return { + mimeType, + bytes: match[2] + ? base64ToUint8Array(payload) + : new TextEncoder().encode(payload), + } +} + +function extensionForMime(mimeType: string | undefined): string { + if (mimeType === undefined) return 'bin' + + switch (mimeType) { + case 'image/png': + return 'png' + case 'image/jpeg': + return 'jpg' + case 'audio/wav': + return 'wav' + case 'audio/mpeg': + return 'mp3' + case 'audio/mp3': + return 'mp3' + case 'video/mp4': + return 'mp4' + case 'application/json': + return 'json' + default: + return 'bin' + } +} + +function defaultArtifactName( + descriptor: GenerationArtifactDescriptor, + activity: PersistedArtifactActivity, + index: number, +): string { + const ext = extensionForMime(descriptor.mimeType) + return `${activity}-${descriptor.role}-${descriptor.mediaType ?? 'artifact'}-${index}.${ext}` +} + +function sourcePartDescriptors( + part: unknown, + role: PersistedArtifactRole, + path: string, +): Array { + const record = objectValue(part) + const type = stringField(record ?? {}, 'type') + const source = objectValue(record?.source) + if ( + !record || + !source || + (type !== 'image' && type !== 'audio' && type !== 'video') + ) { + return [] + } + const sourceType = stringField(source, 'type') + const mimeType = stringField(source, 'mimeType') ?? `${type}/mpeg` + if (sourceType === 'data') { + const value = stringField(source, 'value') + if (!value) return [] + return [ + { + role, + path, + mediaType: type, + mimeType, + bytes: base64ToUint8Array(value), + }, + ] + } + if (sourceType === 'url') { + const value = stringField(source, 'value') + if (!value) return [] + return [{ role, path, mediaType: type, mimeType, url: value }] + } + return [] +} + +function promptInputDescriptors( + inputs: unknown, +): Array { + const prompt = objectValue(inputs)?.prompt + if (!Array.isArray(prompt)) return [] + + const counts: Record = { image: 0, audio: 0, video: 0 } + const descriptors: Array = [] + for (const part of prompt) { + const type = stringField(objectValue(part) ?? {}, 'type') + if (type !== 'image' && type !== 'audio' && type !== 'video') continue + const index = counts[type] ?? 0 + counts[type] = index + 1 + descriptors.push( + ...sourcePartDescriptors(part, 'input', `prompt.${type}s.${index}`), + ) + } + return descriptors +} + +function generatedMediaDescriptor(args: { + role: PersistedArtifactRole + path: string + mediaType: 'image' | 'audio' | 'video' + mimeType: string + media: unknown + jobId?: string + expiresAt?: string | Date +}): GenerationArtifactDescriptor | undefined { + const media = objectValue(args.media) + if (!media) return undefined + const b64Json = stringField(media, 'b64Json') + if (b64Json) { + return { + role: args.role, + path: args.path, + mediaType: args.mediaType, + mimeType: stringField(media, 'contentType') ?? args.mimeType, + bytes: base64ToUint8Array(b64Json), + jobId: args.jobId, + expiresAt: args.expiresAt, + } + } + const url = stringField(media, 'url') + if (url) { + return { + role: args.role, + path: args.path, + mediaType: args.mediaType, + mimeType: stringField(media, 'contentType') ?? args.mimeType, + url, + jobId: args.jobId, + expiresAt: args.expiresAt, + } + } + return undefined +} + +function builtInArtifactDescriptors( + activity: PersistedArtifactActivity, + inputs: unknown, + result: unknown, +): Array { + const descriptors = promptInputDescriptors(inputs) + const output = objectValue(result) + if (!output) return descriptors + + if (activity === 'image' && Array.isArray(output.images)) { + output.images.forEach((image, index) => { + const descriptor = generatedMediaDescriptor({ + role: 'output', + path: `images.${index}`, + mediaType: 'image', + mimeType: 'image/png', + media: image, + }) + if (descriptor) descriptors.push(descriptor) + }) + } + + if (activity === 'audio') { + const descriptor = generatedMediaDescriptor({ + role: 'output', + path: 'audio', + mediaType: 'audio', + mimeType: 'audio/mpeg', + media: output.audio, + }) + if (descriptor) descriptors.push(descriptor) + } + + if (activity === 'tts') { + const audio = stringField(output, 'audio') + if (audio) { + const format = stringField(output, 'format') + descriptors.push({ + role: 'output', + path: 'audio', + mediaType: 'audio', + mimeType: + stringField(output, 'contentType') ?? + (format ? `audio/${format}` : 'audio/mpeg'), + bytes: base64ToUint8Array(audio), + }) + } + } + + if (activity === 'video' && typeof output.url === 'string') { + descriptors.push({ + role: 'output', + path: 'video', + mediaType: 'video', + mimeType: 'video/mp4', + url: output.url, + jobId: stringField(output, 'jobId'), + expiresAt: + output.expiresAt instanceof Date ? output.expiresAt : undefined, + }) + } + + if (activity === 'transcription') { + const audio = objectValue(inputs)?.audio + if (typeof audio === 'string') { + const data = parseDataUrl(audio) + descriptors.push({ + role: 'input', + path: 'audio', + mediaType: 'audio', + mimeType: data?.mimeType ?? 'audio/mpeg', + bytes: data?.bytes ?? base64ToUint8Array(audio), + }) + } else if (audio instanceof ArrayBuffer) { + descriptors.push({ + role: 'input', + path: 'audio', + mediaType: 'audio', + mimeType: 'audio/mpeg', + bytes: audio.slice(0), + }) + } else if (typeof Blob !== 'undefined' && audio instanceof Blob) { + descriptors.push({ + role: 'input', + path: 'audio', + mediaType: 'audio', + mimeType: audio.type || 'audio/mpeg', + bytes: audio, + }) + } + if (Array.isArray(output.segments) || Array.isArray(output.words)) { + descriptors.push({ + role: 'output', + path: 'transcription', + mediaType: 'json', + mimeType: 'application/json', + json: output, + }) + } + } + + return descriptors +} + +async function descriptorBody( + descriptor: GenerationArtifactDescriptor, +): Promise<{ + body: BlobBody + size: number + mimeType: string + externalUrl?: string +}> { + if (descriptor.json !== undefined) { + const body = JSON.stringify(descriptor.json) + return { + body, + size: new TextEncoder().encode(body).byteLength, + mimeType: descriptor.mimeType ?? 'application/json', + } + } + + if (descriptor.bytes !== undefined) { + const body = descriptor.bytes + let size: number + if (typeof body === 'string') { + size = new TextEncoder().encode(body).byteLength + } else if (body instanceof ArrayBuffer) { + size = body.byteLength + } else if (ArrayBuffer.isView(body)) { + size = body.byteLength + } else if (typeof Blob !== 'undefined' && body instanceof Blob) { + size = body.size + } else { + size = 0 + } + return { + body, + size, + mimeType: descriptor.mimeType ?? 'application/octet-stream', + } + } + + if (descriptor.url) { + const data = parseDataUrl(descriptor.url) + if (data) { + return { + body: data.bytes, + size: data.bytes.byteLength, + mimeType: descriptor.mimeType ?? data.mimeType, + } + } + const response = await fetch(descriptor.url) + if (!response.ok) { + throw new Error( + `Failed to persist artifact from ${descriptor.url}: HTTP ${response.status}`, + ) + } + const mimeType = + descriptor.mimeType ?? + response.headers.get('content-type') ?? + 'application/octet-stream' + // Stream the body straight into the blob store instead of buffering the + // whole artifact in memory. `size` is left 0 (unknown up front); the store + // records the actual byte length as it drains the stream. Fall back to + // buffering only when the response has no body to stream. + if (response.body) { + return { + body: response.body, + size: 0, + mimeType, + externalUrl: descriptor.url, + } + } + const body = await response.arrayBuffer() + return { + body, + size: body.byteLength, + mimeType, + externalUrl: descriptor.url, + } + } + + throw new Error( + `Artifact descriptor ${descriptor.path} has no bytes, url, or json.`, + ) +} + +async function persistGenerationArtifacts( + persistence: AIPersistence, + opts: WithPersistenceOptions | undefined, + ctx: GenerationMiddlewareContext, + result: unknown, +): Promise> { + const activity = mediaActivity(ctx.activity) + if (!activity) return [] + + const threadId = ctx.threadId ?? ctx.requestId + const runId = ctx.runId ?? ctx.requestId + const extractionInput: GenerationArtifactExtractionInput = { + activity, + provider: ctx.provider, + model: ctx.model, + threadId, + runId, + inputs: ctx.artifactInputs, + result, + } + const extracted = + opts?.extractArtifacts !== undefined + ? await opts.extractArtifacts(extractionInput) + : builtInArtifactDescriptors(activity, ctx.artifactInputs, result) + + if (extracted.length === 0) return [] + + const existingRefs = extracted.filter(isArtifactRef) + const descriptors = extracted.filter( + (item): item is GenerationArtifactDescriptor => !isArtifactRef(item), + ) + if (descriptors.length === 0) return existingRefs + + if (!persistence.stores.artifacts || !persistence.stores.blobs) { + throw new Error( + 'Generation artifact persistence requires stores.artifacts and stores.blobs.', + ) + } + + const refs: Array = [...existingRefs] + for (const [index, descriptor] of descriptors.entries()) { + const artifactId = ctx.createId('artifact') + const { body, size, mimeType, externalUrl } = + await descriptorBody(descriptor) + const key = artifactBlobKey({ runId, artifactId }) + const stored = await persistence.stores.blobs.put(key, body, { + contentType: mimeType, + customMetadata: { + runId, + threadId, + role: descriptor.role, + activity, + path: descriptor.path, + }, + }) + // For streamed downloads the descriptor size is unknown (0); the store + // reports the real byte length once it has drained the stream. + const resolvedSize = size || stored.size || 0 + const createdAtMs = Date.now() + const name = + opts?.nameArtifact?.({ + descriptor: { ...descriptor, mimeType }, + activity, + provider: ctx.provider, + model: ctx.model, + threadId, + runId, + index, + }) ?? + descriptor.name ?? + defaultArtifactName({ ...descriptor, mimeType }, activity, index) + const record: ArtifactRecord = { + artifactId, + runId, + threadId, + name, + mimeType, + size: resolvedSize, + externalUrl, + createdAt: createdAtMs, + } + await persistence.stores.artifacts.save(record) + refs.push({ + role: descriptor.role, + artifactId, + threadId, + runId, + name, + mimeType, + size: resolvedSize, + createdAt: new Date(createdAtMs).toISOString(), + ...(externalUrl ? { externalUrl } : {}), + source: { + activity, + path: descriptor.path, + provider: ctx.provider, + model: ctx.model, + mediaType: descriptor.mediaType, + jobId: descriptor.jobId, + expiresAt: + descriptor.expiresAt instanceof Date + ? descriptor.expiresAt.toISOString() + : descriptor.expiresAt, + }, + }) + } + + return refs +} + // --------------------------------------------------------------------------- // Shared store / feature plan // --------------------------------------------------------------------------- interface PersistencePlan { wantsInterrupts: boolean + wantsArtifactPersistence: boolean runs: AIPersistence['stores']['runs'] } function resolvePersistencePlan(persistence: AIPersistence): PersistencePlan { return { wantsInterrupts: persistence.stores.interrupts !== undefined, + wantsArtifactPersistence: + persistence.stores.artifacts !== undefined && + persistence.stores.blobs !== undefined, runs: persistence.stores.runs, } } @@ -310,13 +824,22 @@ type InvalidChatPersistence = ? StoreIsDefinitelyAbsent : false +/** + * Generation entrypoint invalid when `runs` is known-absent, or when exactly one + * of `artifacts` / `blobs` is present (artifact persistence needs both). + */ +type InvalidGenerationPersistence = + StoreIsDefinitelyAbsent extends true + ? true + : StoreIsDefinitelyPresent extends true + ? StoreIsDefinitelyAbsent + : StoreIsDefinitelyPresent extends true + ? StoreIsDefinitelyAbsent + : false + type ValidChatPersistence = InvalidChatPersistence extends true ? never : unknown -/** Generation entrypoint invalid when `runs` is known-absent. */ -type InvalidGenerationPersistence = - StoreIsDefinitelyAbsent extends true ? true : false - type ValidGenerationPersistence = InvalidGenerationPersistence extends true ? never : unknown @@ -622,8 +1145,9 @@ export function withPersistence( // --------------------------------------------------------------------------- /** - * Generation-only persistence middleware. Tracks run status (run records) for - * image, audio, TTS, video, and transcription activities. + * Generation-only persistence middleware. Tracks run status (run records) and, + * when `stores.artifacts` + `stores.blobs` are both provided, persists the + * generated media for image, audio, TTS, video, and transcription activities. * * Requires `stores.runs`. * @@ -641,12 +1165,16 @@ export function withPersistence( * optional `threadId` on a job is only a *link* to a chat when the product * needs it, never the job's primary identity. */ -export function withGenerationPersistence< - TStores extends AIPersistenceStores & { runs: RunStore }, ->( +export function withGenerationPersistence( persistence: AIPersistence & ValidGenerationPersistence, + opts?: WithPersistenceOptions, +): GenerationMiddleware +export function withGenerationPersistence( + persistence: AIPersistence, + opts?: WithPersistenceOptions, ): GenerationMiddleware { validateGenerationPersistenceStores(persistence) + const { wantsArtifactPersistence } = resolvePersistencePlan(persistence) const runStore = persistence.stores.runs if (!runStore) { // validateGenerationPersistenceStores already throws; this narrows for TypeScript. @@ -659,6 +1187,21 @@ export function withGenerationPersistence< async onStart(ctx: GenerationMiddlewareContext) { // STOPGAP ONLY — see function JSDoc. Do not copy this pattern. await createOrResumeRun(runStore, ctx.requestId, ctx.requestId) + if (!wantsArtifactPersistence) return + ctx.resultTransforms?.push(async (result) => { + const refs = await persistGenerationArtifacts( + persistence, + opts, + ctx, + result, + ) + if (refs.length === 0) return undefined + const existing = objectValue(result)?.artifacts + return { + ...(objectValue(result) ?? {}), + artifacts: [...(Array.isArray(existing) ? existing : []), ...refs], + } + }) }, async onFinish( diff --git a/packages/ai-persistence/src/retrieve.ts b/packages/ai-persistence/src/retrieve.ts new file mode 100644 index 000000000..0984e3ab8 --- /dev/null +++ b/packages/ai-persistence/src/retrieve.ts @@ -0,0 +1,45 @@ +import type { AIPersistence, ArtifactRecord, BlobObject } from './types' + +/** + * The blob-store key a generation artifact's bytes are stored under. + * `withGenerationPersistence` writes bytes to this key; `retrieveBlob` reads + * from it. Keep the two in lockstep by using this helper on both sides. + */ +export function artifactBlobKey( + ref: Pick, +): string { + return `artifacts/${ref.runId}/${ref.artifactId}` +} + +/** + * Look up a persisted generation artifact's metadata by id. Returns `null` when + * the persistence has no `artifacts` store or no record matches — so a serve + * handler can map that straight to a 404. + */ +export async function retrieveArtifact( + persistence: AIPersistence, + artifactId: string, +): Promise { + const record = await persistence.stores.artifacts?.get(artifactId) + return record ?? null +} + +/** + * Look up a persisted generation artifact's stored bytes. Pass an `artifactId` + * (resolved to its record first) or an already-loaded {@link ArtifactRecord} + * (no second metadata lookup). Returns `null` when the artifact, its record, or + * its blob is missing, or the stores are not configured. + */ +export async function retrieveBlob( + persistence: AIPersistence, + artifact: string | ArtifactRecord, +): Promise { + const record = + typeof artifact === 'string' + ? await retrieveArtifact(persistence, artifact) + : artifact + if (!record) return null + + const blob = await persistence.stores.blobs?.get(artifactBlobKey(record)) + return blob ?? null +} diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index 69309f82e..4f17782fe 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -38,9 +38,10 @@ export type { Scope } // // TIMESTAMP CONVENTION // -------------------- -// Store *records* (`RunRecord`, `InterruptRecord`) speak **epoch -// milliseconds** (`number`), the native unit for SQL/`BIGINT` columns and -// `Date.now()`. Wire/result references that leave the persistence layer speak +// Store *records* (`RunRecord`, `InterruptRecord`, `ArtifactRecord`, +// `BlobRecord`) speak **epoch milliseconds** (`number`), the native unit for +// SQL/`BIGINT` columns and `Date.now()`. Wire/result references that leave the +// persistence layer (e.g. core's `PersistedArtifactRef.createdAt`) speak // **ISO-8601 strings**. The middleware performs the number→ISO conversion at // the boundary; do not mix the two on a single field. @@ -290,6 +291,129 @@ export function defineMetadataStore(store: MetadataStore): MetadataStore { return store } +/** + * Metadata row describing a persisted artifact (generated media, tool output). + * + * The bytes themselves live in a {@link BlobStore}; this record holds the + * descriptive metadata and an optional `externalUrl` for reference-only + * backends. + * + * @property createdAt - Epoch ms. (Core's wire-facing `PersistedArtifactRef` + * exposes the same instant as an ISO string; see the timestamp convention.) + */ +export interface ArtifactRecord { + artifactId: string + runId: string + threadId: string + name: string + mimeType: string + size: number + externalUrl?: string + createdAt: number +} + +/** Durable store for artifact metadata records. */ +export interface ArtifactStore { + /** Insert or overwrite the artifact metadata record. */ + save: (record: ArtifactRecord) => Promise + /** Return the artifact for `artifactId`, or `null` if none exists. */ + get: (artifactId: string) => Promise + /** All artifacts for a run. Returns `[]` when the run has none. */ + list: (runId: string) => Promise> + /** OPTIONAL: delete a single artifact by id. */ + delete?: (artifactId: string) => Promise + /** OPTIONAL: delete every artifact belonging to `runId`. */ + deleteForRun?: (runId: string) => Promise +} + +/** + * Accepted body shapes for {@link BlobStore.put}. `ArrayBufferView` already + * covers `Uint8Array` and every other typed-array/`DataView`, so no separate + * `Uint8Array` member is needed. + */ +export type BlobBody = + | ReadableStream + | ArrayBuffer + | ArrayBufferView + | string + | Blob + +/** + * Metadata for a stored blob. + * + * @property size - Byte length, when known. + * @property createdAt - Epoch ms first written. + * @property updatedAt - Epoch ms last overwritten. + */ +export interface BlobRecord { + key: string + size?: number + etag?: string + contentType?: string + customMetadata?: Record + createdAt?: number + updatedAt?: number +} + +/** A stored blob's metadata plus lazy accessors for its bytes. */ +export interface BlobObject extends BlobRecord { + arrayBuffer: () => Promise + text: () => Promise + body?: ReadableStream +} + +/** + * One page of a {@link BlobStore.list} scan. + * + * @property cursor - Opaque continuation token; present only when `truncated`. + * @property truncated - `true` when more objects match beyond this page. + */ +export interface BlobListPage { + objects: Array + cursor?: string + truncated?: boolean +} + +export interface BlobPutOptions { + contentType?: string + customMetadata?: Record +} + +export interface BlobListOptions { + prefix?: string + cursor?: string + limit?: number +} + +/** Durable object/blob store (byte-storing or reference-only backends). */ +export interface BlobStore { + /** Insert or overwrite the object at `key`, returning its metadata. */ + put: ( + key: string, + body: BlobBody, + options?: BlobPutOptions, + ) => Promise + /** Return the object at `key` (metadata + byte accessors), or `null`. */ + get: (key: string) => Promise + /** Return only the metadata for `key`, or `null`. */ + head: (key: string) => Promise + /** Remove the object at `key`. A no-op if absent. */ + delete: (key: string) => Promise + /** + * List objects, optionally filtered by `prefix`, in ascending key order. + * + * CURSOR SEMANTICS: `prefix` matches literally and case-sensitively (SQL + * backends must escape LIKE metacharacters, so `run_` matches only the exact + * bytes `run_`, not `_` as a wildcard). When `limit` is given and more keys + * match, the page is `truncated: true` with a `cursor`; passing that `cursor` + * back returns the strictly-following keys (keys `> cursor`). Cursor ordering + * is the same byte ordering as the sort, so paging visits every key exactly + * once with no gaps or repeats. `limit: 0` yields an empty, untruncated page + * with no cursor. + */ + list: (options?: BlobListOptions) => Promise +} + /** * Sparse bag of **state** store keys — composition / validation only. * @@ -306,6 +430,8 @@ export interface AIPersistenceStores { runs?: RunStore interrupts?: InterruptStore metadata?: MetadataStore + artifacts?: ArtifactStore + blobs?: BlobStore } /** @@ -478,6 +604,8 @@ const storeKeys = [ 'runs', 'interrupts', 'metadata', + 'artifacts', + 'blobs', ] satisfies Array const storeKeySet = new Set(storeKeys) @@ -513,12 +641,20 @@ export function validateChatPersistenceStores( /** * Generation middleware entrypoint rule: `runs` is required (run lifecycle is - * the only generation state this middleware tracks). + * the only generation state this middleware tracks). When artifact persistence + * is used, `artifacts` and `blobs` must be provided together. */ export function validateGenerationPersistenceStores( persistence: AIPersistence, ): void { validatePersistenceStoreKeys(persistence) + const hasArtifacts = persistence.stores.artifacts !== undefined + const hasBlobs = persistence.stores.blobs !== undefined + if (hasArtifacts !== hasBlobs) { + throw new Error( + 'Generation artifact persistence requires both stores.artifacts and stores.blobs.', + ) + } if (!persistence.stores.runs) { throw new Error('Generation persistence requires stores.runs.') } diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts new file mode 100644 index 000000000..f2642cecb --- /dev/null +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -0,0 +1,496 @@ +import { describe, expect, it, vi } from 'vitest' +import { + EventType, + generateAudio, + generateImage, + generateTranscription, +} from '@tanstack/ai' +import { composePersistence, defineAIPersistence } from '../src/types' +import { memoryPersistence } from '../src/memory' +import { withGenerationPersistence } from '../src/middleware' +import { retrieveArtifact, retrieveBlob } from '../src/retrieve' +import type { + GenerationArtifactDescriptor, + GenerationArtifactExtractionInput, + GenerationArtifactNameInput, + AIPersistence, +} from '../src' +import type { + AudioAdapter, + AudioGenerationResult, + ImageAdapter, + PersistedArtifactRef, + StreamChunk, + TranscriptionAdapter, + TranscriptionResult, +} from '@tanstack/ai' + +void (undefined as unknown as GenerationArtifactDescriptor) +void (undefined as unknown as GenerationArtifactExtractionInput) +void (undefined as unknown as GenerationArtifactNameInput) + +type AudioGenerateOptions = Parameters[0] & { + threadId?: string + runId?: string + replay?: unknown +} + +type TranscriptionGenerateOptions = Parameters< + typeof generateTranscription +>[0] & { + threadId?: string + runId?: string +} + +const imageAdapterTypes: ImageAdapter['~types'] = { + providerOptions: {}, + modelProviderOptionsByName: {}, + modelSizeByName: {}, + modelInputModalitiesByName: {}, +} + +const audioAdapterTypes: AudioAdapter['~types'] = { + providerOptions: {}, +} + +const transcriptionAdapterTypes: TranscriptionAdapter['~types'] = { + providerOptions: {}, +} + +async function collect(stream: AsyncIterable) { + const chunks: Array = [] + for await (const chunk of stream) chunks.push(chunk) + return chunks +} + +function imageAdapter(): ImageAdapter { + return { + kind: 'image', + name: 'test-image-provider', + model: 'test-image-model', + '~types': imageAdapterTypes, + generateImages: vi.fn(async () => ({ + id: 'image-result', + model: 'test-image-model', + images: [{ b64Json: 'b3V0cHV0LWltYWdl' }], + })), + } +} + +function audioAdapter(): AudioAdapter { + return { + kind: 'audio', + name: 'test-audio-provider', + model: 'test-audio-model', + '~types': audioAdapterTypes, + generateAudio: vi.fn(async () => ({ + id: 'audio-result', + model: 'test-audio-model', + audio: { + b64Json: 'b3V0cHV0LWF1ZGlv', + contentType: 'audio/wav', + duration: 1, + }, + })), + } +} + +function transcriptionAdapter(): TranscriptionAdapter { + return { + kind: 'transcription', + name: 'test-transcription-provider', + model: 'test-transcription-model', + '~types': transcriptionAdapterTypes, + transcribe: vi.fn(async () => ({ + id: 'transcription-result', + model: 'test-transcription-model', + text: 'hello world', + language: 'en', + segments: [{ id: 0, start: 0, end: 1, text: 'hello world' }], + })), + } +} + +describe('withGenerationPersistence generation artifacts', () => { + it('persists built-in image output artifacts and attaches refs', async () => { + const persistence = memoryPersistence() + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-image', + runId: 'run-image', + middleware: [withGenerationPersistence(persistence)], + }) + + expect(result.artifacts).toHaveLength(1) + expect(result.artifacts?.[0]).toMatchObject({ + role: 'output', + threadId: 'thread-image', + runId: 'run-image', + mimeType: 'image/png', + size: 12, + source: { + activity: 'image', + path: 'images.0', + provider: 'test-image-provider', + model: 'test-image-model', + mediaType: 'image', + }, + }) + + const record = await persistence.stores.artifacts!.get( + result.artifacts![0]!.artifactId, + ) + expect(record).toMatchObject({ + runId: 'run-image', + threadId: 'thread-image', + mimeType: 'image/png', + size: 12, + }) + const blob = await persistence.stores.blobs!.get( + `artifacts/run-image/${result.artifacts![0]!.artifactId}`, + ) + await expect(blob?.text()).resolves.toBe('output-image') + }) + + it('retrieveArtifact / retrieveBlob fetch a persisted artifact and its bytes', async () => { + const persistence = memoryPersistence() + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-retrieve', + runId: 'run-retrieve', + middleware: [withGenerationPersistence(persistence)], + }) + const artifactId = result.artifacts![0]!.artifactId + + const record = await retrieveArtifact(persistence, artifactId) + expect(record).toMatchObject({ + runId: 'run-retrieve', + mimeType: 'image/png', + }) + + // By id (resolves the record first) and by an already-loaded record. + await expect( + (await retrieveBlob(persistence, artifactId))?.text(), + ).resolves.toBe('output-image') + await expect( + (await retrieveBlob(persistence, record!))?.text(), + ).resolves.toBe('output-image') + + // Unknown id resolves to null on both. + expect(await retrieveArtifact(persistence, 'missing')).toBeNull() + expect(await retrieveBlob(persistence, 'missing')).toBeNull() + }) + + it('persists non-image media outputs', async () => { + const persistence = memoryPersistence() + + const result = (await generateAudio({ + adapter: audioAdapter(), + prompt: 'make audio', + threadId: 'thread-audio', + runId: 'run-audio', + middleware: [withGenerationPersistence(persistence)], + } as AudioGenerateOptions)) as AudioGenerationResult + + expect(result.artifacts).toHaveLength(1) + expect(result.artifacts?.[0]).toMatchObject({ + role: 'output', + mimeType: 'audio/wav', + size: 12, + source: { + activity: 'audio', + path: 'audio', + mediaType: 'audio', + }, + }) + }) + + it('persists media inputs and includes input refs on the result', async () => { + const persistence = memoryPersistence() + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: [ + { type: 'text', content: 'edit this' }, + { + type: 'image', + source: { + type: 'data', + value: 'aW5wdXQtaW1hZ2U=', + mimeType: 'image/png', + }, + }, + ], + threadId: 'thread-input', + runId: 'run-input', + middleware: [withGenerationPersistence(persistence)], + }) + + expect(result.artifacts?.map((artifact) => artifact.role)).toEqual([ + 'input', + 'output', + ]) + const input = result.artifacts?.[0] + expect(input).toMatchObject({ + role: 'input', + mimeType: 'image/png', + size: 11, + source: { path: 'prompt.images.0', mediaType: 'image' }, + }) + }) + + it('allows run tracking without artifact stores', () => { + const full = memoryPersistence() + const persistence = defineAIPersistence({ + stores: { + runs: full.stores.runs, + }, + }) + + expect(() => withGenerationPersistence(persistence)).not.toThrow() + }) + + it('uses custom artifact extraction instead of built-in extraction', async () => { + const persistence = memoryPersistence() + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: [ + { type: 'text', content: 'edit this' }, + { + type: 'image', + source: { + type: 'data', + value: 'aW5wdXQtaW1hZ2U=', + mimeType: 'image/png', + }, + }, + ], + threadId: 'thread-custom', + runId: 'run-custom', + middleware: [ + withGenerationPersistence(persistence, { + extractArtifacts: () => [ + { + role: 'output', + path: 'custom', + mediaType: 'json', + mimeType: 'application/json', + json: { ok: true }, + name: 'custom.json', + }, + ], + }), + ], + }) + + expect(result.artifacts).toHaveLength(1) + expect(result.artifacts?.[0]).toMatchObject({ + name: 'custom.json', + mimeType: 'application/json', + source: { path: 'custom', mediaType: 'json' }, + }) + }) + + it('does not leak data URL bytes into artifact refs', async () => { + const persistence = memoryPersistence() + const dataUrl = 'data:image/png;base64,ZGF0YS11cmwtYnl0ZXM=' + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-data-url', + runId: 'run-data-url', + middleware: [ + withGenerationPersistence(persistence, { + extractArtifacts: () => [ + { + role: 'input', + path: 'prompt.images.0', + mediaType: 'image', + url: dataUrl, + }, + { + role: 'output', + path: 'images.0', + mediaType: 'image', + url: dataUrl, + }, + ], + }), + ], + }) + + expect(result.artifacts).toHaveLength(2) + expect(result.artifacts?.map((artifact) => artifact.externalUrl)).toEqual([ + undefined, + undefined, + ]) + expect(JSON.stringify(result.artifacts)).not.toContain(dataUrl) + + const [input, output] = result.artifacts! + await expect( + persistence.stores.blobs + ?.get(`artifacts/run-data-url/${input!.artifactId}`) + .then((blob) => blob?.text()), + ).resolves.toBe('data-url-bytes') + await expect( + persistence.stores.blobs + ?.get(`artifacts/run-data-url/${output!.artifactId}`) + .then((blob) => blob?.text()), + ).resolves.toBe('data-url-bytes') + }) + + it('uses nameArtifact overrides', async () => { + const persistence = memoryPersistence() + + const result = (await generateAudio({ + adapter: audioAdapter(), + prompt: 'make audio', + threadId: 'thread-name', + runId: 'run-name', + middleware: [ + withGenerationPersistence(persistence, { + nameArtifact: ({ descriptor, index }) => + `${descriptor.role}-${descriptor.mediaType}-${index}.bin`, + }), + ], + } as AudioGenerateOptions)) as AudioGenerationResult + + expect(result.artifacts?.[0]?.name).toBe('output-audio-0.bin') + }) + + it('emits generation:artifacts before generation:result with persisted refs', async () => { + const persistence = memoryPersistence() + + const chunks = await collect( + generateImage, true>({ + adapter: imageAdapter(), + prompt: 'make an image', + stream: true, + threadId: 'thread-stream', + runId: 'run-stream', + middleware: [withGenerationPersistence(persistence)], + }), + ) + + const customEvents = chunks.filter( + (chunk) => chunk.type === EventType.CUSTOM, + ) + expect(customEvents.map((chunk) => chunk.name)).toEqual([ + 'generation:artifacts', + 'generation:result', + ]) + expect(customEvents[0]?.value).toEqual( + (customEvents[1]?.value as { artifacts?: unknown }).artifacts, + ) + }) + + it('uses the same fallback run and thread ids for streamed events and persisted artifact refs', async () => { + const persistence = memoryPersistence() + + const chunks = await collect( + generateImage, true>({ + adapter: imageAdapter(), + prompt: 'make an image', + stream: true, + middleware: [withGenerationPersistence(persistence)], + }), + ) + + const started = chunks.find((chunk) => chunk.type === EventType.RUN_STARTED) + const result = chunks.find( + (chunk) => + chunk.type === EventType.CUSTOM && chunk.name === 'generation:result', + ) + const artifact = ( + result as unknown as + | { value?: { artifacts?: Array } } + | undefined + )?.value?.artifacts?.[0] + + expect(started).toMatchObject({ + runId: expect.any(String), + threadId: expect.any(String), + }) + expect(artifact).toMatchObject({ + runId: started?.runId, + threadId: started?.threadId, + }) + await expect( + persistence.stores.artifacts!.list(started!.runId!), + ).resolves.toHaveLength(1) + }) + + it('does not persist generation artifacts when artifact stores are removed', async () => { + const full = memoryPersistence() + const put = vi.spyOn(full.stores.blobs, 'put') + const save = vi.spyOn(full.stores.artifacts, 'save') + const persistence = composePersistence(full, { + overrides: { artifacts: false, blobs: false }, + }) + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-messages-only', + runId: 'run-messages-only', + middleware: [withGenerationPersistence(persistence)], + }) + + expect(result.artifacts).toBeUndefined() + expect(put).not.toHaveBeenCalled() + expect(save).not.toHaveBeenCalled() + }) + + it('fails early when artifact persistence is enabled without a paired blob store', () => { + const full = memoryPersistence() + const persistence: AIPersistence = defineAIPersistence({ + stores: { + artifacts: full.stores.artifacts, + }, + }) + + expect(() => withGenerationPersistence(persistence)).toThrow( + /artifact persistence requires both stores\.artifacts and stores\.blobs/i, + ) + }) + + it('persists transcription structured JSON output', async () => { + const persistence = memoryPersistence() + + const result = (await generateTranscription({ + adapter: transcriptionAdapter(), + audio: 'aW5wdXQtYXVkaW8=', + responseFormat: 'verbose_json', + threadId: 'thread-transcription', + runId: 'run-transcription', + middleware: [withGenerationPersistence(persistence)], + } as TranscriptionGenerateOptions)) as TranscriptionResult + + expect(result.artifacts?.map((artifact) => artifact.role)).toEqual([ + 'input', + 'output', + ]) + const structured = result.artifacts?.find( + (artifact) => artifact.source.mediaType === 'json', + ) as PersistedArtifactRef | undefined + expect(structured).toMatchObject({ + role: 'output', + mimeType: 'application/json', + source: { + activity: 'transcription', + path: 'transcription', + mediaType: 'json', + }, + }) + const blob = await persistence.stores.blobs!.get( + `artifacts/run-transcription/${structured!.artifactId}`, + ) + await expect(blob?.text()).resolves.toContain('"segments"') + }) +}) diff --git a/packages/ai-persistence/tests/memory.test.ts b/packages/ai-persistence/tests/memory.test.ts index 95820b9a8..811c3ce51 100644 --- a/packages/ai-persistence/tests/memory.test.ts +++ b/packages/ai-persistence/tests/memory.test.ts @@ -13,6 +13,8 @@ describe('memoryPersistence', () => { it('exposes the complete state store set (no locks)', () => { expect(Object.keys(memoryPersistence().stores).sort()).toEqual([ + 'artifacts', + 'blobs', 'interrupts', 'messages', 'metadata', diff --git a/packages/ai-persistence/tests/persistence-types.test-d.ts b/packages/ai-persistence/tests/persistence-types.test-d.ts index 60f040141..cf5b2d98c 100644 --- a/packages/ai-persistence/tests/persistence-types.test-d.ts +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -14,6 +14,8 @@ import { InMemoryLockStore, withLocks } from '@tanstack/ai/locks' import type { LockStore } from '@tanstack/ai/locks' import type { AIPersistence, + ArtifactStore, + BlobStore, ChatPersistence, ChatTranscriptPersistence, ChatTranscriptStores, @@ -123,8 +125,17 @@ const uncertainInherited = composePersistence(base, { }) expectTypeOf(uncertainInherited.stores.messages).toEqualTypeOf() -// Named shapes -expectTypeOf(memoryPersistence()).toEqualTypeOf() +// Named shapes — memoryPersistence now includes generation stores too +expectTypeOf(memoryPersistence()).toEqualTypeOf< + AIPersistence<{ + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore + artifacts: ArtifactStore + blobs: BlobStore + }> +>() const transcript: ChatTranscriptPersistence = messagesOnly void transcript declare const fullChat: ChatPersistence diff --git a/packages/ai-utils/src/base64.ts b/packages/ai-utils/src/base64.ts index 58e8e9a61..bb7827078 100644 --- a/packages/ai-utils/src/base64.ts +++ b/packages/ai-utils/src/base64.ts @@ -55,6 +55,13 @@ export function arrayBufferToBase64(buffer: ArrayBuffer): string { throw new Error('No base64 encoder available in this environment.') } +/** + * Decode a base64 string into a `Uint8Array`. + */ +export function base64ToUint8Array(base64: string): Uint8Array { + return new Uint8Array(base64ToArrayBuffer(base64)) +} + /** * Decode a base64 string into an `ArrayBuffer`. */ diff --git a/packages/ai-utils/src/index.ts b/packages/ai-utils/src/index.ts index 843d5eb37..619338eee 100644 --- a/packages/ai-utils/src/index.ts +++ b/packages/ai-utils/src/index.ts @@ -2,4 +2,8 @@ export { generateId } from './id' export { getApiKeyFromEnv } from './env' export { transformNullsToUndefined, undoNullWidening } from './transforms' export type { NullWideningMap } from './transforms' -export { arrayBufferToBase64, base64ToArrayBuffer } from './base64' +export { + arrayBufferToBase64, + base64ToArrayBuffer, + base64ToUint8Array, +} from './base64' diff --git a/packages/ai/src/activities/generateAudio/index.ts b/packages/ai/src/activities/generateAudio/index.ts index ec2e72890..cb25e2a0b 100644 --- a/packages/ai/src/activities/generateAudio/index.ts +++ b/packages/ai/src/activities/generateAudio/index.ts @@ -9,6 +9,7 @@ import { aiEventClient } from '@tanstack/ai-event-client' import { streamGenerationResult } from '../stream-generation-result.js' import { resolveDebugOption } from '../../logger/resolve' import { + applyGenerationResultTransforms, createGenerationContext, runGenerationError, runGenerationFinish, @@ -84,6 +85,10 @@ export interface AudioActivityOptions< * `GenerationMiddleware` contract for a custom backend. */ middleware?: Array + /** Stable conversation/thread id for correlating this run when persisted. */ + threadId?: string + /** Stable run id for correlating this run when persisted. */ + runId?: string } // =========================== @@ -134,8 +139,9 @@ export function generateAudio< options: AudioActivityOptions, ): AudioActivityResult { if (options.stream) { - return streamGenerationResult(() => - runGenerateAudio(options), + return streamGenerationResult( + (resolved) => runGenerateAudio({ ...options, ...resolved }), + options, ) as AudioActivityResult } return runGenerateAudio(options) as AudioActivityResult @@ -154,6 +160,8 @@ async function runGenerateAudio< stream: _stream, debug: _debug, middleware, + threadId, + runId, ...rest } = options const model = adapter.model @@ -171,6 +179,9 @@ async function runGenerateAudio< provider: adapter.name, model, modelOptions: rest.modelOptions, + threadId, + runId, + artifactInputs: { prompt: rest.prompt, duration: rest.duration }, createId, }) @@ -192,7 +203,8 @@ async function runGenerateAudio< }) try { - const result = await adapter.generateAudio({ ...rest, model, logger }) + const rawResult = await adapter.generateAudio({ ...rest, model, logger }) + const result = await applyGenerationResultTransforms(mwCtx, rawResult) const elapsedMs = Date.now() - startTime aiEventClient.emit('audio:request:completed', { diff --git a/packages/ai/src/activities/generateImage/index.ts b/packages/ai/src/activities/generateImage/index.ts index 8021e0ce2..5e49baad1 100644 --- a/packages/ai/src/activities/generateImage/index.ts +++ b/packages/ai/src/activities/generateImage/index.ts @@ -9,6 +9,7 @@ import { aiEventClient } from '@tanstack/ai-event-client' import { streamGenerationResult } from '../stream-generation-result.js' import { resolveDebugOption } from '../../logger/resolve' import { + applyGenerationResultTransforms, createGenerationContext, runGenerationError, runGenerationFinish, @@ -137,6 +138,10 @@ export type ImageActivityOptions< * `GenerationMiddleware` contract for a custom backend. */ middleware?: Array + /** Stable conversation/thread id for correlating this run when persisted. */ + threadId?: string + /** Stable run id for correlating this run when persisted. */ + runId?: string } & ({} extends ImageProviderOptionsForModel ? { /** Provider-specific options for image generation */ modelOptions?: ImageProviderOptionsForModel< @@ -225,8 +230,9 @@ export function generateImage< options: ImageActivityOptions, ): ImageActivityResult { if (options.stream) { - return streamGenerationResult(() => - runGenerateImage(options), + return streamGenerationResult( + (resolved) => runGenerateImage({ ...options, ...resolved }), + options, ) as ImageActivityResult } @@ -247,6 +253,8 @@ async function runGenerateImage< stream: _stream, debug: _debug, middleware, + threadId, + runId, ...rest } = options const model = adapter.model @@ -260,6 +268,9 @@ async function runGenerateImage< provider: adapter.name, model, modelOptions: rest.modelOptions, + threadId, + runId, + artifactInputs: { prompt: rest.prompt }, createId, }) @@ -295,7 +306,8 @@ async function runGenerateImage< }) try { - const result = await adapter.generateImages({ ...rest, model, logger }) + const rawResult = await adapter.generateImages({ ...rest, model, logger }) + const result = await applyGenerationResultTransforms(mwCtx, rawResult) const duration = Date.now() - startTime aiEventClient.emit('image:request:completed', { diff --git a/packages/ai/src/activities/generateSpeech/index.ts b/packages/ai/src/activities/generateSpeech/index.ts index 06ac193c8..0a134f4cf 100644 --- a/packages/ai/src/activities/generateSpeech/index.ts +++ b/packages/ai/src/activities/generateSpeech/index.ts @@ -9,6 +9,7 @@ import { aiEventClient } from '@tanstack/ai-event-client' import { streamGenerationResult } from '../stream-generation-result.js' import { resolveDebugOption } from '../../logger/resolve' import { + applyGenerationResultTransforms, createGenerationContext, runGenerationError, runGenerationFinish, @@ -87,6 +88,10 @@ export interface TTSActivityOptions< * `GenerationMiddleware` contract for a custom backend. */ middleware?: Array + /** Stable conversation/thread id for correlating this run when persisted. */ + threadId?: string + /** Stable run id for correlating this run when persisted. */ + runId?: string } // =========================== @@ -144,8 +149,9 @@ export function generateSpeech< TStream extends boolean = false, >(options: TTSActivityOptions): TTSActivityResult { if (options.stream) { - return streamGenerationResult(() => - runGenerateSpeech(options), + return streamGenerationResult( + (resolved) => runGenerateSpeech({ ...options, ...resolved }), + options, ) as TTSActivityResult } return runGenerateSpeech(options) as TTSActivityResult @@ -162,6 +168,8 @@ async function runGenerateSpeech< stream: _stream, debug: _debug, middleware, + threadId, + runId, ...rest } = options const model = adapter.model @@ -179,6 +187,14 @@ async function runGenerateSpeech< provider: adapter.name, model, modelOptions: rest.modelOptions, + artifactInputs: { + text: rest.text, + voice: rest.voice, + format: rest.format, + speed: rest.speed, + }, + threadId, + runId, createId, }) @@ -202,7 +218,8 @@ async function runGenerateSpeech< }) try { - const result = await adapter.generateSpeech({ ...rest, model, logger }) + const rawResult = await adapter.generateSpeech({ ...rest, model, logger }) + const result = await applyGenerationResultTransforms(mwCtx, rawResult) const duration = Date.now() - startTime aiEventClient.emit('speech:request:completed', { diff --git a/packages/ai/src/activities/generateTranscription/index.ts b/packages/ai/src/activities/generateTranscription/index.ts index 03c6a9ed1..ccf85f235 100644 --- a/packages/ai/src/activities/generateTranscription/index.ts +++ b/packages/ai/src/activities/generateTranscription/index.ts @@ -9,6 +9,7 @@ import { aiEventClient } from '@tanstack/ai-event-client' import { streamGenerationResult } from '../stream-generation-result.js' import { resolveDebugOption } from '../../logger/resolve' import { + applyGenerationResultTransforms, createGenerationContext, runGenerationError, runGenerationFinish, @@ -94,6 +95,10 @@ export interface TranscriptionActivityOptions< * `GenerationMiddleware` contract for a custom backend. */ middleware?: Array + /** Stable conversation/thread id for correlating this run when persisted. */ + threadId?: string + /** Stable run id for correlating this run when persisted. */ + runId?: string } // =========================== @@ -171,8 +176,9 @@ export function generateTranscription< options: TranscriptionActivityOptions, ): TranscriptionActivityResult { if (options.stream) { - return streamGenerationResult(() => - runGenerateTranscription(options), + return streamGenerationResult( + (resolved) => runGenerateTranscription({ ...options, ...resolved }), + options, ) as TranscriptionActivityResult } @@ -197,6 +203,8 @@ async function runGenerateTranscription< stream: _stream, debug: _debug, middleware, + threadId, + runId, ...rest } = options const model = adapter.model @@ -214,6 +222,14 @@ async function runGenerateTranscription< provider: adapter.name, model, modelOptions: rest.modelOptions, + artifactInputs: { + audio: rest.audio, + language: rest.language, + prompt: rest.prompt, + responseFormat: rest.responseFormat, + }, + threadId, + runId, createId, }) @@ -236,7 +252,8 @@ async function runGenerateTranscription< }) try { - const result = await adapter.transcribe({ ...rest, model, logger }) + const rawResult = await adapter.transcribe({ ...rest, model, logger }) + const result = await applyGenerationResultTransforms(mwCtx, rawResult) const duration = Date.now() - startTime aiEventClient.emit('transcription:request:completed', { diff --git a/packages/ai/src/activities/middleware/index.ts b/packages/ai/src/activities/middleware/index.ts index 79454ac5c..d5123374d 100644 --- a/packages/ai/src/activities/middleware/index.ts +++ b/packages/ai/src/activities/middleware/index.ts @@ -9,6 +9,8 @@ export type { GenerationAbortInfo, GenerationErrorInfo, AnyGenerationMiddleware, + GenerationResultTransform, + GenerationResultTransformContext, } from './types' export { createGenerationContext, diff --git a/packages/ai/src/activities/middleware/run.ts b/packages/ai/src/activities/middleware/run.ts index 6983b1e55..a1793cda9 100644 --- a/packages/ai/src/activities/middleware/run.ts +++ b/packages/ai/src/activities/middleware/run.ts @@ -4,6 +4,7 @@ import type { GenerationFinishInfo, GenerationMiddleware, GenerationMiddlewareContext, + GenerationResultTransformContext, GenerationUsageInfo, } from './types' @@ -19,6 +20,9 @@ export function createGenerationContext(args: { provider: string model: string modelOptions?: unknown + threadId?: string + runId?: string + artifactInputs?: unknown createId: (prefix: string) => string }): GenerationMiddlewareContext { return { @@ -27,9 +31,13 @@ export function createGenerationContext(args: { provider: args.provider, model: args.model, modelOptions: args.modelOptions, + threadId: args.threadId, + runId: args.runId, source: 'server', createId: args.createId, context: undefined, + resultTransforms: [], + artifactInputs: args.artifactInputs, } } @@ -86,3 +94,26 @@ export function runGenerationError( ): Promise { return run(middleware, (mw) => mw.onError?.(ctx, info)) } + +/** + * Apply the result transforms middleware registered on the context, in order, + * to the raw adapter result. Each transform may return a replacement result or + * `undefined` to leave it unchanged. Runs after the adapter result exists and + * before the final result is returned or streamed. + */ +export async function applyGenerationResultTransforms( + ctx: GenerationMiddlewareContext, + result: TResult, +): Promise { + let current = result + const transformCtx: GenerationResultTransformContext = { middleware: ctx } + + for (const transform of ctx.resultTransforms ?? []) { + const transformed = await transform(current, transformCtx) + if (transformed !== undefined) { + current = transformed as TResult + } + } + + return current +} diff --git a/packages/ai/src/activities/middleware/types.ts b/packages/ai/src/activities/middleware/types.ts index 99ae3e44e..ef1136413 100644 --- a/packages/ai/src/activities/middleware/types.ts +++ b/packages/ai/src/activities/middleware/types.ts @@ -60,6 +60,10 @@ export interface GenerationMiddlewareContext { provider: string /** Model id. Emitted as `gen_ai.request.model`. */ model: string + /** Stable conversation/thread id, when supplied by the caller. */ + threadId?: string + /** Stable run id, when supplied by the caller. */ + runId?: string /** * Provider-specific options passed to the activity, if any. Typed `unknown` * because each activity's options are strongly typed per model; a supertype @@ -72,8 +76,36 @@ export interface GenerationMiddlewareContext { createId: (prefix: string) => string /** Runtime context provided by the activity options, if any. */ context: TContext + /** + * Result transforms registered by middleware during this activity call. + * Transforms run after the raw adapter result exists and before the final + * result is returned or streamed. Push multiple transforms to run them in + * registration order. + */ + resultTransforms?: Array> + /** + * Activity inputs captured for middleware that needs to transform or persist + * the result together with reconstructable request metadata. + */ + artifactInputs?: unknown } +/** Stable context handed to each {@link GenerationResultTransform}. */ +export interface GenerationResultTransformContext { + /** The activity call being transformed. */ + middleware: GenerationMiddlewareContext +} + +/** + * A transform middleware registers on `ctx.resultTransforms` to rewrite the raw + * adapter result before it is returned or streamed. Return a new result to + * replace it, or `undefined` to leave it unchanged. + */ +export type GenerationResultTransform = ( + result: TResult, + ctx: GenerationResultTransformContext, +) => TResult | undefined | Promise + // =========================== // Hook payloads // =========================== diff --git a/packages/ai/src/activities/stream-generation-result.ts b/packages/ai/src/activities/stream-generation-result.ts index 1bb530179..721b7a349 100644 --- a/packages/ai/src/activities/stream-generation-result.ts +++ b/packages/ai/src/activities/stream-generation-result.ts @@ -12,6 +12,19 @@ function createId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } +/** + * Persisted artifact refs a middleware may have attached to the result. Read + * defensively: the result shape is activity-specific and `artifacts` is only + * present when generation persistence is wired with an artifact + blob store. + */ +function artifactsFromResult(result: unknown): Array | undefined { + if (typeof result !== 'object' || result === null) return undefined + const artifacts = (result as { artifacts?: unknown }).artifacts + return Array.isArray(artifacts) && artifacts.length > 0 + ? artifacts + : undefined +} + /** * Wrap a one-shot generation result as a StreamChunk async iterable. * @@ -23,7 +36,10 @@ function createId(prefix: string): string { * @returns An AsyncIterable of StreamChunks with RUN_STARTED, CUSTOM(generation:result), and RUN_FINISHED events on success, or RUN_STARTED and RUN_ERROR on failure */ export async function* streamGenerationResult( - generator: () => Promise, + generator: (resolved: { + runId: string + threadId: string + }) => Promise, options?: { runId?: string; threadId?: string }, ): AsyncIterable { const runId = options?.runId ?? createId('run') @@ -37,7 +53,19 @@ export async function* streamGenerationResult( } try { - const result = await generator() + const result = await generator({ runId, threadId }) + + // Emit persisted artifact refs (if a middleware attached any) before the + // result, so the client records them as the run streams. + const artifacts = artifactsFromResult(result) + if (artifacts) { + yield { + type: EventType.CUSTOM, + name: 'generation:artifacts', + value: artifacts, + timestamp: Date.now(), + } + } yield { type: EventType.CUSTOM, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index fa23a7407..24ff9bb33 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -201,6 +201,8 @@ export type { GenerationAbortInfo, GenerationErrorInfo, AnyGenerationMiddleware, + GenerationResultTransform, + GenerationResultTransformContext, } from './activities/middleware/index' // Capability primitives + middleware builder export { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index caec892a8..d818b7ebc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2075,6 +2075,10 @@ importers: version: 4.3.6 packages/ai-persistence: + dependencies: + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils devDependencies: '@tanstack/ai': specifier: workspace:* From a9421b9eeffc3b0b3eeb242c1df1c0c6d71fb98e Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 28 Jul 2026 13:06:52 +0200 Subject: [PATCH 19/66] 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. --- .changeset/generation-persistence.md | 6 +- docs/config.json | 3 +- docs/persistence/build-your-own-adapter.md | 645 +++++++++++++++++- docs/persistence/generation-persistence.md | 331 ++++++--- .../ai-angular/src/inject-generate-audio.ts | 2 +- .../ai-angular/src/inject-generate-video.ts | 16 +- packages/ai-angular/src/inject-generation.ts | 16 +- packages/ai-client/src/connection-adapters.ts | 113 +++ packages/ai-client/src/generation-client.ts | 62 +- packages/ai-client/src/generation-types.ts | 48 +- packages/ai-client/src/index.ts | 2 + .../ai-client/src/video-generation-client.ts | 62 +- .../ai-client/tests/generation-client.test.ts | 180 ++++- .../skills/ai-persistence/SKILL.md | 32 +- .../build-cloudflare-artifact-store/SKILL.md | 402 +++++++++++ packages/ai-persistence/src/index.ts | 14 + packages/ai-persistence/src/memory.ts | 58 +- packages/ai-persistence/src/middleware.ts | 121 ++-- .../src/reconstruct-generation.ts | 184 +++++ packages/ai-persistence/src/types.ts | 137 +++- .../ai-persistence/tests/error-abort.test.ts | 51 +- .../tests/generation-artifacts.test.ts | 87 ++- packages/ai-persistence/tests/memory.test.ts | 1 + .../tests/persistence-fixtures.ts | 29 + .../tests/persistence-types.test-d.ts | 11 +- .../tests/persistence-validation.test.ts | 9 +- .../tests/reconstruct-generation.test.ts | 138 ++++ packages/ai-react/src/use-generate-audio.ts | 15 +- packages/ai-react/src/use-generate-image.ts | 15 +- packages/ai-react/src/use-generate-speech.ts | 15 +- packages/ai-react/src/use-generate-video.ts | 16 +- packages/ai-react/src/use-generation.ts | 16 +- packages/ai-react/src/use-summarize.ts | 15 +- packages/ai-react/src/use-transcription.ts | 15 +- .../ai-react/tests/use-generation.test.ts | 33 + packages/ai-solid/src/use-generate-audio.ts | 2 +- packages/ai-solid/src/use-generate-image.ts | 2 +- packages/ai-solid/src/use-generate-speech.ts | 2 +- packages/ai-solid/src/use-generate-video.ts | 16 +- packages/ai-solid/src/use-generation.ts | 16 +- packages/ai-solid/src/use-summarize.ts | 2 +- packages/ai-solid/src/use-transcription.ts | 2 +- .../src/create-generate-audio.svelte.ts | 2 +- .../src/create-generate-image.svelte.ts | 2 +- .../src/create-generate-speech.svelte.ts | 2 +- .../src/create-generate-video.svelte.ts | 16 +- .../ai-svelte/src/create-generation.svelte.ts | 16 +- .../ai-svelte/src/create-summarize.svelte.ts | 2 +- .../src/create-transcription.svelte.ts | 2 +- packages/ai-vue/src/use-generate-audio.ts | 2 +- packages/ai-vue/src/use-generate-image.ts | 2 +- packages/ai-vue/src/use-generate-speech.ts | 2 +- packages/ai-vue/src/use-generate-video.ts | 16 +- packages/ai-vue/src/use-generation.ts | 16 +- packages/ai-vue/src/use-summarize.ts | 2 +- packages/ai-vue/src/use-transcription.ts | 2 +- .../ai-core/client-persistence/SKILL.md | 54 +- .../skills/ai-core/media-generation/SKILL.md | 62 ++ testing/e2e/src/routeTree.gen.ts | 44 ++ .../api.generation-persistence-server.ts | 124 ++++ .../routes/generation-persistence-server.tsx | 68 ++ .../generation-persistence-server.spec.ts | 47 ++ 62 files changed, 3149 insertions(+), 276 deletions(-) create mode 100644 packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md create mode 100644 packages/ai-persistence/src/reconstruct-generation.ts create mode 100644 packages/ai-persistence/tests/reconstruct-generation.test.ts create mode 100644 testing/e2e/src/routes/api.generation-persistence-server.ts create mode 100644 testing/e2e/src/routes/generation-persistence-server.tsx create mode 100644 testing/e2e/tests/generation-persistence-server.spec.ts diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index 9413cf39f..ef82522fe 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -13,7 +13,11 @@ Add generation persistence: a lightweight client resume snapshot plus optional durable storage of the generated media bytes. -**Media byte storage (server).** 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//`), records an `ArtifactRecord`, attaches `PersistedArtifactRef`s to the result, and emits a `generation:artifacts` event so the client records them. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options and run their result through middleware result transforms. `@tanstack/ai-utils` adds `base64ToUint8Array`. +**Generation job store (server).** `withGenerationPersistence` now records each run in a dedicated `jobs` (`GenerationJobStore`) store, keyed by the job's `jobId` (`runId`), with `threadId` only an optional link — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `jobs` store, and `defineGenerationJobStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. + +**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?jobId=` (or `?threadId=`) from the request, authorizes it via an optional `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client can restore the last run on mount. Requires the `jobs` store. + +**Media byte storage (server).** When the persistence backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, attaches `PersistedArtifactRef`s to the result and onto the job record, and emits a `generation:artifacts` event so the client records them. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options and run their result through middleware result transforms. `@tanstack/ai-utils` adds `base64ToUint8Array`. Add client-side generation persistence: a lightweight resume snapshot for media generation activities. diff --git a/docs/config.json b/docs/config.json index b8cee337f..cd515691d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -259,7 +259,8 @@ { "label": "Generation Persistence", "to": "persistence/generation-persistence", - "addedAt": "2026-07-28" + "addedAt": "2026-07-28", + "updatedAt": "2026-07-28" }, { "label": "Controls", diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index 7469992ef..f21d46a66 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -36,19 +36,25 @@ const persistence: ChatTranscriptPersistence = { } ``` -Each store is independent. Provide only the ones you need: `messages` for the -transcript, `runs` for run lifecycle, `interrupts` for durable approvals (needs -`runs`), `metadata` for namespaced key/value state. The middleware turns on +Each store is independent. Provide only the ones you need. For chat: `messages` +for the transcript, `runs` for run lifecycle, `interrupts` for durable approvals +(needs `runs`), `metadata` for namespaced key/value state. For generation: +`jobs` for the generation job lifecycle (the counterpart to `runs`, keyed by +`jobId`), plus `artifacts` and `blobs` to keep generated media bytes (see +[Generation & media stores](#generation--media-stores)). The middleware turns on behavior for whatever stores it finds, so a `messages`-only adapter is a valid adapter. -Those four are the *only* keys `stores` accepts — anything else throws -`Unknown AIPersistence store key` at construction. Need a mutex across +Those seven — `messages`, `runs`, `interrupts`, `metadata`, `jobs`, +`artifacts`, `blobs` — are the *only* keys `stores` accepts; anything else +throws `Unknown AIPersistence store key` at construction. Need a mutex across instances? That is `withLocks`; see [Locks](../advanced/locks). Type each store with its `define*Store` helper — `defineMessageStore`, -`defineRunStore`, `defineInterruptStore`, `defineMetadataStore` — as the sections -below do. Each checks the object against the contract inline (autocomplete, no +`defineRunStore`, `defineInterruptStore`, `defineMetadataStore`, +`defineGenerationJobStore`, `defineArtifactStore`, `defineBlobStore` — as the +sections below do. Each checks the object against the contract inline +(autocomplete, no `: MessageStore` annotation) and composes into `defineAIPersistence`, which tracks **exact presence**: the stores you pass are defined, autocompleted keys on `persistence.stores`, and accessing one you did not pass is a compile error. @@ -475,6 +481,485 @@ export async function POST(request: Request) { } ``` +## Generation & media stores + +Everything above builds a **chat** adapter. [Media generation](./generation-persistence) +persists differently: it does not use the chat `runs` store. Instead +`withGenerationPersistence` requires a `jobs` store — a `GenerationJobStore` +keyed by `jobId` (the run/request id a generation mints), the counterpart to +`runs`. A generation has no conversation, so `threadId` is only an optional +*link* on the job record. Keeping the generated bytes is an optional add-on on +top of `jobs`: an `artifacts` store (metadata) and a `blobs` store (the bytes), +which must be provided **together**. + +These are three more tables alongside the four from the schema in step 1: + +```sql +CREATE TABLE IF NOT EXISTS generation_jobs ( + job_id text PRIMARY KEY NOT NULL, + thread_id text, + activity text NOT NULL, + provider text NOT NULL, + model text NOT NULL, + status text NOT NULL, + started_at integer NOT NULL, + finished_at integer, + error_json text, + result_json text, + artifacts_json text, + usage_json text +); +CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id text PRIMARY KEY NOT NULL, + run_id text NOT NULL, + thread_id text NOT NULL, + name text NOT NULL, + mime_type text NOT NULL, + size integer NOT NULL, + external_url text, + created_at integer NOT NULL +); +CREATE TABLE IF NOT EXISTS blobs ( + key text PRIMARY KEY NOT NULL, + bytes blob NOT NULL, + size integer NOT NULL, + etag text NOT NULL, + content_type text, + custom_metadata_json text, + created_at integer NOT NULL, + updated_at integer NOT NULL +); +``` + +### Jobs: idempotent create, patch, latest-for-thread + +`GenerationJobStore` is the generation analogue of `RunStore`. `createOrResume` +is idempotent — a second call for a `jobId` returns the stored record unchanged, +so resuming a job never resets its `startedAt`, `activity`, or status. +`INSERT ... ON CONFLICT DO NOTHING` gives you that. `update` on an unknown +`jobId` is a no-op. `findLatestForThread` returns the job with the greatest +`startedAt` linked to a thread — `reconstructGeneration` calls it to hydrate the +last generation for a thread on a server-driven client's mount. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineGenerationJobStore } from '@tanstack/ai-persistence' +import type { + GenerationJobRecord, + GenerationJobStatus, +} from '@tanstack/ai-persistence' + +function toJobStatus(value: unknown): GenerationJobStatus { + switch (value) { + case 'running': + case 'complete': + case 'error': + case 'interrupted': + return value + default: + throw new TypeError(`Unexpected job status: ${String(value)}`) + } +} + +// `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field +// (String / Number / typeof) and JSON-parse the text columns — no cast. +function mapJob(row: Record): GenerationJobRecord { + return { + jobId: String(row.job_id), + ...(typeof row.thread_id === 'string' ? { threadId: row.thread_id } : {}), + activity: String(row.activity), + provider: String(row.provider), + model: String(row.model), + status: toJobStatus(row.status), + startedAt: Number(row.started_at), + ...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}), + ...(typeof row.error_json === 'string' + ? { error: JSON.parse(row.error_json) } + : {}), + ...(typeof row.result_json === 'string' + ? { result: JSON.parse(row.result_json) } + : {}), + ...(typeof row.artifacts_json === 'string' + ? { artifacts: JSON.parse(row.artifacts_json) } + : {}), + ...(typeof row.usage_json === 'string' + ? { usage: JSON.parse(row.usage_json) } + : {}), + } +} + +function createGenerationJobStore(db: DatabaseSync) { + const select = db.prepare('SELECT * FROM generation_jobs WHERE job_id = ?') + const insert = db.prepare( + `INSERT INTO generation_jobs + (job_id, thread_id, activity, provider, model, status, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(job_id) DO NOTHING`, + ) + const latest = db.prepare( + `SELECT * FROM generation_jobs WHERE thread_id = ? + ORDER BY started_at DESC LIMIT 1`, + ) + return defineGenerationJobStore({ + async createOrResume(input) { + const existing = select.get(input.jobId) + if (existing) return mapJob(existing) + const status: GenerationJobStatus = input.status ?? 'running' + insert.run( + input.jobId, + input.threadId ?? null, + input.activity, + input.provider, + input.model, + status, + input.startedAt, + ) + return { + jobId: input.jobId, + activity: input.activity, + provider: input.provider, + model: input.model, + status, + startedAt: input.startedAt, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), + } + }, + async update(jobId, patch) { + const sets: Array = [] + const params: Array = [] + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(patch.status) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + params.push(patch.finishedAt) + } + if (patch.error !== undefined) { + sets.push('error_json = ?') + params.push(JSON.stringify(patch.error)) + } + if (patch.result !== undefined) { + sets.push('result_json = ?') + params.push(JSON.stringify(patch.result)) + } + if (patch.artifacts !== undefined) { + sets.push('artifacts_json = ?') + params.push(JSON.stringify(patch.artifacts)) + } + if (patch.usage !== undefined) { + sets.push('usage_json = ?') + params.push(JSON.stringify(patch.usage)) + } + // Empty patch, or an unknown job id, touches nothing (UPDATE no-ops). + if (sets.length === 0) return + params.push(jobId) + db.prepare( + `UPDATE generation_jobs SET ${sets.join(', ')} WHERE job_id = ?`, + ).run(...params) + }, + async get(jobId) { + const row = select.get(jobId) + return row ? mapJob(row) : null + }, + // The most recent job linked to a thread. `reconstructGeneration` calls this + // so a server-driven client (`persistence: true`) hydrates the last + // generation for its thread by the stable thread id, without a job id. + async findLatestForThread(threadId) { + const row = latest.get(threadId) + return row ? mapJob(row) : null + }, + }) +} +``` + +### Artifacts: media metadata + +`ArtifactStore` holds one metadata row per generated file — its `runId`, +`mimeType`, `size`, and a `createdAt`. The bytes live in the blob store below. +`save` is an upsert, `list(runId)` returns every artifact for a run (`[]` when +none). `delete` / `deleteForRun` are optional but cheap to add. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineArtifactStore } from '@tanstack/ai-persistence' +import type { ArtifactRecord } from '@tanstack/ai-persistence' + +function mapArtifact(row: Record): ArtifactRecord { + return { + artifactId: String(row.artifact_id), + runId: String(row.run_id), + threadId: String(row.thread_id), + name: String(row.name), + mimeType: String(row.mime_type), + size: Number(row.size), + ...(typeof row.external_url === 'string' + ? { externalUrl: row.external_url } + : {}), + createdAt: Number(row.created_at), + } +} + +function createArtifactStore(db: DatabaseSync) { + const upsert = db.prepare( + `INSERT INTO artifacts + (artifact_id, run_id, thread_id, name, mime_type, size, external_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + run_id = excluded.run_id, thread_id = excluded.thread_id, + name = excluded.name, mime_type = excluded.mime_type, + size = excluded.size, external_url = excluded.external_url, + created_at = excluded.created_at`, + ) + const selectOne = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') + const byRun = db.prepare( + 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC', + ) + return defineArtifactStore({ + async save(record) { + upsert.run( + record.artifactId, + record.runId, + record.threadId, + record.name, + record.mimeType, + record.size, + record.externalUrl ?? null, + record.createdAt, + ) + }, + async get(artifactId) { + const row = selectOne.get(artifactId) + return row ? mapArtifact(row) : null + }, + async list(runId) { + return byRun.all(runId).map(mapArtifact) + }, + async delete(artifactId) { + db.prepare('DELETE FROM artifacts WHERE artifact_id = ?').run(artifactId) + }, + async deleteForRun(runId) { + db.prepare('DELETE FROM artifacts WHERE run_id = ?').run(runId) + }, + }) +} +``` + +### Blobs: the bytes + +`BlobStore` is a small object store. `withGenerationPersistence` writes each +generated file under the key `artifacts//`, so a +prefix-filtered `list({ prefix: 'artifacts//' })` enumerates a run's +media. `put` accepts any `BlobBody` (a stream, buffer, string, or `Blob`); the +helper below normalizes it to bytes. `list` matches `prefix` literally and pages +with a keyset cursor. + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineBlobStore } from '@tanstack/ai-persistence' +import type { + BlobBody, + BlobObject, + BlobRecord, +} from '@tanstack/ai-persistence' + +async function toBytes(body: BlobBody): Promise { + if (typeof body === 'string') return new TextEncoder().encode(body) + if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)) + if (ArrayBuffer.isView(body)) { + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength).slice() + } + if (body instanceof Blob) { + return new Uint8Array(await body.arrayBuffer()) + } + // ReadableStream: drain it into one buffer. + const reader = body.getReader() + const chunks: Array = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +function mapBlobRecord(row: Record): BlobRecord { + return { + key: String(row.key), + ...(row.size != null ? { size: Number(row.size) } : {}), + ...(typeof row.etag === 'string' ? { etag: row.etag } : {}), + ...(typeof row.content_type === 'string' + ? { contentType: row.content_type } + : {}), + ...(typeof row.custom_metadata_json === 'string' + ? { customMetadata: JSON.parse(row.custom_metadata_json) } + : {}), + ...(row.created_at != null ? { createdAt: Number(row.created_at) } : {}), + ...(row.updated_at != null ? { updatedAt: Number(row.updated_at) } : {}), + } +} + +function blobObject(record: BlobRecord, bytes: Uint8Array): BlobObject { + return { + ...record, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice()) + controller.close() + }, + }), + arrayBuffer() { + const copy = new ArrayBuffer(bytes.byteLength) + new Uint8Array(copy).set(bytes) + return Promise.resolve(copy) + }, + text: () => Promise.resolve(new TextDecoder().decode(bytes)), + } +} + +function createBlobStore(db: DatabaseSync) { + const upsert = db.prepare( + `INSERT INTO blobs + (key, bytes, size, etag, content_type, custom_metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + bytes = excluded.bytes, size = excluded.size, etag = excluded.etag, + content_type = excluded.content_type, + custom_metadata_json = excluded.custom_metadata_json, + updated_at = excluded.updated_at`, + ) + const selectCreated = db.prepare('SELECT created_at FROM blobs WHERE key = ?') + const selectOne = db.prepare('SELECT * FROM blobs WHERE key = ?') + return defineBlobStore({ + async put(key, body, options) { + const bytes = await toBytes(body) + const now = Date.now() + const prior = selectCreated.get(key) + const createdAt = + prior && prior.created_at != null ? Number(prior.created_at) : now + const etag = String(now) + upsert.run( + key, + bytes, + bytes.byteLength, + etag, + options?.contentType ?? null, + options?.customMetadata ? JSON.stringify(options.customMetadata) : null, + createdAt, + now, + ) + return { + key, + size: bytes.byteLength, + etag, + createdAt, + updatedAt: now, + ...(options?.contentType !== undefined + ? { contentType: options.contentType } + : {}), + ...(options?.customMetadata !== undefined + ? { customMetadata: options.customMetadata } + : {}), + } + }, + async get(key) { + const row = selectOne.get(key) + if (!row) return null + const bytes = + row.bytes instanceof Uint8Array ? row.bytes : new Uint8Array() + return blobObject(mapBlobRecord(row), bytes) + }, + async head(key) { + const row = selectOne.get(key) + return row ? mapBlobRecord(row) : null + }, + async delete(key) { + db.prepare('DELETE FROM blobs WHERE key = ?').run(key) + }, + async list(options) { + if (options?.limit === 0) return { objects: [], truncated: false } + // Escape LIKE metacharacters so `prefix` matches literally, then page with + // a keyset cursor (keys strictly greater than the last returned key). + const escaped = (options?.prefix ?? '').replace( + /[\\%_]/g, + (ch) => `\\${ch}`, + ) + const params: Array = [`${escaped}%`] + let where = "key LIKE ? ESCAPE '\\'" + if (options?.cursor !== undefined) { + where += ' AND key > ?' + params.push(options.cursor) + } + let sql = `SELECT * FROM blobs WHERE ${where} ORDER BY key ASC` + const limit = options?.limit + if (limit !== undefined) { + sql += ' LIMIT ?' // fetch one extra row to detect truncation + params.push(limit + 1) + } + const rows = db + .prepare(sql) + .all(...params) + .map(mapBlobRecord) + if (limit !== undefined && rows.length > limit) { + const page = rows.slice(0, limit) + const cursor = page.at(-1)?.key + return { + objects: page, + truncated: true, + ...(cursor !== undefined ? { cursor } : {}), + } + } + return { objects: rows, truncated: false } + }, + }) +} +``` + +### Assemble a generation adapter + +Hand the three stores to `defineAIPersistence` the same way. `jobs` alone is a +valid generation adapter (job records, no byte storage); add `artifacts` + +`blobs` — together — to keep the media: + +```ts +import { DatabaseSync } from 'node:sqlite' +import { defineAIPersistence } from '@tanstack/ai-persistence' +// The three generation store factories and the schema string, from your modules. +import { createArtifactStore } from './artifact-store' +import { createBlobStore } from './blob-store' +import { createGenerationJobStore } from './generation-job-store' +import { GENERATION_SCHEMA_SQL } from './generation-schema' + +export function generationPersistence(options: { + url: string + migrate?: boolean +}) { + const db = new DatabaseSync(options.url) + if (options.migrate) db.exec(GENERATION_SCHEMA_SQL) + return defineAIPersistence({ + stores: { + jobs: createGenerationJobStore(db), + artifacts: createArtifactStore(db), + blobs: createBlobStore(db), + }, + }) +} +``` + +Pass the result to `withGenerationPersistence` on a `generateImage` / +`generateVideo` / … call; see [Generation persistence](./generation-persistence). +You can also fold these stores into an existing chat adapter with +`composePersistence`, so one backend serves both `withPersistence` and +`withGenerationPersistence`. + ## Existing database: map the contracts onto your schema You do not have to create the four tables above. If you already have a database, @@ -707,6 +1192,152 @@ composite identity. A stored `null` is indistinguishable from absence at the typ level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or reject nullish values outright the way the SQLite store above does. +### GenerationJobStore + +The generation counterpart to `RunStore`, keyed by `jobId` rather than a +conversation `threadId`. `withGenerationPersistence` requires this store, not +`runs`. + +```ts +import type { PersistedArtifactRef, TokenUsage } from '@tanstack/ai' + +type GenerationJobStatus = 'running' | 'complete' | 'error' | 'interrupted' + +interface GenerationJobRecord { + jobId: string + threadId?: string // optional link to a chat conversation + activity: string // 'image' | 'audio' | 'tts' | 'video' | 'transcription' + provider: string + model: string + status: GenerationJobStatus + startedAt: number // epoch ms + finishedAt?: number // epoch ms, set once the job reaches a terminal status + error?: { message: string; code?: string } + result?: unknown // terminal result metadata (ids, urls) — never media bytes + artifacts?: Array // present with an artifacts + blobs backend + usage?: TokenUsage +} + +interface GenerationJobStore { + createOrResume(input: { + jobId: string + activity: string + provider: string + model: string + startedAt: number + threadId?: string + status?: GenerationJobStatus + }): Promise + update( + jobId: string, + patch: Partial< + Pick< + GenerationJobRecord, + 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' + > + >, + ): Promise + get(jobId: string): Promise + // The most recent job linked to a thread (greatest `startedAt`), or null. + // Optional, but implement it: `reconstructGeneration` feature-detects it to + // hydrate the last generation for a thread by its stable thread id. + findLatestForThread?(threadId: string): Promise +} +``` + +Implement `createOrResume` idempotently: a second call for an existing `jobId` +returns the stored record unchanged (`startedAt` / `activity` / `provider` / +`model` / `threadId` are not mutated), which is what makes resuming a job safe. +`update` against an unknown `jobId` is a no-op. + +### ArtifactStore + +Metadata rows for persisted media. The bytes live in a `BlobStore`; this record +holds the descriptive metadata and an optional `externalUrl` for reference-only +backends. Provide it together with a `BlobStore` to keep generated bytes. + +```ts +interface ArtifactRecord { + artifactId: string + runId: string + threadId: string + name: string + mimeType: string + size: number + externalUrl?: string + createdAt: number // epoch ms +} + +interface ArtifactStore { + save(record: ArtifactRecord): Promise + get(artifactId: string): Promise + list(runId: string): Promise> // [] when the run has none + delete?(artifactId: string): Promise + deleteForRun?(runId: string): Promise +} +``` + +### BlobStore + +A durable object/blob store for the bytes. `withGenerationPersistence` writes +each generated file under the key `artifacts//`. + +```ts +type BlobBody = + | ReadableStream + | ArrayBuffer + | ArrayBufferView + | string + | Blob + +interface BlobRecord { + key: string + size?: number + etag?: string + contentType?: string + customMetadata?: Record + createdAt?: number // epoch ms first written + updatedAt?: number // epoch ms last overwritten +} + +interface BlobObject extends BlobRecord { + arrayBuffer(): Promise + text(): Promise + body?: ReadableStream +} + +interface BlobListPage { + objects: Array + cursor?: string // present only when `truncated` + truncated?: boolean +} + +interface BlobPutOptions { + contentType?: string + customMetadata?: Record +} + +interface BlobListOptions { + prefix?: string + cursor?: string + limit?: number +} + +interface BlobStore { + put(key: string, body: BlobBody, options?: BlobPutOptions): Promise + get(key: string): Promise + head(key: string): Promise + delete(key: string): Promise + list(options?: BlobListOptions): Promise +} +``` + +`list` matches `prefix` literally and case-sensitively (escape SQL `LIKE` +metacharacters). When `limit` is given and more keys match, return +`truncated: true` with a `cursor`; passing that cursor back returns the +strictly-following keys, so paging visits every key exactly once. `limit: 0` +yields an empty, untruncated page. + ## Where to go next - [Controls](./controls): compose stores from different systems. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index bbcb4f724..a2a254470 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -13,8 +13,10 @@ things back up. It helps with three things: - **After a reload**, show what the last run was: whether it finished, what - failed, and metadata like the result id or video job id. This is a small - snapshot kept in browser storage and read back automatically on mount. + failed, and metadata like the result id or video job id. Depending on the mode + you choose, that record is either hydrated from the server or kept as a small + snapshot in browser storage — either way it is read back automatically on + mount. - **Keep the generated files.** On the server, save the generated bytes to your own storage so they outlive the provider's expiring URLs. - **While a run is still streaming**, let a dropped connection re-attach to it @@ -28,16 +30,48 @@ matters, or when you need to keep the output: video, batch images, long audio, transcription of a big file. For a quick one-shot image you show and forget, you can skip it. -The browser snapshot never holds the generated bytes, only run identity, status, -errors, and result metadata (like the result id, model, or video job id). -Storing the bytes themselves is a server-side opt-in, shown in +The record never holds the generated bytes, only run identity, status, errors, +and result metadata (like the result id, model, or video job id). Storing the +bytes themselves is a server-side opt-in, shown in [Store the generated bytes](#store-the-generated-bytes). -## Create the server endpoint - -Record each run in a store, and wrap the stream so a reload can re-attach to it: - -```ts group=generation-persistence +## Choose a mode + +Generation persistence mirrors chat's `persistence` option: it takes a boolean +or a storage adapter, and each side of that choice decides who owns the record. + +- **`true`** is server-driven. The client caches nothing; on mount it hydrates + the last generation for its `threadId` from the server and repaints it. The + server owns the job record. +- **a storage adapter** (`persistence: localStoragePersistence()`) is + client-driven. The client writes a small snapshot to browser storage as a run + streams and reads it back on the next mount. +- **`false`** (or omitted) is off: the run lives in memory only and a reload + starts empty. + +Both modes rely on the server keeping a **generation job** record. +`withGenerationPersistence(persistence)` requires a dedicated `jobs` store — a +`GenerationJobStore` keyed by `jobId`, the request id the run mints. A +generation has no conversation of its own, so `threadId` is only an optional +*link* on the job record; the server-driven client uses that link to find the +last generation for a thread. `memoryPersistence()` ships a `jobs` store (plus +`artifacts` + `blobs`), so it works out of the box; any backend that implements +`GenerationJobStore` works the same way (see +[Build your own adapter](./build-your-own-adapter#generation--media-stores)). + +### Server-driven: `persistence: true` + +Pass `persistence: true` and give the hook a stable `threadId`. The client +stores nothing. On mount it hydrates the last generation for that thread from +the server through the connection's read-only JSON GET (`?threadId`), the same +`fetchServerSentEvents` / `fetchHttpStream` adapters `useChat` uses. It never +auto-starts a run — a generation begins only when you call `generate(...)`. + +**Server** — a `POST` that runs the generation with +`withGenerationPersistence`, and a `GET` on the same endpoint that answers the +hydration request with `reconstructGeneration`: + +```ts group=generation-server-driven import { generateImage, generationParamsFromRequest, @@ -46,17 +80,22 @@ import { toServerSentEventsResponse, } from '@tanstack/ai' import { openaiImage } from '@tanstack/ai-openai' -import { withGenerationPersistence } from '@tanstack/ai-persistence' -import { sqlitePersistence } from './sqlite-persistence' +import { + memoryPersistence, + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' -const persistence = sqlitePersistence({ - url: 'file:.tanstack-ai/generation.sqlite', - migrate: true, -}) +// memoryPersistence() ships the `jobs` store generation persistence requires +// (plus `artifacts` + `blobs`). Point it at a durable backend for production. +const persistence = memoryPersistence() export async function POST(request: Request) { const durability = memoryStream(request) - const { input } = await generationParamsFromRequest('image', request) + const { input, threadId } = await generationParamsFromRequest( + 'image', + request, + ) if (typeof input.prompt !== 'string') { throw new Error('This endpoint accepts text image prompts only.') @@ -65,113 +104,105 @@ export async function POST(request: Request) { const stream = generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt, + // Link the job to the thread so the GET below can hydrate the last + // generation for it by thread id. Optional — omit it and the job is + // findable only by its own jobId. + ...(threadId !== undefined ? { threadId } : {}), stream: true, middleware: [withGenerationPersistence(persistence)], }) - // withGenerationPersistence records the run's status and usage. - // durability records the stream so a reload can re-attach to it. + // withGenerationPersistence records the job's status, result metadata, and + // usage in the jobs store. durability records the stream so a reload can + // re-attach to it. return toServerSentEventsResponse(stream, { durability: { adapter: durability }, }) } -export async function GET(request: Request) { - // Replays an in-flight run from the durability log. No provider call here. - return resumeServerSentEventsResponse({ adapter: memoryStream(request) }) +export function GET(request: Request): Response | Promise { + const durability = memoryStream(request) + // A reconnecting client carries a resume cursor (Last-Event-ID / ?offset and + // X-Run-Id / ?runId). Replay the durability log so a run still streaming + // finishes in place. No provider call here. + if (durability.resumeFrom() !== null) { + return resumeServerSentEventsResponse({ adapter: durability }) + } + // Otherwise this is the mount-time hydration GET (?threadId): return the last + // generation job for the thread. Guard access in multi-user apps with the + // `authorize` option (session → owned thread/job). + return reconstructGeneration(persistence, request) } ``` -Use the matching request kind for audio, TTS, video, or transcription. -`./sqlite-persistence` is the hand-rolled adapter from -[Build your own adapter](./build-your-own-adapter) — any adapter with a `runs` -store works. `withGenerationPersistence` requires a `runs` store and records -each run in it, keyed by the request id it generates for the run. In -production, swap `memoryStream` for `durableStream` from -`@tanstack/ai-durable-stream`, where requests span processes. - -## Store the generated bytes - -Provider URLs for generated media expire. To keep the output, give your -persistence backend an `artifacts` store (metadata) and a `blobs` store (the -bytes). When both are present, `withGenerationPersistence` writes each generated -file's bytes to the blob store, records an `ArtifactRecord`, and attaches -durable references to the result. `memoryPersistence()` ships both stores, so it -works out of the box; any backend that implements `ArtifactStore` and -`BlobStore` works the same way. - -The bytes land under the blob key `artifacts//`. To fetch a -generated file later (to render it, download it, or hand it to another request), -add a `GET` route that reads the artifact back with the `retrieveArtifact` / -`retrieveBlob` helpers and streams it from your own origin: - -```ts group=generation-bytes -import { - generateImage, - generationParamsFromRequest, - toServerSentEventsResponse, -} from '@tanstack/ai' -import { openaiImage } from '@tanstack/ai-openai' -import { - memoryPersistence, - retrieveArtifact, - retrieveBlob, - withGenerationPersistence, -} from '@tanstack/ai-persistence' +`reconstructGeneration(persistence, request)` reads a `?jobId` (preferred) or +`?threadId` from the query and returns `{ resumeSnapshot, activeRun }`: +`resumeSnapshot` is the last job mapped to a client snapshot (its status, +result metadata, error, and — while still running — a `resumeState` cursor), +and `activeRun` is `{ runId }` when that job is still generating. It requires a +`jobs` store and resolves the latest job for a thread through the store's +`findLatestForThread`. It does **not** enforce tenancy on its own — pass its +`authorize` option before exposing it on a public route. -const persistence = memoryPersistence() +**Client** — a connection, a stable `threadId`, and `persistence: true`: -export async function POST(request: Request) { - const { input } = await generationParamsFromRequest('image', request) +```tsx +import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' - if (typeof input.prompt !== 'string') { - throw new Error('This endpoint accepts text image prompts only.') - } +const connection = fetchServerSentEvents('/api/generate/image') - const stream = generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: input.prompt, - stream: true, - middleware: [withGenerationPersistence(persistence)], +export function HeroImageGenerator({ threadId }: { threadId: string }) { + const image = useGenerateImage({ + id: 'hero-image', + threadId, + connection, + persistence: true, }) - return toServerSentEventsResponse(stream) -} - -// Serve a stored artifact's bytes by id. This is a plain file endpoint: it -// serves one stored file, it does not resume a run or rebuild a conversation. -export async function GET(request: Request) { - const artifactId = new URL(request.url).searchParams.get('id') - if (!artifactId) return new Response('missing id', { status: 400 }) - - const artifact = await retrieveArtifact(persistence, artifactId) - if (!artifact) return new Response('not found', { status: 404 }) - - const blob = await retrieveBlob(persistence, artifact) - if (!blob) return new Response('not found', { status: 404 }) + return ( +
+ - return new Response(blob.body ?? (await blob.arrayBuffer()), { - headers: { - 'content-type': artifact.mimeType, - 'content-length': String(artifact.size), - }, - }) + {image.resumeSnapshot?.status === 'running' ? ( +

A run was still generating when the page last closed…

+ ) : null} + {image.resumeSnapshot?.status === 'complete' ? ( +

+ Last run finished + {image.resumeSnapshot.result?.id + ? ` (${image.resumeSnapshot.result.id})` + : ''} + . +

+ ) : null} + {image.resumeSnapshot?.error ? ( +

Last run failed: {image.resumeSnapshot.error.message}

+ ) : null} +
+ ) } ``` -`memoryPersistence` keeps everything in process memory, which is right for -development and tests; point `artifacts` / `blobs` at a durable backend for -production. Control what gets captured with `withGenerationPersistence`'s -`extractArtifacts` (return your own descriptors) and `nameArtifact` (name each -file) options. On the client, the observed references show up on the generation -hook as `resultArtifacts` (and `pendingArtifacts` while streaming), so the UI -can link straight to this serve route. +You never fetch or seed the snapshot yourself. A reload and the same thread +opened on another device follow the identical path, because the thread id is +the stable key and the server resolves everything from it — no loader, no extra +props. Reach for this mode when you do not want run records in the browser, or +when the same generation must show up on another device. -## Show the last run after a reload +### Client-driven: a storage adapter Pass a storage adapter as `persistence`, and give the hook a stable `id`. The -client writes a snapshot as the run streams and reads it back (validated) when -the component mounts: +client writes a snapshot to browser storage as the run streams and reads it back +(validated) when the component mounts. No server `GET` endpoint is needed for +hydration — the record lives entirely in the browser: ```tsx import { localStoragePersistence } from '@tanstack/ai-client' @@ -240,13 +271,96 @@ the value yourself and pass it as `initialResumeSnapshot` — that skips the automatic read. Validate untrusted values with `parseGenerationResumeSnapshot` from `@tanstack/ai-client`. +## Store the generated bytes + +Provider URLs for generated media expire. To keep the output, give your +persistence backend an `artifacts` store (metadata) and a `blobs` store (the +bytes), **on top of** the `jobs` store `withGenerationPersistence` already +requires. Byte storage is an optional add-on: when both `artifacts` and `blobs` +are present, `withGenerationPersistence` writes each generated file's bytes to +the blob store, records an `ArtifactRecord`, and attaches durable references to +the result and the job record. `memoryPersistence()` ships all three stores, so +it works out of the box; any backend that implements `ArtifactStore` and +`BlobStore` (see +[Build your own adapter](./build-your-own-adapter#generation--media-stores)) +works the same way. + +The bytes land under the blob key `artifacts//`. To fetch a +generated file later (to render it, download it, or hand it to another request), +add a `GET` route that reads the artifact back with the `retrieveArtifact` / +`retrieveBlob` helpers and streams it from your own origin: + +```ts group=generation-bytes +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + memoryPersistence, + retrieveArtifact, + retrieveBlob, + withGenerationPersistence, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() + +export async function POST(request: Request) { + const { input } = await generationParamsFromRequest('image', request) + + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + const stream = generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + stream: true, + middleware: [withGenerationPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} + +// Serve a stored artifact's bytes by id. This is a plain file endpoint: it +// serves one stored file, it does not resume a run or rebuild a conversation. +export async function GET(request: Request) { + const artifactId = new URL(request.url).searchParams.get('id') + if (!artifactId) return new Response('missing id', { status: 400 }) + + const artifact = await retrieveArtifact(persistence, artifactId) + if (!artifact) return new Response('not found', { status: 404 }) + + const blob = await retrieveBlob(persistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + }, + }) +} +``` + +`memoryPersistence` keeps everything in process memory, which is right for +development and tests; point `jobs` / `artifacts` / `blobs` at a durable backend +for production. Control what gets captured with `withGenerationPersistence`'s +`extractArtifacts` (return your own descriptors) and `nameArtifact` (name each +file) options. On the client, the observed references show up on the generation +hook as `resultArtifacts` (and `pendingArtifacts` while streaming), so the UI +can link straight to this serve route. + ## Reconnect to a run that is still streaming -The server endpoint above already wires this: a `durability` adapter on +The server-driven endpoint above already wires this: a `durability` adapter on `toServerSentEventsResponse`, plus a `GET` handler that replays the run from the -log. On the client there is nothing to add. A connection dropped mid-generation -re-attaches on its own through `fetchServerSentEvents` or `fetchHttpStream`, the -same adapters `useChat` uses. +log when the request carries a resume cursor. On the client there is nothing to +add. A connection dropped mid-generation re-attaches on its own through +`fetchServerSentEvents` or `fetchHttpStream`, the same adapters `useChat` uses. +In production, swap `memoryStream` for `durableStream` from +`@tanstack/ai-durable-stream`, where requests span processes. A full page reload is different: the hooks never start or resume a run on mount, and the snapshot alone cannot re-attach to the stream — it records that @@ -255,11 +369,12 @@ reload as informational ("a run was still going when the page closed"). See [Resumable Streams](../resumable-streams/overview) for the durability contract, production adapters, and the one-time-side-effects note. -## What the browser snapshot holds +## What the record holds -The browser snapshot holds run identity and result metadata (ids, model, -status, a video `jobId`, an expiry timestamp), never the generated bytes, and it -does not carry the provider's media URL. To keep the media itself, persist the -bytes on the server with an `artifacts` + `blobs` backend (see +The generation record — whether hydrated from the server or read from the +browser snapshot — holds run identity and result metadata (ids, model, status, +a video `jobId`, an expiry timestamp), never the generated bytes, and it does +not carry the provider's media URL. To keep the media itself, persist the bytes +on the server with an `artifacts` + `blobs` backend (see [Store the generated bytes](#store-the-generated-bytes)) and serve them from your own origin. diff --git a/packages/ai-angular/src/inject-generate-audio.ts b/packages/ai-angular/src/inject-generate-audio.ts index e8cfe19c4..e9312d667 100644 --- a/packages/ai-angular/src/inject-generate-audio.ts +++ b/packages/ai-angular/src/inject-generate-audio.ts @@ -24,7 +24,7 @@ export interface InjectGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< InjectGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index 4c6fdbe43..957a71c2d 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -37,8 +37,19 @@ export interface InjectGenerateVideoOptions { id?: string body?: ReactiveOption> devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot onResult?: (result: VideoGenerateResult) => TOutput | null | void @@ -125,6 +136,7 @@ export function injectGenerateVideo( ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.threadId !== undefined && { threadId: options.threadId }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index afa736d62..0df2a9ccd 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -40,8 +40,19 @@ export interface InjectGenerationOptions { body?: ReactiveOption> /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -157,6 +168,7 @@ export function injectGeneration< ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.threadId !== undefined && { threadId: options.threadId }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 260f17526..20979d3a1 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -453,6 +453,40 @@ async function fetchThreadHydration( } } +/** + * GET the hydration endpoint for a generation thread and parse its JSON + * `{ resumeSnapshot, activeRun }` body. Mirrors {@link fetchThreadHydration} for + * the generation clients: keyed on the stable thread id, it returns the last + * generation's resume snapshot (re-validated client-side before adoption) and — + * if a run is still generating — a cursor. Shared by every fetch/XHR adapter. + */ +async function fetchGenerationHydration( + fetchClient: typeof globalThis.fetch, + url: string, + headers: Record, + credentials: RequestCredentials, + threadId: string, +): Promise { + const response = await fetchClient(withSearchParams(url, { threadId }), { + method: 'GET', + headers: { Accept: 'application/json', ...headers }, + credentials, + }) + assertResponseOk(response) + const data = (await response.json()) as { + resumeSnapshot?: GenerationHydrationResult['resumeSnapshot'] + activeRun?: { runId?: unknown } | null + } + const activeRun = + data.activeRun && typeof data.activeRun.runId === 'string' + ? { runId: data.activeRun.runId } + : null + return { + resumeSnapshot: data.resumeSnapshot ?? null, + activeRun, + } +} + /** Yield SSE stream events (chunk + offset) from a fetch Response body. */ async function* responseToSSEEvents( response: Response, @@ -707,6 +741,35 @@ export interface ConnectConnectionAdapter { abortSignal?: AbortSignal, runContext?: RunAgentInputContext, ) => AsyncIterable + /** + * Fetch server-driven hydration for a generation `threadId`: the last + * generation's resume snapshot, plus a cursor to a run still generating if + * one exists. The generation client calls this itself on mount when + * `persistence: true` (no loader/prop) and repaints the snapshot — it never + * auto-starts a run. Read-only JSON GET (`?threadId`), so it is + * transport-agnostic. Optional and feature-detected exactly like the chat + * `hydrate` handler. + */ + hydrateGeneration?: (threadId: string) => Promise +} + +/** + * Server-resolved hydration for a generation thread. `resumeSnapshot` is the + * last generation's lightweight snapshot (validated client-side before it is + * adopted); `activeRun` is a cursor to a run still generating for the thread + * (or `null`). Structurally matches `@tanstack/ai-persistence`'s + * `reconstructGeneration` response — the client never imports that package. + */ +export interface GenerationHydrationResult { + resumeSnapshot: { + schemaVersion?: 1 + resumeState: { threadId?: string; runId: string } | null + status: 'idle' | 'running' | 'complete' | 'error' + result?: unknown + error?: { message: string; code?: string } + activity?: string + } | null + activeRun: { runId: string } | null } /** @@ -1197,6 +1260,18 @@ export function fetchServerSentEvents( threadId, ) }, + async hydrateGeneration(threadId) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = + typeof options === 'function' ? await options() : options + return fetchGenerationHydration( + resolvedOptions.fetchClient ?? fetch, + resolvedUrl, + mergeHeaders(resolvedOptions.headers), + resolvedOptions.credentials || 'same-origin', + threadId, + ) + }, } } @@ -1340,6 +1415,18 @@ export function fetchHttpStream( threadId, ) }, + async hydrateGeneration(threadId) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = + typeof options === 'function' ? await options() : options + return fetchGenerationHydration( + resolvedOptions.fetchClient ?? fetch, + resolvedUrl, + mergeHeaders(resolvedOptions.headers), + resolvedOptions.credentials || 'same-origin', + threadId, + ) + }, } } @@ -1666,6 +1753,19 @@ export function xhrServerSentEvents( threadId, ) }, + async hydrateGeneration(threadId) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = await resolveXhrConnectionOptions(options) + // Hydration is a non-streaming JSON GET, so fetch is fine even for the + // XHR-backed streaming adapter. + return fetchGenerationHydration( + fetch, + resolvedUrl, + mergeHeaders(resolvedOptions.headers), + resolvedOptions.withCredentials ? 'include' : 'same-origin', + threadId, + ) + }, } } @@ -1736,6 +1836,19 @@ export function xhrHttpStream( threadId, ) }, + async hydrateGeneration(threadId) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = await resolveXhrConnectionOptions(options) + // Hydration is a non-streaming JSON GET, so fetch is fine even for the + // XHR-backed streaming adapter. + return fetchGenerationHydration( + fetch, + resolvedUrl, + mergeHeaders(resolvedOptions.headers), + resolvedOptions.withCredentials ? 'include' : 'same-origin', + threadId, + ) + }, } } diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index fead619b1..3bc769a07 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -9,6 +9,7 @@ import { parseSSEResponse } from './sse-parser' import type { StreamChunk } from '@tanstack/ai/client' import type { ConnectConnectionAdapter, + GenerationHydrationResult, RunAgentInputContext, } from './connection-adapters' import type { @@ -92,6 +93,9 @@ export class GenerationClient< private readonly devtoolsBridge: GenerationDevtoolsBridge private readonly threadId: string private readonly resumePersistence: GenerationPersistence | undefined + // Server-driven mode (`persistence: true`): no local snapshot store; on mount + // the client hydrates the last generation for `threadId` from the server. + private readonly serverDriven: boolean = false private body: Record private result: TOutput | null = null private input: TInput | null = null @@ -120,11 +124,22 @@ export class GenerationClient< ), ) { this.uniqueId = options.id ?? this.generateUniqueId('generation') - this.threadId = this.uniqueId + // The wire/hydration thread key. Server-driven mode needs a stable key, so + // prefer an explicit `threadId`, then `id`, then a generated id. + this.threadId = options.threadId ?? this.uniqueId this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} - this.resumePersistence = options.persistence + // `persistence` is `false`/omitted (ephemeral), `true` (server-driven: cache + // nothing, hydrate the last generation for `threadId` on mount), or a storage + // adapter (client-driven: cache the lightweight resume snapshot locally). + // Only the server-driven mode leaves `resumePersistence` undefined AND turns + // on mount hydration. + if (options.persistence === true) { + this.serverDriven = true + } else if (options.persistence) { + this.resumePersistence = options.persistence + } this.resumeSnapshot = options.initialResumeSnapshot this.callbacksRef = { @@ -147,6 +162,19 @@ export class GenerationClient< // After callbacksRef is assigned: hydration may fire // onResumeSnapshotChange synchronously if an adapter resolves sync. this.maybeHydrateResumeSnapshot() + + // Server-driven (`persistence: true`): the client caches nothing locally and + // re-hydrates the last generation for its stable threadId from the server on + // mount. Best-effort and non-blocking; it never auto-starts a run. + if (this.serverDriven && this.connection?.hydrateGeneration) { + this.hydrateFromServer() + } else if (this.serverDriven) { + // `persistence: true` without a hydrate-capable connection can never + // restore anything — warn rather than silently no-op. + console.warn( + '[TanStack AI] `persistence: true` (server-driven) needs a connection that implements `hydrateGeneration` (e.g. `fetchServerSentEvents` / `fetchHttpStream`). With a plain `fetcher` or no connection, nothing is persisted or restored.', + ) + } } private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions { @@ -667,6 +695,36 @@ export class GenerationClient< this.callbacksRef.onResumeSnapshotChange?.(snapshot) } + /** + * Server-driven mount hydration (`persistence: true`). The client holds no + * local snapshot; on mount it asks the server — keyed by the stable threadId — + * for the last generation's resume snapshot, validates it, and repaints it. It + * never auto-starts a run. Best-effort and non-blocking: a failure leaves the + * client empty rather than throwing, and a `generate()` that starts first owns + * the client (hydration then backs off, mirroring the chat client). + */ + private hydrateFromServer(): void { + const hydrate = this.connection?.hydrateGeneration + if (!hydrate) return + // A send that already started owns the client; don't stomp it. + if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + void (async () => { + let res: GenerationHydrationResult + try { + res = await hydrate(this.threadId) + } catch { + return + } + if (!res.resumeSnapshot) return + const snapshot = parseGenerationResumeSnapshot(res.resumeSnapshot) + if (!snapshot) return + // Re-check: a send may have started while the fetch was in flight. + if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + this.resumeSnapshot = snapshot + this.callbacksRef.onResumeSnapshotChange?.(snapshot) + })() + } + private async persistResumeSnapshot( snapshot: GenerationResumeSnapshot, ): Promise { diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index f7229276f..929332e78 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -117,6 +117,23 @@ export interface GenerationResumeSnapshot { */ export type GenerationPersistence = ChatStorageAdapter +/** + * The `persistence` option for a generation client. Mirrors the chat client's + * {@link ChatPersistenceOption}. + * + * - `false` (default) / omitted: ephemeral. Nothing is written; a reload starts + * from empty. + * - `true`: server-driven. Nothing is cached in the browser. On mount the client + * hydrates the last generation for its `threadId` from the server (via the + * connection's `hydrateGeneration` handler) and repaints that snapshot — it + * never auto-starts a run. Requires a connection that implements + * `hydrateGeneration`. + * - a {@link GenerationPersistence} adapter: client-driven. The lightweight + * resume snapshot is cached in the browser under `generation:` as a run + * streams and read back (validated) on mount. + */ +export type GenerationPersistenceOption = boolean | GenerationPersistence + // =========================== // Event Constants // =========================== @@ -190,6 +207,14 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { /** Unique identifier for this generation client instance */ id?: string + /** + * The thread id for this generation, stable across reloads. Used as the AG-UI + * thread key on the wire AND, in server-driven mode (`persistence: true`), as + * the key the client hydrates the last generation under on mount. Falls back + * to `id`, then to a generated id. + */ + threadId?: string + /** Additional body parameters to send with connect-based adapter requests */ body?: Record @@ -208,15 +233,22 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { initialResumeSnapshot?: GenerationResumeSnapshot /** - * Optional client-side storage adapter for the lightweight generation resume - * snapshot. Accepts any {@link ChatStorageAdapter} — including the shared - * `localStoragePersistence` / `sessionStoragePersistence` / - * `indexedDBPersistence` factories. The client writes the snapshot under the - * key `generation:` as a run streams, and reads it back (validated) on - * construction unless `initialResumeSnapshot` is provided. Generated media - * bytes are never written. + * How this generation persists across reloads. See + * {@link GenerationPersistenceOption}. + * + * - Omit or `false`: ephemeral, in-memory only. + * - `true`: server-driven. The client caches nothing and, on mount, hydrates + * the last generation for its `threadId` from the server (needs a connection + * with a `hydrateGeneration` handler). It repaints the snapshot but never + * auto-starts a run. + * - a {@link GenerationPersistence} adapter (any {@link ChatStorageAdapter}, + * including the shared `localStoragePersistence` / `sessionStoragePersistence` + * / `indexedDBPersistence` factories): client-driven. The client writes the + * lightweight snapshot under the key `generation:` as a run streams, and + * reads it back (validated) on construction unless `initialResumeSnapshot` is + * provided. Generated media bytes are never written. */ - persistence?: GenerationPersistence + persistence?: GenerationPersistenceOption /** * Factory that constructs the devtools bridge. Default is a no-op diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index 70e96072e..796bb7cfe 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -78,6 +78,7 @@ export type { GenerationErrorSnapshot, GenerationEventSnapshot, GenerationPersistence, + GenerationPersistenceOption, GenerationClientOptions, GenerationFetcher, GenerationFetcherOptions, @@ -152,6 +153,7 @@ export { StreamReconnectLimitError, type ConnectConnectionAdapter, type ConnectionAdapter, + type GenerationHydrationResult, type FetchConnectionOptions, type ReconnectOptions, type ResumableConnectConnectionAdapter, diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index e50be403b..14dda2eb4 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -9,6 +9,7 @@ import { parseSSEResponse } from './sse-parser' import type { StreamChunk } from '@tanstack/ai/client' import type { ConnectConnectionAdapter, + GenerationHydrationResult, RunAgentInputContext, } from './connection-adapters' import type { @@ -99,6 +100,9 @@ export class VideoGenerationClient { private readonly devtoolsBridge: VideoDevtoolsBridge private readonly threadId: string private readonly resumePersistence: GenerationPersistence | undefined + // Server-driven mode (`persistence: true`): no local snapshot store; on mount + // the client hydrates the last generation for `threadId` from the server. + private readonly serverDriven: boolean = false private body: Record private result: TOutput | null = null @@ -130,11 +134,22 @@ export class VideoGenerationClient { ), ) { this.uniqueId = options.id ?? this.generateUniqueId('video') - this.threadId = this.uniqueId + // The wire/hydration thread key. Server-driven mode needs a stable key, so + // prefer an explicit `threadId`, then `id`, then a generated id. + this.threadId = options.threadId ?? this.uniqueId this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} - this.resumePersistence = options.persistence + // `persistence` is `false`/omitted (ephemeral), `true` (server-driven: cache + // nothing, hydrate the last generation for `threadId` on mount), or a storage + // adapter (client-driven: cache the lightweight resume snapshot locally). + // Only the server-driven mode leaves `resumePersistence` undefined AND turns + // on mount hydration. + if (options.persistence === true) { + this.serverDriven = true + } else if (options.persistence) { + this.resumePersistence = options.persistence + } this.resumeSnapshot = options.initialResumeSnapshot this.callbacksRef = { @@ -161,6 +176,19 @@ export class VideoGenerationClient { // After callbacksRef is assigned: hydration may fire // onResumeSnapshotChange synchronously if an adapter resolves sync. this.maybeHydrateResumeSnapshot() + + // Server-driven (`persistence: true`): the client caches nothing locally and + // re-hydrates the last generation for its stable threadId from the server on + // mount. Best-effort and non-blocking; it never auto-starts a run. + if (this.serverDriven && this.connection?.hydrateGeneration) { + this.hydrateFromServer() + } else if (this.serverDriven) { + // `persistence: true` without a hydrate-capable connection can never + // restore anything — warn rather than silently no-op. + console.warn( + '[TanStack AI] `persistence: true` (server-driven) needs a connection that implements `hydrateGeneration` (e.g. `fetchServerSentEvents` / `fetchHttpStream`). With a plain `fetcher` or no connection, nothing is persisted or restored.', + ) + } } private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions { @@ -753,6 +781,36 @@ export class VideoGenerationClient { this.callbacksRef.onResumeSnapshotChange?.(snapshot) } + /** + * Server-driven mount hydration (`persistence: true`). The client holds no + * local snapshot; on mount it asks the server — keyed by the stable threadId — + * for the last generation's resume snapshot, validates it, and repaints it. It + * never auto-starts a run. Best-effort and non-blocking: a failure leaves the + * client empty rather than throwing, and a `generate()` that starts first owns + * the client (hydration then backs off, mirroring the chat client). + */ + private hydrateFromServer(): void { + const hydrate = this.connection?.hydrateGeneration + if (!hydrate) return + // A send that already started owns the client; don't stomp it. + if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + void (async () => { + let res: GenerationHydrationResult + try { + res = await hydrate(this.threadId) + } catch { + return + } + if (!res.resumeSnapshot) return + const snapshot = parseGenerationResumeSnapshot(res.resumeSnapshot) + if (!snapshot) return + // Re-check: a send may have started while the fetch was in flight. + if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + this.resumeSnapshot = snapshot + this.callbacksRef.onResumeSnapshotChange?.(snapshot) + })() + } + private async persistResumeSnapshot( snapshot: GenerationResumeSnapshot, ): Promise { diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index 7ecd8ecc0..469b46716 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -6,7 +6,10 @@ import { VideoGenerationClient, } from '../src' import type { StreamChunk } from '@tanstack/ai/client' -import type { ConnectConnectionAdapter } from '../src/connection-adapters' +import type { + ConnectConnectionAdapter, + GenerationHydrationResult, +} from '../src/connection-adapters' import type { GenerationResumeSnapshot, GenerationPersistence } from '../src' // Helper to create a mock connect-based adapter from StreamChunks @@ -1769,4 +1772,179 @@ describe('GenerationClient', () => { expect(connectSpy).not.toHaveBeenCalled() }) }) + + describe('server-driven persistence (persistence: true)', () => { + const completedHydration: GenerationHydrationResult = { + resumeSnapshot: { + schemaVersion: 1, + resumeState: null, + status: 'complete', + result: { id: 'result-1', model: 'image-model' }, + }, + activeRun: null, + } + + function createHydratingConnection( + result: GenerationHydrationResult, + ): { + connection: ConnectConnectionAdapter + hydrateGeneration: ReturnType + } { + const hydrateGeneration = vi.fn(async () => result) + return { + connection: { async *connect() {}, hydrateGeneration }, + hydrateGeneration, + } + } + + it('adopts the server snapshot on mount, keyed by threadId, without a local store', async () => { + const { connection, hydrateGeneration } = + createHydratingConnection(completedHydration) + const onResumeSnapshotChange = vi.fn() + const client = new GenerationClient({ + threadId: 'thread-server', + connection, + persistence: true, + onResumeSnapshotChange, + }) + + await waitForCondition(() => { + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'complete', + result: { id: 'result-1' }, + }) + }) + expect(hydrateGeneration).toHaveBeenCalledWith('thread-server') + expect(onResumeSnapshotChange).toHaveBeenCalledWith( + expect.objectContaining({ status: 'complete' }), + ) + }) + + it('does nothing when the connection exposes no hydrateGeneration', async () => { + const client = new GenerationClient({ + threadId: 'thread-server', + connection: createMockConnection([]), + persistence: true, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(client.getResumeSnapshot()).toBeUndefined() + }) + + it('ignores an invalid server snapshot without throwing', async () => { + const { connection } = createHydratingConnection({ + // Structurally invalid status — must be rejected by the client's parser. + resumeSnapshot: { + resumeState: null, + status: 'bogus', + } as unknown as GenerationHydrationResult['resumeSnapshot'], + activeRun: null, + }) + const client = new GenerationClient({ + threadId: 'thread-server', + connection, + persistence: true, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(client.getResumeSnapshot()).toBeUndefined() + }) + + it('does not stomp a run that a generate() started before hydration resolves', async () => { + const hydrateGeneration = vi.fn( + async (): Promise => completedHydration, + ) + const connection: ConnectConnectionAdapter = { + async *connect() { + yield { + type: EventType.RUN_STARTED, + runId: 'run-live', + threadId: 'thread-server', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED, + runId: 'run-live', + threadId: 'thread-server', + finishReason: 'stop', + timestamp: Date.now(), + } satisfies StreamChunk + }, + hydrateGeneration, + } + const client = new GenerationClient({ + threadId: 'thread-server', + connection, + persistence: true, + }) + + // Start a live run immediately; it owns the client and hydration backs off. + await client.generate({ prompt: 'test' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(client.getStatus()).toBe('success') + // The live run's terminal snapshot (from run-live), not the server's. + expect(client.getResumeSnapshot()).toMatchObject({ status: 'complete' }) + expect(client.getResumeSnapshot()?.result?.id).toBeUndefined() + }) + + it('leaves the client-driven adapter path unchanged (writes locally, no hydrateGeneration)', async () => { + const hydrateGeneration = vi.fn() + const store = new Map() + const persistence = { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: unknown) => { + store.set(key, value) + }), + removeItem: vi.fn((key: string) => { + store.delete(key) + }), + } as unknown as GenerationPersistence + const client = new GenerationClient({ + id: 'local-hero', + connection: { + async *connect() { + yield { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + finishReason: 'stop', + timestamp: Date.now(), + } satisfies StreamChunk + }, + hydrateGeneration, + }, + persistence, + }) + + await client.generate({ prompt: 'test' }) + await waitForCondition(() => { + expect(persistence.setItem).toHaveBeenCalled() + }) + // An adapter is client-driven: the server hydrate path is never used. + expect(hydrateGeneration).not.toHaveBeenCalled() + expect(store.has('generation:local-hero')).toBe(true) + }) + + it('adopts the server snapshot for the video client too', async () => { + const { connection, hydrateGeneration } = + createHydratingConnection(completedHydration) + const client = new VideoGenerationClient({ + threadId: 'thread-video', + connection, + persistence: true, + }) + + await waitForCondition(() => { + expect(client.getResumeSnapshot()).toMatchObject({ status: 'complete' }) + }) + expect(hydrateGeneration).toHaveBeenCalledWith('thread-video') + }) + }) }) diff --git a/packages/ai-persistence/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md index 7463890fe..f032660cb 100644 --- a/packages/ai-persistence/skills/ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -44,12 +44,23 @@ the result to `withPersistence`. The core never inspects your tables. | Ships in the package | What it is | | --------------------------------------------------------------------------- | ------------------------------------------------------ | -| `MessageStore` / `RunStore` / `InterruptStore` / `MetadataStore` | The four state contracts | +| `MessageStore` / `RunStore` / `InterruptStore` / `MetadataStore` | The four **chat** state contracts | +| `GenerationJobStore` / `ArtifactStore` / `BlobStore` | The **generation** contracts (job lifecycle + bytes) | | `withPersistence` / `withGenerationPersistence` | Chat + generation middleware | -| `memoryPersistence()` | In-process reference backend (dev, tests) | -| `reconstructChat` | Server hydrate route helper | +| `memoryPersistence()` | In-process reference backend, all seven stores (dev, tests) | +| `reconstructChat` / `reconstructGeneration` | Server hydrate route helpers (chat / generation) | +| `retrieveArtifact` / `retrieveBlob` / `artifactBlobKey` | Serve persisted generation-media bytes back | | `LockStore` / `withLocks` / `InMemoryLockStore` (from `@tanstack/ai/locks`) | Coordination, **not** this package — see ai-core/locks | -| `@tanstack/ai-persistence/testkit` | `runPersistenceConformance` compatibility gate | +| `@tanstack/ai-persistence/testkit` | `runPersistenceConformance` gate (chat state stores) | + +**Chat vs generation stores.** Chat persistence keys on `threadId` and uses +`messages` + optional `runs` / `interrupts` / `metadata`. Generation persistence +keys on `jobId` and uses `jobs` (required by `withGenerationPersistence`) plus an +optional `artifacts` + `blobs` **pair** — provide both or neither — to store the +generated media bytes at blob key `artifacts//`. `threadId` on +a generation is only an optional *link* to a chat, never the job's identity. To +build the R2/D1-backed byte stores for a Worker, see +**ai-persistence/build-cloudflare-artifact-store**. ## Sub-skills @@ -64,12 +75,13 @@ Adding persistence to an app? Pick the recipe that matches what it already runs — each one writes a single `chat-persistence.ts` against the app's existing database client and schema: -| The app runs... | Read | -| ------------------------------------------------ | ------------------------------------------------ | -| Drizzle ORM (SQLite / Postgres / MySQL) | ai-persistence/build-drizzle-adapter/SKILL.md | -| Prisma | ai-persistence/build-prisma-adapter/SKILL.md | -| Cloudflare Workers + D1 (± Durable Object locks) | ai-persistence/build-cloudflare-adapter/SKILL.md | -| Anything else — raw `pg`, Kysely, SQLite, Mongo | ai-persistence/build-custom-adapter/SKILL.md | +| The app runs... | Read | +| -------------------------------------------------------- | ------------------------------------------------------ | +| Drizzle ORM (SQLite / Postgres / MySQL) | ai-persistence/build-drizzle-adapter/SKILL.md | +| Prisma | ai-persistence/build-prisma-adapter/SKILL.md | +| Cloudflare Workers + D1 (± Durable Object locks) | ai-persistence/build-cloudflare-adapter/SKILL.md | +| Cloudflare Workers + R2/D1 for generated media bytes | ai-persistence/build-cloudflare-artifact-store/SKILL.md | +| Anything else — raw `pg`, Kysely, SQLite, Mongo | ai-persistence/build-custom-adapter/SKILL.md | ## State persistence has two halves diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md new file mode 100644 index 000000000..dc9edf01b --- /dev/null +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -0,0 +1,402 @@ +--- +name: ai-persistence/build-cloudflare-artifact-store +description: Use when a Cloudflare Worker needs durable byte storage for TanStack AI generated media (images, audio, video, transcripts) — writes a BlobStore backed by R2 and an ArtifactStore backed by D1 (or KV), composes them onto the generation persistence so withGenerationPersistence persists artifact bytes, and serves them back from a Worker GET route. Includes one-line sketches for S3, GCS, Vercel Blob, Supabase, and a dev filesystem BlobStore. +--- + +# Cloudflare Artifact + Blob Store + +`withGenerationPersistence(persistence)` needs only `stores.jobs` to track a +generation's lifecycle. Add `stores.artifacts` (metadata) **and** `stores.blobs` +(the bytes) — both, or neither — and the middleware also persists the generated +media: image/audio/TTS/video/transcription bytes land at blob key +`artifacts//`, with an `ArtifactRecord` row describing each. + +The deliverable is **one file in the Worker** — e.g. +`src/lib/generation-persistence.ts` — exporting a factory that builds an +`AIPersistence` from the request's R2 + D1 bindings, plus a GET route that serves +artifact bytes with `retrieveArtifact` / `retrieveBlob`. + +Read the sibling **ai-persistence/build-cloudflare-adapter** skill for the +per-request-binding rule, `wrangler` config shape, D1 migration workflow, and the +chat (jobs/messages) side. This skill covers only the two byte-storage stores and +how to compose them. + +## The two contracts, verbatim + +Both come from `@tanstack/ai-persistence`. `defineBlobStore` / `defineArtifactStore` +type an object literal inline (autocomplete + contract checking, no separate +annotation). + +```ts +// BlobStore — the byte layer. R2 backs it. +interface BlobStore { + put(key: string, body: BlobBody, options?: BlobPutOptions): Promise + get(key: string): Promise // metadata + byte accessors + head(key: string): Promise // metadata only + delete(key: string): Promise // no-op if absent + list(options?: BlobListOptions): Promise +} + +// ArtifactStore — the metadata layer. D1 (or KV) backs it. +interface ArtifactStore { + save(record: ArtifactRecord): Promise // insert or overwrite + get(artifactId: string): Promise + list(runId: string): Promise> // [] when none + delete?(artifactId: string): Promise // OPTIONAL + deleteForRun?(runId: string): Promise // OPTIONAL +} +``` + +`BlobBody` is `ReadableStream | ArrayBuffer | ArrayBufferView | +string | Blob` — which is exactly what `R2Bucket.put` accepts, so the body flows +straight through with no conversion. `BlobPutOptions` is +`{ contentType?, customMetadata? }`; `BlobListOptions` is +`{ prefix?, cursor?, limit? }`; `BlobListPage` is +`{ objects: BlobRecord[], cursor?, truncated? }`. + +## 1. BlobStore backed by R2 + +`R2Object` carries `size`, `etag`, `httpMetadata.contentType`, `customMetadata`, +and `uploaded` (a `Date`). `BlobRecord` wants `createdAt` / `updatedAt` as epoch +ms — R2 tracks only the single `uploaded` instant, so map it to both. `get` / +`head` are the byte-body vs metadata-only split; `R2ObjectBody` already exposes +`body`, `arrayBuffer()`, and `text()`, so a `BlobObject` is essentially the R2 +object plus the mapped metadata. + +```ts ignore +import { defineBlobStore } from '@tanstack/ai-persistence' +import type { BlobObject, BlobRecord } from '@tanstack/ai-persistence' + +function toRecord(obj: R2Object): BlobRecord { + const uploaded = obj.uploaded.getTime() + return { + key: obj.key, + size: obj.size, + etag: obj.etag, + ...(obj.httpMetadata?.contentType + ? { contentType: obj.httpMetadata.contentType } + : {}), + ...(obj.customMetadata ? { customMetadata: obj.customMetadata } : {}), + createdAt: uploaded, + updatedAt: uploaded, + } +} + +export function r2BlobStore(bucket: R2Bucket) { + return defineBlobStore({ + async put(key, body, options) { + const obj = await bucket.put(key, body, { + ...(options?.contentType + ? { httpMetadata: { contentType: options.contentType } } + : {}), + ...(options?.customMetadata + ? { customMetadata: options.customMetadata } + : {}), + }) + // R2.put returns null only when an onlyIf precondition fails — not used here. + if (!obj) throw new Error(`R2 put failed for ${key}`) + return toRecord(obj) + }, + + async get(key): Promise { + const obj = await bucket.get(key) + if (!obj) return null + return { + ...toRecord(obj), + body: obj.body, + arrayBuffer: () => obj.arrayBuffer(), + text: () => obj.text(), + } + }, + + async head(key) { + const obj = await bucket.head(key) + return obj ? toRecord(obj) : null + }, + + async delete(key) { + await bucket.delete(key) + }, + + async list(options) { + const page = await bucket.list({ + ...(options?.prefix !== undefined ? { prefix: options.prefix } : {}), + ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}), + ...(options?.limit !== undefined ? { limit: options.limit } : {}), + // R2 omits httpMetadata/customMetadata from list rows unless asked. + include: ['httpMetadata', 'customMetadata'], + }) + return { + objects: page.objects.map(toRecord), + ...(page.truncated ? { cursor: page.cursor, truncated: true } : {}), + } + }, + }) +} +``` + +Invariants that matter (asserted by the conformance testkit): + +- `get` / `head` return `null` for a missing key; `delete` is a silent no-op. +- `put` **overwrites** an existing key. +- `list` filters by `prefix` literally (R2 prefix is a literal byte prefix — no + glob), returns keys in ascending order, and pages via the opaque `cursor` when + `truncated`. R2's own cursor is opaque and satisfies this directly. `limit: 0` + must yield an empty, untruncated page — R2 treats `limit: 0` as "use the + default", so special-case it: `if (options?.limit === 0) return { objects: [] }`. + +## 2. ArtifactStore backed by D1 + +`ArtifactRecord` is `{ artifactId, runId, threadId, name, mimeType, size, +externalUrl?, createdAt }` (`createdAt` epoch ms). One flat table, keyed by +`artifact_id`, indexed by `run_id` for `list`. + +```sql +CREATE TABLE IF NOT EXISTS generation_artifacts ( + artifact_id text PRIMARY KEY NOT NULL, + run_id text NOT NULL, + thread_id text NOT NULL, + name text NOT NULL, + mime_type text NOT NULL, + size integer NOT NULL, + external_url text, + created_at integer NOT NULL +); +CREATE INDEX IF NOT EXISTS generation_artifacts_run ON generation_artifacts (run_id); +``` + +```ts ignore +import { defineArtifactStore } from '@tanstack/ai-persistence' +import type { ArtifactRecord } from '@tanstack/ai-persistence' + +interface ArtifactRow { + artifact_id: string + run_id: string + thread_id: string + name: string + mime_type: string + size: number + external_url: string | null + created_at: number +} + +function fromRow(row: ArtifactRow): ArtifactRecord { + return { + artifactId: row.artifact_id, + runId: row.run_id, + threadId: row.thread_id, + name: row.name, + mimeType: row.mime_type, + size: row.size, + ...(row.external_url != null ? { externalUrl: row.external_url } : {}), + createdAt: row.created_at, + } +} + +export function d1ArtifactStore(db: D1Database) { + return defineArtifactStore({ + async save(record) { + // Insert or overwrite (artifact ids are unique). + await db + .prepare( + `INSERT INTO generation_artifacts + (artifact_id, run_id, thread_id, name, mime_type, size, external_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + run_id = excluded.run_id, thread_id = excluded.thread_id, + name = excluded.name, mime_type = excluded.mime_type, + size = excluded.size, external_url = excluded.external_url, + created_at = excluded.created_at`, + ) + .bind( + record.artifactId, + record.runId, + record.threadId, + record.name, + record.mimeType, + record.size, + record.externalUrl ?? null, + record.createdAt, + ) + .run() + }, + + async get(artifactId) { + const row = await db + .prepare(`SELECT * FROM generation_artifacts WHERE artifact_id = ?`) + .bind(artifactId) + .first() + return row ? fromRow(row) : null + }, + + async list(runId) { + const { results } = await db + .prepare(`SELECT * FROM generation_artifacts WHERE run_id = ?`) + .bind(runId) + .all() + return results.map(fromRow) + }, + + async delete(artifactId) { + await db + .prepare(`DELETE FROM generation_artifacts WHERE artifact_id = ?`) + .bind(artifactId) + .run() + }, + + async deleteForRun(runId) { + await db + .prepare(`DELETE FROM generation_artifacts WHERE run_id = ?`) + .bind(runId) + .run() + }, + }) +} +``` + +Omitting `external_url` from the record when the column is `NULL` keeps records +comparing cleanly against the reference in-memory store. **KV alternative:** if +you have no D1, back `save`/`get` with `KV.put(artifactId, JSON.stringify(record))` +/ `KV.get(artifactId, 'json')`, and maintain a `run:` index key (a JSON +array of artifact ids) for `list` — KV has no query, so `list` needs that +secondary index. + +## 3. Compose and wire + +Bindings are per-request on Workers, so export a **factory**. Combine the byte +stores with a jobs store (and, if this Worker also does chat, the chat stores). +Either build the whole `AIPersistence` with `defineAIPersistence`, or layer the +artifact stores onto an existing chat persistence with `composePersistence`: + +```ts ignore +import { + defineAIPersistence, + composePersistence, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import { r2BlobStore } from './r2-blob-store' +import { d1ArtifactStore } from './d1-artifact-store' +import { d1GenerationJobStore } from './d1-job-store' // your GenerationJobStore + +/** Call inside a request handler — bindings are not available at module scope. */ +export function generationPersistence(env: Env) { + return defineAIPersistence({ + stores: { + jobs: d1GenerationJobStore(env.DB), + artifacts: d1ArtifactStore(env.DB), + blobs: r2BlobStore(env.ARTIFACTS_BUCKET), + }, + }) +} + +// …or add bytes to a persistence that already has chat + jobs: +// composePersistence(chatAndJobsPersistence(env), { +// overrides: { +// artifacts: d1ArtifactStore(env.DB), +// blobs: r2BlobStore(env.ARTIFACTS_BUCKET), +// }, +// }) +``` + +`withGenerationPersistence` throws if exactly one of `artifacts` / `blobs` is +present — provide both or neither. Wire it as generation middleware: + +```ts ignore +import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { withGenerationPersistence } from '@tanstack/ai-persistence' +import { generationPersistence } from './lib/generation-persistence' + +export default { + async fetch(request: Request, env: Env) { + const { prompt, threadId } = await request.json() + const stream = generateImage({ + adapter: openaiImage('gpt-image-1'), + prompt, + threadId, // optional link recorded on the job + artifacts + stream: true, + middleware: [withGenerationPersistence(generationPersistence(env))], + }) + return toServerSentEventsResponse(stream) + }, +} +``` + +## 4. Serve the bytes back + +A GET route resolves an `artifactId` to its record and its stored bytes. +`retrieveArtifact` returns the `ArtifactRecord` (or `null` → 404); +`retrieveBlob` returns the `BlobObject` (metadata + a streamable `body`). Both +key off `artifactBlobKey({ runId, artifactId })` internally, so you never build +the key yourself. + +```ts ignore +import { retrieveArtifact, retrieveBlob } from '@tanstack/ai-persistence' +import { generationPersistence } from './lib/generation-persistence' + +export async function GET(request: Request, env: Env) { + const artifactId = new URL(request.url).searchParams.get('id') ?? '' + const persistence = generationPersistence(env) + + // Authorize before serving — derive the owner from the session, never trust + // a client-supplied id. (The record carries runId/threadId to check against.) + const record = await retrieveArtifact(persistence, artifactId) + if (!record) return new Response('Not found', { status: 404 }) + + const blob = await retrieveBlob(persistence, record) // pass the record: no 2nd lookup + if (!blob?.body) return new Response('Not found', { status: 404 }) + + return new Response(blob.body, { + headers: { + 'content-type': record.mimeType, + 'content-length': String(record.size), + }, + }) +} +``` + +To hydrate a **server-driven generation client** (`persistence: true` + a stable +`threadId`) on mount, also expose `reconstructGeneration(persistence, request)` +on a GET that reads `?threadId=` / `?jobId=` — see `ai-core/client-persistence`. + +## Other backends — the contract is tiny, here's how each maps + +`BlobStore` is five methods over an object store. Any of these backs it; swap the +factory, keep everything else. `put` maps to the SDK's upload, `get` to a +download that exposes `body`/`arrayBuffer`/`text`, `head` to a metadata fetch, +`delete` to a delete, `list` to a prefixed, cursor-paged list. + +| Backend | npm | One-line sketch | +| --- | --- | --- | +| **AWS S3** | `@aws-sdk/client-s3` | `put`→`PutObjectCommand`; `get`→`GetObjectCommand` (`Body` is a stream → `body`, `.transformToByteArray()`/`.transformToString()`); `head`→`HeadObjectCommand`; `delete`→`DeleteObjectCommand`; `list`→`ListObjectsV2Command` (`Prefix`, `ContinuationToken`↔`cursor`, `MaxKeys`↔`limit`, `IsTruncated`↔`truncated`). | +| **Google Cloud Storage** | `@google-cloud/storage` | `bucket.file(key)`: `put`→`.save(body, { contentType, metadata })`; `get`→`.createReadStream()` for `body` + `.download()` for bytes; `head`→`.getMetadata()`; `delete`→`.delete({ ignoreNotFound: true })`; `list`→`bucket.getFiles({ prefix, maxResults, pageToken })`. | +| **Vercel Blob** | `@vercel/blob` | `put`→`put(key, body, { access: 'public', contentType })`; `get`→`fetch(head(key).url)` (stream `res.body`); `head`→`head(key)` (returns `null`→catch as absent); `delete`→`del(key)`; `list`→`list({ prefix, cursor, limit })` (`hasMore`↔`truncated`). | +| **Supabase Storage** | `@supabase/supabase-js` | `storage.from(bucket)`: `put`→`.upload(key, body, { contentType, upsert: true })`; `get`→`.download(key)` (returns a `Blob` → `body`/`arrayBuffer`/`text`); `head`→`.info(key)` or list-one; `delete`→`.remove([key])`; `list`→`.list(prefix, { limit })` (offset/limit paging → synthesize a `cursor`). | +| **Filesystem (dev only)** | `node:fs/promises` | Root each key under a dir: `put`→`mkdir(dirname, { recursive: true })` + `writeFile`; `get`→`createReadStream` for `body` + `readFile`; `head`→`stat` (`size`, `mtimeMs`→`updatedAt`); `delete`→`rm(path, { force: true })`; `list`→recursive `readdir` filtered by prefix, sorted, sliced by `limit`, cursor = last key. Not for production — no concurrency guarantees. | + +For each: `contentType` and `customMetadata` ride the SDK's own metadata fields; +`BlobRecord.createdAt`/`updatedAt` come from the object's stored timestamps +(epoch ms); return `null` from `get`/`head` on a not-found rather than throwing. + +## Verify + +The shared `runPersistenceConformance` testkit currently covers the four **state** +stores only (`messages`, `runs`, `interrupts`, `metadata`) — it does **not** +exercise `blobs`, `artifacts`, or `jobs`. So the byte stores need their **own** +tests. Run them against a Miniflare R2 + D1 binding with the migration applied, +reset between runs (see **ai-persistence/build-cloudflare-adapter** for the +`cloudflare:test` harness pattern). Assert against the reference in-memory stores +from `memoryPersistence()` as the oracle, covering at minimum: + +- `put` then `get` round-trips bytes and metadata; `get`/`head` return `null` for + a missing key; `delete` is a silent no-op on an absent key. +- `put` overwrites an existing key (and its `contentType`/`customMetadata`). +- `list` filters by `prefix`, returns ascending keys, pages correctly through the + `cursor` when `truncated`, and returns an empty untruncated page for `limit: 0`. +- The `ArtifactStore`: `save` is insert-or-overwrite, `get` returns `null` when + absent, `list(runId)` returns `[]` for an unknown run, and (if implemented) + `deleteForRun` removes exactly that run's rows. + +An end-to-end check is the strongest signal: run `generateImage` through +`withGenerationPersistence(generationPersistence(env))`, then confirm the blob +exists at `artifacts//` and `retrieveBlob` streams it back. diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index d86ea793a..41964440d 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -6,6 +6,9 @@ export { defineRunStore, defineInterruptStore, defineMetadataStore, + defineGenerationJobStore, + defineArtifactStore, + defineBlobStore, } from './types' export type { MessageStore, @@ -23,6 +26,10 @@ export type { ChatTranscriptPersistence, ChatPersistence, ChatWithInterruptsPersistence, + // Generation job store contract + GenerationJobStatus, + GenerationJobRecord, + GenerationJobStore, // Generation artifact + blob store contracts ArtifactRecord, ArtifactStore, @@ -63,6 +70,13 @@ export type { export { reconstructChat } from './reconstruct' export type { ReconstructChatOptions } from './reconstruct' +// Server helper: rehydrate the last generation job for a client load +export { reconstructGeneration } from './reconstruct-generation' +export type { + ReconstructedGeneration, + ReconstructGenerationOptions, +} from './reconstruct-generation' + // Server helpers: retrieve a persisted generation artifact + its bytes export { retrieveArtifact, retrieveBlob, artifactBlobKey } from './retrieve' diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts index e885837aa..81136ae51 100644 --- a/packages/ai-persistence/src/memory.ts +++ b/packages/ai-persistence/src/memory.ts @@ -8,6 +8,8 @@ import type { BlobObject, BlobRecord, BlobStore, + GenerationJobRecord, + GenerationJobStore, InterruptRecord, InterruptStore, MessageStore, @@ -67,6 +69,54 @@ class MemoryRunStore implements RunStore { } } +class MemoryGenerationJobStore implements GenerationJobStore { + private readonly jobs = new Map() + createOrResume( + input: Pick< + GenerationJobRecord, + 'jobId' | 'activity' | 'provider' | 'model' | 'startedAt' + > & { threadId?: string; status?: GenerationJobRecord['status'] }, + ): Promise { + const existing = this.jobs.get(input.jobId) + if (existing) return Promise.resolve(existing) + const record: GenerationJobRecord = { + jobId: input.jobId, + activity: input.activity, + provider: input.provider, + model: input.model, + status: input.status ?? 'running', + startedAt: input.startedAt, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), + } + this.jobs.set(record.jobId, record) + return Promise.resolve(record) + } + update( + jobId: string, + patch: Partial< + Pick< + GenerationJobRecord, + 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' + > + >, + ): Promise { + const existing = this.jobs.get(jobId) + if (existing) this.jobs.set(jobId, { ...existing, ...patch }) + return Promise.resolve() + } + get(jobId: string): Promise { + return Promise.resolve(this.jobs.get(jobId) ?? null) + } + findLatestForThread( + threadId: string, + ): Promise { + const linked = [...this.jobs.values()] + .filter((job) => job.threadId === threadId) + .sort((a, b) => b.startedAt - a.startedAt) + return Promise.resolve(linked[0] ?? null) + } +} + function byRequestedAt(a: InterruptRecord, b: InterruptRecord): number { return a.requestedAt - b.requestedAt } @@ -372,6 +422,7 @@ class MemoryBlobStore implements BlobStore { interface MemoryPersistenceStores { messages: MessageStore runs: RunStore + jobs: GenerationJobStore interrupts: InterruptStore metadata: MetadataStore artifacts: ArtifactStore @@ -381,14 +432,15 @@ interface MemoryPersistenceStores { /** * In-process reference backend for the full state + generation store set. * - * Returns messages + runs + interrupts + metadata + artifacts + blobs. Locks - * are not included — use `InMemoryLockStore` + `withLocks` from `@tanstack/ai` - * when a test or single-process app needs coordination. + * Returns messages + runs + jobs + interrupts + metadata + artifacts + blobs. + * Locks are not included — use `InMemoryLockStore` + `withLocks` from + * `@tanstack/ai` when a test or single-process app needs coordination. */ export function memoryPersistence() { const stores: MemoryPersistenceStores = { messages: new MemoryMessageStore(), runs: new MemoryRunStore(), + jobs: new MemoryGenerationJobStore(), interrupts: new MemoryInterruptStore(), metadata: new MemoryMetadataStore(), artifacts: new MemoryArtifactStore(), diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index f1b5bfc5e..3a73dca95 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -825,11 +825,11 @@ type InvalidChatPersistence = : false /** - * Generation entrypoint invalid when `runs` is known-absent, or when exactly one + * Generation entrypoint invalid when `jobs` is known-absent, or when exactly one * of `artifacts` / `blobs` is present (artifact persistence needs both). */ type InvalidGenerationPersistence = - StoreIsDefinitelyAbsent extends true + StoreIsDefinitelyAbsent extends true ? true : StoreIsDefinitelyPresent extends true ? StoreIsDefinitelyAbsent @@ -1145,25 +1145,21 @@ export function withPersistence( // --------------------------------------------------------------------------- /** - * Generation-only persistence middleware. Tracks run status (run records) and, - * when `stores.artifacts` + `stores.blobs` are both provided, persists the - * generated media for image, audio, TTS, video, and transcription activities. + * Generation-only persistence middleware. Tracks generation job status (job + * records keyed by `jobId`) and, when `stores.artifacts` + `stores.blobs` are + * both provided, persists the generated media for image, audio, TTS, video, and + * transcription activities. * - * Requires `stores.runs`. + * Requires `stores.jobs`. A generation activity has no conversation, so the job + * is keyed on its own `jobId` (`ctx.runId ?? ctx.requestId`). `ctx.threadId` is + * carried through only as an OPTIONAL *link* to a chat when the caller supplies + * one — it is never the job's primary identity and is never faked from the + * request id. * - * ⚠️ TEMPORARY / WRONG SHAPE — do not extend this design. - * - * Generation jobs must **not** fake `threadId = requestId`. `threadId` is the - * shared conversation key ({@link Scope.threadId} / chat middleware); a - * generation activity has no conversation. Dual-keying chat {@link RunStore} - * with `(runId: requestId, threadId: requestId)` pollutes chat run queries and - * confuses `findActiveRun(threadId)`. - * - * The follow-up generation-persistence work should introduce a dedicated job - * store (e.g. `GenerationJobStore` keyed by `jobId` / `requestId`) and optional - * later artifact storage — not reuse chat `RunStore` / `MessageStore`. An - * optional `threadId` on a job is only a *link* to a chat when the product - * needs it, never the job's primary identity. + * On success the terminal result metadata (ids, urls — never media bytes) and, + * when artifact persistence is on, the persisted artifact refs are captured onto + * the job record so a server-authoritative client can hydrate the last + * generation for a thread via {@link reconstructGeneration}. */ export function withGenerationPersistence( persistence: AIPersistence & ValidGenerationPersistence, @@ -1175,32 +1171,63 @@ export function withGenerationPersistence( ): GenerationMiddleware { validateGenerationPersistenceStores(persistence) const { wantsArtifactPersistence } = resolvePersistencePlan(persistence) - const runStore = persistence.stores.runs - if (!runStore) { + const jobs = persistence.stores.jobs + if (!jobs) { // validateGenerationPersistenceStores already throws; this narrows for TypeScript. - throw new Error('Generation persistence requires stores.runs.') + throw new Error('Generation persistence requires stores.jobs.') } + const jobIdOf = (ctx: GenerationMiddlewareContext): string => + ctx.runId ?? ctx.requestId + return { name: 'generation-persistence', async onStart(ctx: GenerationMiddlewareContext) { - // STOPGAP ONLY — see function JSDoc. Do not copy this pattern. - await createOrResumeRun(runStore, ctx.requestId, ctx.requestId) - if (!wantsArtifactPersistence) return + const jobId = jobIdOf(ctx) + const threadId = ctx.threadId + await jobs.createOrResume({ + jobId, + activity: ctx.activity, + provider: ctx.provider, + model: ctx.model, + startedAt: Date.now(), + ...(threadId !== undefined ? { threadId } : {}), + }) + + // Extract + persist artifact bytes (media → blobs, metadata → artifacts) + // and merge the resulting refs onto the result. Gated on artifact stores. + if (wantsArtifactPersistence) { + ctx.resultTransforms?.push(async (result) => { + const refs = await persistGenerationArtifacts( + persistence, + opts, + ctx, + result, + ) + if (refs.length === 0) return undefined + const existing = objectValue(result)?.artifacts + return { + ...(objectValue(result) ?? {}), + artifacts: [...(Array.isArray(existing) ? existing : []), ...refs], + } + }) + } + + // Always capture the terminal result metadata + any artifact refs onto the + // job record. Registered AFTER the artifact transform so it observes the + // fully-merged result (with the artifact refs attached). `result` is + // metadata/urls only — the media bytes already live in the blob store. ctx.resultTransforms?.push(async (result) => { - const refs = await persistGenerationArtifacts( - persistence, - opts, - ctx, + const rawArtifacts = objectValue(result)?.artifacts + const artifacts = Array.isArray(rawArtifacts) + ? rawArtifacts.filter(isArtifactRef) + : [] + await jobs.update(jobId, { result, - ) - if (refs.length === 0) return undefined - const existing = objectValue(result)?.artifacts - return { - ...(objectValue(result) ?? {}), - artifacts: [...(Array.isArray(existing) ? existing : []), ...refs], - } + ...(artifacts.length > 0 ? { artifacts } : {}), + }) + return undefined }) }, @@ -1208,18 +1235,34 @@ export function withGenerationPersistence( ctx: GenerationMiddlewareContext, info: GenerationFinishInfo, ) { - await completeRun(runStore, ctx.requestId, info.usage) + await jobs.update(jobIdOf(ctx), { + status: 'complete', + finishedAt: Date.now(), + ...(info.usage ? { usage: info.usage } : {}), + }) }, async onError(ctx: GenerationMiddlewareContext, info: GenerationErrorInfo) { - await failRun(runStore, ctx.requestId, info.error) + await jobs.update(jobIdOf(ctx), { + status: 'error', + finishedAt: Date.now(), + error: { + message: + info.error instanceof Error + ? info.error.message + : String(info.error), + }, + }) }, async onAbort( ctx: GenerationMiddlewareContext, _info: GenerationAbortInfo, ) { - await interruptRun(runStore, ctx.requestId) + await jobs.update(jobIdOf(ctx), { + status: 'interrupted', + finishedAt: Date.now(), + }) }, } } diff --git a/packages/ai-persistence/src/reconstruct-generation.ts b/packages/ai-persistence/src/reconstruct-generation.ts new file mode 100644 index 000000000..2c49d4215 --- /dev/null +++ b/packages/ai-persistence/src/reconstruct-generation.ts @@ -0,0 +1,184 @@ +import { validateReconstructGenerationStores } from './types' +import type { AIPersistence, GenerationJobRecord } from './types' + +/** + * The JSON body `reconstructGeneration` returns and a server-authoritative + * client hydrates from on mount. + * + * `resumeSnapshot` mirrors the last generation job for the requested thread (or + * a specific job id): its terminal/running `status`, the `result` metadata and + * `error` it recorded, the `activity` it ran, and a `resumeState` cursor + * (present only while the job is still `running`) the client can use to tail the + * live generation. `null` when there is no matching job. + * + * `activeRun` is `{ runId }` when the resolved job is still `running`, else + * `null` — the parallel of {@link ReconstructedChat.activeRun}. + */ +export interface ReconstructedGeneration { + resumeSnapshot: { + schemaVersion: 1 + resumeState: { threadId?: string; runId: string } | null + status: 'idle' | 'running' | 'complete' | 'error' + result?: unknown + error?: { message: string; code?: string } + activity?: string + } | null + activeRun: { runId: string } | null +} + +export interface ReconstructGenerationOptions { + /** Query parameter carrying the thread id. Defaults to `threadId`. */ + param?: string + /** Query parameter carrying the job id. Defaults to `jobId`. */ + jobParam?: string + /** + * Authorize access to the requested generation before loading it. + * + * ⚠️ Without this, any caller who knows or guesses `?threadId=` / `?jobId=` + * receives the generation's status and result metadata. Multi-user / + * multi-tenant deployments **must** supply an authorization check (session → + * owned thread/job) or resolve a validated id in the route. + * + * Called with whichever id was supplied — the `jobId` when present, else the + * `threadId`. Return: + * - `true` to allow the load + * - `false` for a default `403` response + * - a `Response` to return as-is (e.g. `401` with a body) + */ + authorize?: ( + id: string, + request: Request, + ) => boolean | Response | Promise +} + +/** + * Map the persisted job status to the client-facing resume-snapshot status. + * An `interrupted` job surfaces as `error` — the client has no live run to + * resume, and an interrupted generation produced no usable result. + */ +function snapshotStatus( + status: GenerationJobRecord['status'], +): 'running' | 'complete' | 'error' { + switch (status) { + case 'running': + return 'running' + case 'complete': + return 'complete' + case 'error': + case 'interrupted': + return 'error' + } +} + +function jobToSnapshot( + job: GenerationJobRecord, +): NonNullable { + const status = snapshotStatus(job.status) + return { + schemaVersion: 1, + resumeState: + status === 'running' + ? { + runId: job.jobId, + ...(job.threadId !== undefined ? { threadId: job.threadId } : {}), + } + : null, + status, + ...(job.result !== undefined ? { result: job.result } : {}), + ...(job.error !== undefined ? { error: job.error } : {}), + ...(job.activity !== undefined ? { activity: job.activity } : {}), + } +} + +function jsonResponse(body: ReconstructedGeneration): Response { + return new Response(JSON.stringify(body), { + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + }) +} + +/** + * Build the JSON `Response` a server-authoritative client hydrates a generation + * from on load. Reads a `?jobId=` (preferred) or `?threadId=` from the request + * query and returns `{ resumeSnapshot, activeRun }` + * ({@link ReconstructedGeneration}): + * + * - Resolves the job by `jobId` via `stores.jobs.get`, else the latest job + * linked to `threadId` via the optional `stores.jobs.findLatestForThread`. + * - `resumeSnapshot` — the job mapped to a client snapshot (status, result, + * error, activity, and a `resumeState` cursor while still running), or `null`. + * - `activeRun` — `{ runId }` when the job is still generating, else `null`. + * + * Requires `stores.jobs`. Returns `{ resumeSnapshot: null, activeRun: null }` + * when no id is supplied or no matching job exists, so the caller never has to + * special-case a first load. + * + * This helper does **not** enforce tenancy by itself. Pass + * {@link ReconstructGenerationOptions.authorize} (or wrap the call in your own + * session gate) before exposing it on a public route. + * + * ```ts + * export async function GET(request: Request) { + * return reconstructGeneration(persistence, request, { + * authorize: async (id, req) => { + * const userId = await getSessionUserId(req) + * return userId != null && (await userOwnsThread(userId, id)) + * }, + * }) + * } + * ``` + */ +export async function reconstructGeneration( + persistence: AIPersistence, + request: Request, + options?: ReconstructGenerationOptions, +): Promise { + validateReconstructGenerationStores(persistence) + const jobStore = persistence.stores.jobs + if (!jobStore) { + // validateReconstructGenerationStores already throws; this narrows for TS. + throw new Error('reconstructGeneration requires stores.jobs.') + } + + const params = new URL(request.url).searchParams + const jobParam = options?.jobParam ?? 'jobId' + const threadParam = options?.param ?? 'threadId' + const jobId = params.get(jobParam) ?? '' + const threadId = params.get(threadParam) ?? '' + + const id = jobId || threadId + if (!id) { + return jsonResponse({ resumeSnapshot: null, activeRun: null }) + } + + if (options?.authorize) { + const decision = await options.authorize(id, request) + if (decision instanceof Response) { + return decision + } + if (!decision) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + }) + } + } + + const job = jobId + ? await jobStore.get(jobId) + : ((await jobStore.findLatestForThread?.(threadId)) ?? null) + + if (!job) { + return jsonResponse({ resumeSnapshot: null, activeRun: null }) + } + + return jsonResponse({ + resumeSnapshot: jobToSnapshot(job), + activeRun: job.status === 'running' ? { runId: job.jobId } : null, + }) +} diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index 4f17782fe..fd91af204 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -1,4 +1,9 @@ -import type { ModelMessage, Scope, TokenUsage } from '@tanstack/ai' +import type { + ModelMessage, + PersistedArtifactRef, + Scope, + TokenUsage, +} from '@tanstack/ai' // Re-export the shared identity type so app code can import Scope from either // `@tanstack/ai` or `@tanstack/ai-persistence`. See {@link Scope} security notes: @@ -150,6 +155,94 @@ export interface RunStore { findActiveRun: (threadId: string) => Promise } +export type GenerationJobStatus = + | 'running' + | 'complete' + | 'error' + | 'interrupted' + +/** + * A single generation job (one `generateImage` / `generateVideo` / … call). + * + * Its primary identity is `jobId` (the run/request id the activity mints) — a + * generation has no conversation, so `threadId` is only an OPTIONAL *link* to a + * chat when the product wants one (e.g. to correlate a generation with the + * thread that triggered it). Do not key generation state on the chat + * {@link RunStore} / {@link Scope.threadId}. + * + * `result` holds terminal result METADATA (ids, model, urls, a video jobId), + * never the media bytes — those live in a {@link BlobStore}. `artifacts` are the + * durable {@link PersistedArtifactRef}s, present only when byte storage is on. + * + * @property startedAt - Epoch ms when the job was first created. + * @property finishedAt - Epoch ms when the job reached a terminal status. + */ +export interface GenerationJobRecord { + jobId: string + /** Optional link to the chat conversation that triggered this generation. */ + threadId?: string + /** `'image' | 'audio' | 'tts' | 'video' | 'transcription'`. */ + activity: string + provider: string + model: string + status: GenerationJobStatus + startedAt: number + finishedAt?: number + error?: { message: string; code?: string } + /** Terminal result metadata (ids, model, urls). Never the media bytes. */ + result?: unknown + /** Durable artifact references, when an artifacts + blobs backend is used. */ + artifacts?: Array + usage?: TokenUsage +} + +/** + * Durable store for generation job records — the generation counterpart to + * {@link RunStore}, keyed by `jobId` rather than a conversation `threadId`. + */ +export interface GenerationJobStore { + /** + * Create a job record, or return the existing one if `jobId` is already + * present (resume). + * + * INVARIANT (idempotency): a second call for a `jobId` returns the existing + * record unchanged; `startedAt`/`activity`/`provider`/`model`/`threadId` are + * not mutated. `status` defaults to `'running'` on first creation. + */ + createOrResume: ( + input: Pick< + GenerationJobRecord, + 'jobId' | 'activity' | 'provider' | 'model' | 'startedAt' + > & { threadId?: string; status?: GenerationJobStatus }, + ) => Promise + /** + * Patch a job record's mutable fields. + * + * INVARIANT: patching a `jobId` that does not exist is a **no-op** — it must + * not throw and must not create a record. + */ + update: ( + jobId: string, + patch: Partial< + Pick< + GenerationJobRecord, + 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' + > + >, + ) => Promise + /** Return the job record for `jobId`, or `null` if none exists. */ + get: (jobId: string) => Promise + /** + * The most recent job linked to `threadId`, or `null`. OPTIONAL — callers + * feature-detect it (`store.findLatestForThread?.(threadId)`). Lets a + * server-authoritative client hydrate the last generation for a thread by the + * stable thread id, without handling a job id. + */ + findLatestForThread?: ( + threadId: string, + ) => Promise +} + /** Lifecycle status of a human-in-the-loop interrupt. */ export type InterruptStatus = 'pending' | 'resolved' | 'cancelled' @@ -290,6 +383,20 @@ export function defineInterruptStore(store: InterruptStore): InterruptStore { export function defineMetadataStore(store: MetadataStore): MetadataStore { return store } +/** Type a {@link GenerationJobStore} implementation inline. */ +export function defineGenerationJobStore( + store: GenerationJobStore, +): GenerationJobStore { + return store +} +/** Type an {@link ArtifactStore} implementation inline. */ +export function defineArtifactStore(store: ArtifactStore): ArtifactStore { + return store +} +/** Type a {@link BlobStore} implementation inline. */ +export function defineBlobStore(store: BlobStore): BlobStore { + return store +} /** * Metadata row describing a persisted artifact (generated media, tool output). @@ -430,6 +537,7 @@ export interface AIPersistenceStores { runs?: RunStore interrupts?: InterruptStore metadata?: MetadataStore + jobs?: GenerationJobStore artifacts?: ArtifactStore blobs?: BlobStore } @@ -602,6 +710,7 @@ export type ComposedAIPersistenceStores< const storeKeys = [ 'messages', 'runs', + 'jobs', 'interrupts', 'metadata', 'artifacts', @@ -640,9 +749,10 @@ export function validateChatPersistenceStores( } /** - * Generation middleware entrypoint rule: `runs` is required (run lifecycle is - * the only generation state this middleware tracks). When artifact persistence - * is used, `artifacts` and `blobs` must be provided together. + * Generation middleware entrypoint rule: `jobs` is required (the generation job + * lifecycle is keyed on `jobId`, not a chat conversation `threadId`). When + * artifact persistence is used, `artifacts` and `blobs` must be provided + * together. */ export function validateGenerationPersistenceStores( persistence: AIPersistence, @@ -655,8 +765,8 @@ export function validateGenerationPersistenceStores( 'Generation artifact persistence requires both stores.artifacts and stores.blobs.', ) } - if (!persistence.stores.runs) { - throw new Error('Generation persistence requires stores.runs.') + if (!persistence.stores.jobs) { + throw new Error('Generation persistence requires stores.jobs.') } } @@ -672,6 +782,21 @@ export function validateReconstructChatStores( } } +/** + * Server hydrate entrypoint rule for generation: `jobs` is required. The job + * store resolves the latest generation for a thread (or a specific job id), so + * a server-authoritative client can hydrate the last generation's status, + * result, and artifact refs on load. + */ +export function validateReconstructGenerationStores( + persistence: AIPersistence, +): void { + validatePersistenceStoreKeys(persistence) + if (!persistence.stores.jobs) { + throw new Error('reconstructGeneration requires stores.jobs.') + } +} + export function defineAIPersistence( persistence: AIPersistence>, ): AIPersistence { diff --git a/packages/ai-persistence/tests/error-abort.test.ts b/packages/ai-persistence/tests/error-abort.test.ts index 091382666..ac572924a 100644 --- a/packages/ai-persistence/tests/error-abort.test.ts +++ b/packages/ai-persistence/tests/error-abort.test.ts @@ -245,9 +245,10 @@ function imageAdapterThatThrows(thrown: unknown): ImageAdapter { } } -// A generation activity's only identity is `requestId` (auto-generated), so the -// integration tests capture it via a probe middleware, and the direct-drive -// tests set `requestId` to the pre-created run's id. +// A generation job is keyed on `jobId` (`ctx.runId ?? ctx.requestId`). With no +// runId supplied the auto-generated `requestId` is the jobId, so the integration +// tests capture it via a probe middleware, and the direct-drive tests set +// `requestId` to the pre-created job's id. function generationContext(requestId: string): GenerationMiddlewareContext { return { requestId, @@ -261,7 +262,7 @@ function generationContext(requestId: string): GenerationMiddlewareContext { } describe('generation persistence error/abort hooks', () => { - it('marks the run failed when generation throws', async () => { + it('marks the job errored when generation throws', async () => { const persistence = memoryPersistence() let requestId = '' @@ -280,12 +281,12 @@ describe('generation persistence error/abort hooks', () => { }), ).rejects.toThrow('image boom') - const run = await persistence.stores.runs!.get(requestId) - expect(run?.status).toBe('failed') - expect(run?.error).toBe('image boom') + const job = await persistence.stores.jobs.get(requestId) + expect(job?.status).toBe('error') + expect(job?.error).toEqual({ message: 'image boom' }) }) - it('coerces a non-Error generation failure into the run error string', async () => { + it('coerces a non-Error generation failure into the job error message', async () => { const persistence = memoryPersistence() let requestId = '' @@ -304,18 +305,20 @@ describe('generation persistence error/abort hooks', () => { }), ).rejects.toBeDefined() - const run = await persistence.stores.runs!.get(requestId) - expect(run?.status).toBe('failed') - expect(run?.error).toBe('image string failure') + const job = await persistence.stores.jobs.get(requestId) + expect(job?.status).toBe('error') + expect(job?.error).toEqual({ message: 'image string failure' }) }) - it('marks the run interrupted on generation abort', async () => { + it('marks the job interrupted on generation abort', async () => { const persistence = memoryPersistence() const middleware = withGenerationPersistence(persistence) - await persistence.stores.runs!.createOrResume({ - runId: 'req-abort', - threadId: 'req-abort', + await persistence.stores.jobs.createOrResume({ + jobId: 'req-abort', + activity: 'image', + provider: 'test', + model: 'test-model', startedAt: 1, }) @@ -327,17 +330,19 @@ describe('generation persistence error/abort hooks', () => { } await middleware.onAbort?.(generationContext('req-abort'), abortInfo) - expect((await persistence.stores.runs!.get('req-abort'))?.status).toBe( + expect((await persistence.stores.jobs.get('req-abort'))?.status).toBe( 'interrupted', ) }) - it('coerces a non-Error into the run error string via the onError handler', async () => { + it('coerces a non-Error into the job error message via the onError handler', async () => { const persistence = memoryPersistence() const middleware = withGenerationPersistence(persistence) - await persistence.stores.runs!.createOrResume({ - runId: 'req-err', - threadId: 'req-err', + await persistence.stores.jobs.createOrResume({ + jobId: 'req-err', + activity: 'image', + provider: 'test', + model: 'test-model', startedAt: 1, }) const errorInfo: GenerationErrorInfo = { @@ -346,8 +351,8 @@ describe('generation persistence error/abort hooks', () => { } await middleware.onError?.(generationContext('req-err'), errorInfo) - const run = await persistence.stores.runs!.get('req-err') - expect(run?.status).toBe('failed') - expect(run?.error).toBe('[object Object]') + const job = await persistence.stores.jobs.get('req-err') + expect(job?.status).toBe('error') + expect(job?.error).toEqual({ message: '[object Object]' }) }) }) diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index f2642cecb..0dbc2b17c 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -243,17 +243,100 @@ describe('withGenerationPersistence generation artifacts', () => { }) }) - it('allows run tracking without artifact stores', () => { + it('allows job tracking without artifact stores', () => { const full = memoryPersistence() const persistence = defineAIPersistence({ stores: { - runs: full.stores.runs, + jobs: full.stores.jobs, }, }) expect(() => withGenerationPersistence(persistence)).not.toThrow() }) + it('throws when the job store is missing', () => { + const full = memoryPersistence() + const persistence: AIPersistence = defineAIPersistence({ + stores: { + runs: full.stores.runs, + }, + }) + + expect(() => withGenerationPersistence(persistence)).toThrow( + /Generation persistence requires stores\.jobs/i, + ) + }) + + it('records a job that transitions running -> complete with result + artifacts', async () => { + const persistence = memoryPersistence() + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-job', + runId: 'run-job', + middleware: [withGenerationPersistence(persistence)], + }) + + const job = await persistence.stores.jobs.get('run-job') + expect(job).toMatchObject({ + jobId: 'run-job', + threadId: 'thread-job', + activity: 'image', + provider: 'test-image-provider', + model: 'test-image-model', + status: 'complete', + }) + expect(job?.finishedAt).toEqual(expect.any(Number)) + // Terminal result metadata is captured on the job (never the media bytes). + expect(job?.result).toBeDefined() + // Persisted artifact refs land on the job too. + expect(job?.artifacts).toHaveLength(1) + expect(job?.artifacts?.[0]?.artifactId).toBe(result.artifacts![0]!.artifactId) + }) + + it('links the job to a thread and finds the latest for that thread', async () => { + const persistence = memoryPersistence() + + await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-latest', + runId: 'run-latest-1', + middleware: [withGenerationPersistence(persistence)], + }) + + const latest = + await persistence.stores.jobs.findLatestForThread!('thread-latest') + expect(latest?.jobId).toBe('run-latest-1') + expect(latest?.status).toBe('complete') + }) + + it('records an error job when generation throws', async () => { + const persistence = memoryPersistence() + const adapter = imageAdapter() + adapter.generateImages = vi.fn(async () => { + throw new Error('boom') + }) + + await expect( + generateImage({ + adapter, + prompt: 'make an image', + threadId: 'thread-error', + runId: 'run-error', + middleware: [withGenerationPersistence(persistence)], + }), + ).rejects.toThrow('boom') + + const job = await persistence.stores.jobs.get('run-error') + expect(job).toMatchObject({ + jobId: 'run-error', + status: 'error', + error: { message: 'boom' }, + }) + }) + it('uses custom artifact extraction instead of built-in extraction', async () => { const persistence = memoryPersistence() diff --git a/packages/ai-persistence/tests/memory.test.ts b/packages/ai-persistence/tests/memory.test.ts index 811c3ce51..30d7bc780 100644 --- a/packages/ai-persistence/tests/memory.test.ts +++ b/packages/ai-persistence/tests/memory.test.ts @@ -16,6 +16,7 @@ describe('memoryPersistence', () => { 'artifacts', 'blobs', 'interrupts', + 'jobs', 'messages', 'metadata', 'runs', diff --git a/packages/ai-persistence/tests/persistence-fixtures.ts b/packages/ai-persistence/tests/persistence-fixtures.ts index daf690be1..463738975 100644 --- a/packages/ai-persistence/tests/persistence-fixtures.ts +++ b/packages/ai-persistence/tests/persistence-fixtures.ts @@ -1,4 +1,6 @@ import type { + GenerationJobRecord, + GenerationJobStore, InterruptStore, MessageStore, MetadataStore, @@ -48,6 +50,33 @@ export function createRunStore(): RunStore { } } +export function createGenerationJobStore(): GenerationJobStore { + const jobs = new Map() + return { + createOrResume: (input) => { + const existing = jobs.get(input.jobId) + if (existing) return Promise.resolve(existing) + const record: GenerationJobRecord = { + jobId: input.jobId, + activity: input.activity, + provider: input.provider, + model: input.model, + status: input.status ?? 'running', + startedAt: input.startedAt, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), + } + jobs.set(record.jobId, record) + return Promise.resolve(record) + }, + update: (jobId, patch) => { + const existing = jobs.get(jobId) + if (existing) jobs.set(jobId, { ...existing, ...patch }) + return Promise.resolve() + }, + get: (jobId) => Promise.resolve(jobs.get(jobId) ?? null), + } +} + export function createInterruptStore(): InterruptStore { return { create: () => Promise.resolve(), diff --git a/packages/ai-persistence/tests/persistence-types.test-d.ts b/packages/ai-persistence/tests/persistence-types.test-d.ts index cf5b2d98c..6c30066d8 100644 --- a/packages/ai-persistence/tests/persistence-types.test-d.ts +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -19,6 +19,7 @@ import type { ChatPersistence, ChatTranscriptPersistence, ChatTranscriptStores, + GenerationJobStore, InterruptStore, MessageStore, MetadataStore, @@ -35,6 +36,7 @@ declare const replacementRuns: RunStore & { } declare const interrupts: InterruptStore declare const metadata: MetadataStore +declare const jobs: GenerationJobStore declare const locks: LockStore const messagesOnly = defineAIPersistence({ stores: { messages } }) @@ -130,6 +132,7 @@ expectTypeOf(memoryPersistence()).toEqualTypeOf< AIPersistence<{ messages: MessageStore runs: RunStore + jobs: GenerationJobStore interrupts: InterruptStore metadata: MetadataStore artifacts: ArtifactStore @@ -149,10 +152,12 @@ withPersistence(defineAIPersistence({ stores: { runs } })) // @ts-expect-error a known interrupt store requires a known run store withPersistence(defineAIPersistence({ stores: { interrupts, messages } })) -// Generation requires runs -withGenerationPersistence(defineAIPersistence({ stores: { runs } })) -// @ts-expect-error generation persistence requires runs +// Generation requires jobs +withGenerationPersistence(defineAIPersistence({ stores: { jobs } })) +// @ts-expect-error generation persistence requires jobs withGenerationPersistence(messagesOnly) +// @ts-expect-error a runs store alone does not satisfy generation persistence +withGenerationPersistence(defineAIPersistence({ stores: { runs } })) const chatWithRemovedRuns = composePersistence(base, { overrides: { runs: false }, diff --git a/packages/ai-persistence/tests/persistence-validation.test.ts b/packages/ai-persistence/tests/persistence-validation.test.ts index 7b53bfddc..e504ed911 100644 --- a/packages/ai-persistence/tests/persistence-validation.test.ts +++ b/packages/ai-persistence/tests/persistence-validation.test.ts @@ -4,6 +4,7 @@ import { reconstructChat } from '../src/reconstruct' import { defineAIPersistence } from '../src/types' import type { ChatTranscriptPersistence } from '../src/types' import { + createGenerationJobStore, createInterruptStore, createMessageStore, createRunStore, @@ -60,7 +61,7 @@ describe('persistence store dependency validation', () => { expect(() => withPersistence(persistence)).not.toThrow() }) - it('rejects generation persistence without runs', () => { + it('rejects generation persistence without jobs', () => { const persistence = defineAIPersistence({ stores: { messages: createMessageStore() }, }) @@ -69,12 +70,12 @@ describe('persistence store dependency validation', () => { withGenerationPersistence( persistence as Parameters[0], ), - ).toThrow(/requires stores\.runs/i) + ).toThrow(/requires stores\.jobs/i) }) - it('allows generation persistence with runs', () => { + it('allows generation persistence with jobs', () => { const persistence = defineAIPersistence({ - stores: { runs: createRunStore() }, + stores: { jobs: createGenerationJobStore() }, }) expect(() => withGenerationPersistence(persistence)).not.toThrow() diff --git a/packages/ai-persistence/tests/reconstruct-generation.test.ts b/packages/ai-persistence/tests/reconstruct-generation.test.ts new file mode 100644 index 000000000..4f923a579 --- /dev/null +++ b/packages/ai-persistence/tests/reconstruct-generation.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { memoryPersistence } from '../src/memory' +import { reconstructGeneration } from '../src/reconstruct-generation' +import type { ReconstructedGeneration } from '../src/reconstruct-generation' + +async function body(response: Response): Promise { + return (await response.json()) as ReconstructedGeneration +} + +describe('reconstructGeneration', () => { + it('maps a completed job to a resume snapshot', async () => { + const persistence = memoryPersistence() + await persistence.stores.jobs.createOrResume({ + jobId: 'job-done', + threadId: 'thread-1', + activity: 'image', + provider: 'test-image-provider', + model: 'test-image-model', + startedAt: 1000, + }) + await persistence.stores.jobs.update('job-done', { + status: 'complete', + finishedAt: 2000, + result: { id: 'image-result' }, + }) + + const response = await reconstructGeneration( + persistence, + new Request('http://example.test/api/generation?threadId=thread-1'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + + const parsed = await body(response) + expect(parsed).toEqual({ + resumeSnapshot: { + schemaVersion: 1, + resumeState: null, + status: 'complete', + result: { id: 'image-result' }, + activity: 'image', + }, + activeRun: null, + }) + }) + + it('reports an active run and resume state for a running job', async () => { + const persistence = memoryPersistence() + await persistence.stores.jobs.createOrResume({ + jobId: 'job-live', + threadId: 'thread-live', + activity: 'video', + provider: 'test-video-provider', + model: 'test-video-model', + startedAt: 5000, + }) + + // Resolve by jobId directly. + const parsed = await body( + await reconstructGeneration( + persistence, + new Request('http://example.test/api/generation?jobId=job-live'), + ), + ) + expect(parsed.activeRun).toEqual({ runId: 'job-live' }) + expect(parsed.resumeSnapshot).toMatchObject({ + status: 'running', + resumeState: { runId: 'job-live', threadId: 'thread-live' }, + activity: 'video', + }) + }) + + it('surfaces an interrupted job as error status', async () => { + const persistence = memoryPersistence() + await persistence.stores.jobs.createOrResume({ + jobId: 'job-int', + threadId: 'thread-int', + activity: 'audio', + provider: 'p', + model: 'm', + startedAt: 1, + }) + await persistence.stores.jobs.update('job-int', { + status: 'interrupted', + finishedAt: 2, + }) + + const parsed = await body( + await reconstructGeneration( + persistence, + new Request('http://example.test/api/generation?jobId=job-int'), + ), + ) + expect(parsed.resumeSnapshot?.status).toBe('error') + expect(parsed.resumeSnapshot?.resumeState).toBeNull() + expect(parsed.activeRun).toBeNull() + }) + + it('returns nulls when the id is missing or the thread is unknown', async () => { + const persistence = memoryPersistence() + + const missing = await body( + await reconstructGeneration( + persistence, + new Request('http://example.test/api/generation'), + ), + ) + expect(missing).toEqual({ resumeSnapshot: null, activeRun: null }) + + const unknown = await body( + await reconstructGeneration( + persistence, + new Request('http://example.test/api/generation?threadId=nope'), + ), + ) + expect(unknown).toEqual({ resumeSnapshot: null, activeRun: null }) + }) + + it('returns 403 when authorize returns false', async () => { + const persistence = memoryPersistence() + await persistence.stores.jobs.createOrResume({ + jobId: 'job-secret', + threadId: 'thread-secret', + activity: 'image', + provider: 'p', + model: 'm', + startedAt: 1, + }) + + const response = await reconstructGeneration( + persistence, + new Request('http://example.test/api/generation?jobId=job-secret'), + { authorize: () => false }, + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Forbidden' }) + }) +}) diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index f1e0df0a6..4c5b3f4ae 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -30,8 +30,19 @@ export interface UseGenerateAudioOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index c1619858a..20e53bd13 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -30,8 +30,19 @@ export interface UseGenerateImageOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index 3442f8c26..a51fdc9c6 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -30,8 +30,19 @@ export interface UseGenerateSpeechOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index bdaaaf07c..c54ddb5bc 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -32,8 +32,19 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -164,6 +175,7 @@ export function useGenerateVideo( id: clientId, body: opts.body, ...(opts.persistence !== undefined && { persistence: opts.persistence }), + ...(opts.threadId !== undefined && { threadId: opts.threadId }), ...(opts.initialResumeSnapshot !== undefined && { initialResumeSnapshot: opts.initialResumeSnapshot, }), diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 7c9222871..45f680ae4 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -36,8 +36,19 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -154,6 +165,7 @@ export function useGeneration< id: clientId, body: opts.body, ...(opts.persistence !== undefined && { persistence: opts.persistence }), + ...(opts.threadId !== undefined && { threadId: opts.threadId }), ...(opts.initialResumeSnapshot !== undefined && { initialResumeSnapshot: opts.initialResumeSnapshot, }), diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index 7072601e3..ba6694de4 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -30,8 +30,19 @@ export interface UseSummarizeOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index 3c830f12f..fd1d1e4fe 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -30,8 +30,19 @@ export interface UseTranscriptionOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index 39584b36c..27ded90c1 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -451,6 +451,39 @@ describe('useGeneration', () => { expect(result.current.status).toBe('idle') }) + it('server-driven (persistence: true) hydrates from the connection by threadId without a local store', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: { + schemaVersion: 1 as const, + resumeState: null, + status: 'complete' as const, + result: { id: 'server-result', model: 'image-model' }, + }, + activeRun: null, + })) + + const { result } = renderHook(() => + useGeneration({ + threadId: 'thread-server', + connection: { ...adapter, hydrateGeneration }, + persistence: true, + }), + ) + + await waitFor(() => { + expect(result.current.resumeSnapshot).toMatchObject({ + status: 'complete', + result: { id: 'server-result' }, + }) + }) + expect(hydrateGeneration).toHaveBeenCalledWith('thread-server') + expect(connect).not.toHaveBeenCalled() + expect(result.current.status).toBe('idle') + }) + it('generates normally under React StrictMode (dispose → remount replay)', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createGenerationChunks({ id: 'strict-1' }), diff --git a/packages/ai-solid/src/use-generate-audio.ts b/packages/ai-solid/src/use-generate-audio.ts index 6130d1711..f790b1aa6 100644 --- a/packages/ai-solid/src/use-generate-audio.ts +++ b/packages/ai-solid/src/use-generate-audio.ts @@ -23,7 +23,7 @@ export interface UseGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-image.ts b/packages/ai-solid/src/use-generate-image.ts index 87f1c9c3c..ce13d5a34 100644 --- a/packages/ai-solid/src/use-generate-image.ts +++ b/packages/ai-solid/src/use-generate-image.ts @@ -23,7 +23,7 @@ export interface UseGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-speech.ts b/packages/ai-solid/src/use-generate-speech.ts index e8cff6113..0daf9e1b1 100644 --- a/packages/ai-solid/src/use-generate-speech.ts +++ b/packages/ai-solid/src/use-generate-speech.ts @@ -21,7 +21,7 @@ import type { Accessor } from 'solid-js' */ export interface UseGenerateSpeechOptions extends Pick< UseGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index 799091492..e520d3bc8 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -42,8 +42,19 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -175,6 +186,7 @@ export function useGenerateVideo( ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.threadId !== undefined && { threadId: options.threadId }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index f39dd62b8..f279c1e44 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -44,8 +44,19 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -164,6 +175,7 @@ export function useGeneration< ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.threadId !== undefined && { threadId: options.threadId }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), diff --git a/packages/ai-solid/src/use-summarize.ts b/packages/ai-solid/src/use-summarize.ts index 01f992d4c..b26268123 100644 --- a/packages/ai-solid/src/use-summarize.ts +++ b/packages/ai-solid/src/use-summarize.ts @@ -23,7 +23,7 @@ export interface UseSummarizeOptions< TOutput = SummarizationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-transcription.ts b/packages/ai-solid/src/use-transcription.ts index e67476d4a..a2db667fe 100644 --- a/packages/ai-solid/src/use-transcription.ts +++ b/packages/ai-solid/src/use-transcription.ts @@ -27,7 +27,7 @@ export interface UseTranscriptionOptions< TranscriptionResult, TOutput >, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-audio.svelte.ts b/packages/ai-svelte/src/create-generate-audio.svelte.ts index 12191c14a..f22128892 100644 --- a/packages/ai-svelte/src/create-generate-audio.svelte.ts +++ b/packages/ai-svelte/src/create-generate-audio.svelte.ts @@ -22,7 +22,7 @@ export interface CreateGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< CreateGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-image.svelte.ts b/packages/ai-svelte/src/create-generate-image.svelte.ts index cd8310924..762b6a862 100644 --- a/packages/ai-svelte/src/create-generate-image.svelte.ts +++ b/packages/ai-svelte/src/create-generate-image.svelte.ts @@ -22,7 +22,7 @@ export interface CreateGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< CreateGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-speech.svelte.ts b/packages/ai-svelte/src/create-generate-speech.svelte.ts index 4f14cab56..9f3d6ea6b 100644 --- a/packages/ai-svelte/src/create-generate-speech.svelte.ts +++ b/packages/ai-svelte/src/create-generate-speech.svelte.ts @@ -20,7 +20,7 @@ import type { */ export interface CreateGenerateSpeechOptions extends Pick< CreateGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index 53491d22f..32d29ded5 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -33,8 +33,19 @@ export interface CreateGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -172,6 +183,7 @@ export function createGenerateVideo( ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.threadId !== undefined && { threadId: options.threadId }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index 2863dd055..c7691cf91 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -35,8 +35,19 @@ export interface CreateGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -174,6 +185,7 @@ export function createGeneration< ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.threadId !== undefined && { threadId: options.threadId }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), diff --git a/packages/ai-svelte/src/create-summarize.svelte.ts b/packages/ai-svelte/src/create-summarize.svelte.ts index a25f64d54..38c24b62a 100644 --- a/packages/ai-svelte/src/create-summarize.svelte.ts +++ b/packages/ai-svelte/src/create-summarize.svelte.ts @@ -22,7 +22,7 @@ export interface CreateSummarizeOptions< TOutput = SummarizationResult, > extends Pick< CreateGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-transcription.svelte.ts b/packages/ai-svelte/src/create-transcription.svelte.ts index 1e1bd35b3..ba0134b57 100644 --- a/packages/ai-svelte/src/create-transcription.svelte.ts +++ b/packages/ai-svelte/src/create-transcription.svelte.ts @@ -26,7 +26,7 @@ export interface CreateTranscriptionOptions< TranscriptionResult, TOutput >, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-audio.ts b/packages/ai-vue/src/use-generate-audio.ts index 5655daa6a..ee62ec9b3 100644 --- a/packages/ai-vue/src/use-generate-audio.ts +++ b/packages/ai-vue/src/use-generate-audio.ts @@ -23,7 +23,7 @@ export interface UseGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-image.ts b/packages/ai-vue/src/use-generate-image.ts index 61684a503..13dc2f74d 100644 --- a/packages/ai-vue/src/use-generate-image.ts +++ b/packages/ai-vue/src/use-generate-image.ts @@ -23,7 +23,7 @@ export interface UseGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-speech.ts b/packages/ai-vue/src/use-generate-speech.ts index 210c84592..33734afd1 100644 --- a/packages/ai-vue/src/use-generate-speech.ts +++ b/packages/ai-vue/src/use-generate-speech.ts @@ -21,7 +21,7 @@ import type { DeepReadonly, ShallowRef } from 'vue' */ export interface UseGenerateSpeechOptions extends Pick< UseGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 1f703eec6..80c430015 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -42,8 +42,19 @@ export interface UseGenerateVideoOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -189,6 +200,7 @@ export function useGenerateVideo( ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.threadId !== undefined && { threadId: options.threadId }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index 70c1fe9be..edb69420a 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -44,8 +44,19 @@ export interface UseGenerationOptions { body?: Record /** Display options for TanStack AI Devtools. */ devtools?: AIDevtoolsDisplayOptions - /** Client-side storage adapter for the lightweight resume snapshot. Written under `generation:` as a run streams and read back on mount. Media bytes are never stored. */ - persistence?: GenerationPersistence + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + * - a storage adapter: client-driven — the lightweight snapshot is cached under + * `generation:` as a run streams and read back on mount. Media bytes are + * never stored. + */ + persistence?: boolean | GenerationPersistence + /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot /** @@ -181,6 +192,7 @@ export function useGeneration< ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.threadId !== undefined && { threadId: options.threadId }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), diff --git a/packages/ai-vue/src/use-summarize.ts b/packages/ai-vue/src/use-summarize.ts index 672ccbfc4..c96610ec1 100644 --- a/packages/ai-vue/src/use-summarize.ts +++ b/packages/ai-vue/src/use-summarize.ts @@ -23,7 +23,7 @@ export interface UseSummarizeOptions< TOutput = SummarizationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-transcription.ts b/packages/ai-vue/src/use-transcription.ts index b375a202b..a4f960537 100644 --- a/packages/ai-vue/src/use-transcription.ts +++ b/packages/ai-vue/src/use-transcription.ts @@ -27,7 +27,7 @@ export interface UseTranscriptionOptions< TranscriptionResult, TOutput >, - 'persistence' | 'initialResumeSnapshot' + 'persistence' | 'threadId' | 'initialResumeSnapshot' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index 7fa1601f5..886ab5359 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -7,8 +7,10 @@ description: > client cache). Reload restore, pending interrupts, mid-stream rejoin with delivery durability. Use for SPA reload durability — NOT server history alone. - Also covers generation hooks (useGenerateImage etc.): the same adapters - persist a lightweight resume snapshot under generation:. + Also covers generation hooks (useGenerateImage etc.), same two modes as chat: + client-driven (adapter) persists a lightweight resume snapshot under + generation:; server-driven (persistence: true + threadId) hydrates the last + generation from the server on mount, nothing cached. No extra package: the adapters ship in the framework packages. type: sub-skill library: tanstack-ai @@ -112,13 +114,15 @@ chat's identity _is_ its `threadId`. Without a stable one, each load is a new chat. Generate it server-side or from a route param the user owns; do not randomize per mount. -## Generation hooks: lightweight resume snapshots +## Generation hooks: two modes, mirroring chat The generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGeneration`, -`useSummarize`, `useTranscription`, …) take the **same adapters** via their -`persistence` option, but store something much smaller than chat: a -`GenerationResumeSnapshot` — run identity, status, error, and result metadata -(ids, model, video `jobId`), **never the generated media bytes**. +`useSummarize`, `useTranscription`, …) take the **same `persistence` option** as +`useChat` — `boolean | adapter` — with the same two-mode split. Whichever mode, +what persists is a `GenerationResumeSnapshot`: run identity, status, error, and +result metadata (ids, model, video `jobId`), **never the generated media bytes**. + +### Mode A — client-driven (a storage adapter) ```tsx const image = useGenerateImage({ @@ -130,15 +134,45 @@ const image = useGenerateImage({ // image.resumeState is non-null only WHILE a run is streaming. ``` +- The lightweight snapshot is cached in the browser under `generation:` as a + run streams, and read back on mount. - Hydration is automatic on mount and validated (`parseGenerationResumeSnapshot`); an explicit `initialResumeSnapshot` seed skips it. -- `stop()` marks the record no longer resumable; `reset()` deletes it. -- Nothing auto-runs from a persisted snapshot — `generate(...)` is always - explicit. - The `generation:` key segment means a chat and a generation client can share an id and an adapter without colliding. +### Mode B — server-driven (`persistence: true`) + +```tsx +const image = useGenerateImage({ + threadId, // stable — the key the last generation is hydrated under (falls back to id) + connection: fetchServerSentEvents('/api/generate/image'), + persistence: true, +}) +// After a reload: image.resumeSnapshot is the last generation for `threadId`, +// fetched from the server — nothing was cached in the browser. +``` + +- Nothing is cached client-side. On mount the client hydrates the **last + generation** for its `threadId` from the server via the connection's + `hydrateGeneration` handler (the SSE/HTTP adapters issue a `GET` with + `?threadId=` to the same endpoint URL) and repaints that snapshot. +- The server `GET` returns `reconstructGeneration(persistence, request)` from + `@tanstack/ai-persistence` — it resolves the job by `?jobId=` (preferred) or + the latest job linked to `?threadId=`, and needs `stores.jobs`. Pair it with + `withGenerationPersistence` on the generation route. See + `ai-core/media-generation` and `ai-persistence`. +- Best for multi-device / compliance (no generation metadata in browser + storage), exactly like chat Mode B. + +Common to both modes: + +- `stop()` marks the record no longer resumable; `reset()` deletes it (Mode A) or + clears the in-memory snapshot (Mode B). +- Nothing auto-runs from a hydrated snapshot — `generate(...)` is always + explicit. + ## Common mistakes ### HIGH: No `threadId` diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index c88144c8f..78481c3da 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -563,6 +563,68 @@ if (result.usage?.unitsBilled != null) { For video, the units arrive with the completed result: `getVideoJobStatus()` returns `usage` and emits a `video:usage` devtools event when fal reports it. +### 7. Durable persistence (job lifecycle + artifact bytes) + +To make generations survive a server restart and be re-served later, add +`withGenerationPersistence` from `@tanstack/ai-persistence` as generation +middleware. It requires `stores.jobs` (a `GenerationJobStore`, keyed on `jobId` — +not a chat `threadId`) and, when you also pass an `stores.artifacts` + +`stores.blobs` **pair** (both or neither), it persists the generated media bytes +at blob key `artifacts//` with an `ArtifactRecord` per file. +`memoryPersistence()` ships all three for dev/tests. + +```typescript +import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + withGenerationPersistence, + memoryPersistence, + retrieveArtifact, + retrieveBlob, + reconstructGeneration, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() // swap for your DB/object-store adapter + +export async function POST(req: Request) { + const { prompt, threadId } = await req.json() + return toServerSentEventsResponse( + generateImage({ + adapter: openaiImage('gpt-image-1'), + prompt, + threadId, // optional link recorded on the job + artifacts + stream: true, + middleware: [withGenerationPersistence(persistence)], + }), + ) +} + +// Serve the stored bytes back (GET /api/artifacts?id=…): +export async function GET(req: Request) { + const id = new URL(req.url).searchParams.get('id') ?? '' + const record = await retrieveArtifact(persistence, id) + if (!record) return new Response('Not found', { status: 404 }) + const blob = await retrieveBlob(persistence, record) + if (!blob?.body) return new Response('Not found', { status: 404 }) + return new Response(blob.body, { + headers: { 'content-type': record.mimeType }, + }) +} +``` + +On the client, the generation hooks mirror chat's two persistence modes: +`persistence: ` (client-driven, caches a lightweight snapshot under +`generation:`) or `persistence: true` + a stable `threadId` (server-driven — +hydrates the last generation for the thread on mount via the connection's +`hydrateGeneration` handler, backed by a `reconstructGeneration` GET route). +Neither mode ever stores the media bytes on the client. + +- Building the R2/D1-backed byte stores for a Cloudflare Worker: + **ai-persistence/build-cloudflare-artifact-store**. +- Store contracts, `composePersistence`, and the two client modes end-to-end: + `docs/persistence/generation-persistence.md` and the + `ai-core/client-persistence` sub-skill. + --- ## Common Hook API diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index a883ceba1..cdfaec2b3 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as PersistenceDurabilityRouteImport } from './routes/persistence- import { Route as MiddlewareTestRouteImport } from './routes/middleware-test' import { Route as MarkdownCjkRouteImport } from './routes/markdown-cjk' import { Route as InterruptsTestRouteImport } from './routes/interrupts-test' +import { Route as GenerationPersistenceServerRouteImport } from './routes/generation-persistence-server' import { Route as GenerationPersistenceRouteImport } from './routes/generation-persistence' import { Route as ForeignInterruptRouteImport } from './routes/foreign-interrupt' import { Route as DevtoolsToolsRouteImport } from './routes/devtools-tools' @@ -53,6 +54,7 @@ import { Route as ApiMaxToolCallsWireRouteImport } from './routes/api.max-tool-c import { Route as ApiLazyToolsWireRouteImport } from './routes/api.lazy-tools-wire' import { Route as ApiInterruptsTestRouteImport } from './routes/api.interrupts-test' import { Route as ApiImageRouteImport } from './routes/api.image' +import { Route as ApiGenerationPersistenceServerRouteImport } from './routes/api.generation-persistence-server' import { Route as ApiGenerationPersistenceRouteImport } from './routes/api.generation-persistence' import { Route as ApiForeignInterruptRouteImport } from './routes/api.foreign-interrupt' import { Route as ApiDurableDeliveryRouteImport } from './routes/api.durable-delivery' @@ -95,6 +97,12 @@ const InterruptsTestRoute = InterruptsTestRouteImport.update({ path: '/interrupts-test', getParentRoute: () => rootRouteImport, } as any) +const GenerationPersistenceServerRoute = + GenerationPersistenceServerRouteImport.update({ + id: '/generation-persistence-server', + path: '/generation-persistence-server', + getParentRoute: () => rootRouteImport, + } as any) const GenerationPersistenceRoute = GenerationPersistenceRouteImport.update({ id: '/generation-persistence', path: '/generation-persistence', @@ -295,6 +303,12 @@ const ApiImageRoute = ApiImageRouteImport.update({ path: '/api/image', getParentRoute: () => rootRouteImport, } as any) +const ApiGenerationPersistenceServerRoute = + ApiGenerationPersistenceServerRouteImport.update({ + id: '/api/generation-persistence-server', + path: '/api/generation-persistence-server', + getParentRoute: () => rootRouteImport, + } as any) const ApiGenerationPersistenceRoute = ApiGenerationPersistenceRouteImport.update({ id: '/api/generation-persistence', @@ -390,6 +404,7 @@ export interface FileRoutesByFullPath { '/devtools-tools': typeof DevtoolsToolsRoute '/foreign-interrupt': typeof ForeignInterruptRoute '/generation-persistence': typeof GenerationPersistenceRoute + '/generation-persistence-server': typeof GenerationPersistenceServerRoute '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute @@ -406,6 +421,7 @@ export interface FileRoutesByFullPath { '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute '/api/generation-persistence': typeof ApiGenerationPersistenceRoute + '/api/generation-persistence-server': typeof ApiGenerationPersistenceServerRoute '/api/image': typeof ApiImageRouteWithChildren '/api/interrupts-test': typeof ApiInterruptsTestRoute '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -452,6 +468,7 @@ export interface FileRoutesByTo { '/devtools-tools': typeof DevtoolsToolsRoute '/foreign-interrupt': typeof ForeignInterruptRoute '/generation-persistence': typeof GenerationPersistenceRoute + '/generation-persistence-server': typeof GenerationPersistenceServerRoute '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute @@ -468,6 +485,7 @@ export interface FileRoutesByTo { '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute '/api/generation-persistence': typeof ApiGenerationPersistenceRoute + '/api/generation-persistence-server': typeof ApiGenerationPersistenceServerRoute '/api/image': typeof ApiImageRouteWithChildren '/api/interrupts-test': typeof ApiInterruptsTestRoute '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -515,6 +533,7 @@ export interface FileRoutesById { '/devtools-tools': typeof DevtoolsToolsRoute '/foreign-interrupt': typeof ForeignInterruptRoute '/generation-persistence': typeof GenerationPersistenceRoute + '/generation-persistence-server': typeof GenerationPersistenceServerRoute '/interrupts-test': typeof InterruptsTestRoute '/markdown-cjk': typeof MarkdownCjkRoute '/middleware-test': typeof MiddlewareTestRoute @@ -531,6 +550,7 @@ export interface FileRoutesById { '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/foreign-interrupt': typeof ApiForeignInterruptRoute '/api/generation-persistence': typeof ApiGenerationPersistenceRoute + '/api/generation-persistence-server': typeof ApiGenerationPersistenceServerRoute '/api/image': typeof ApiImageRouteWithChildren '/api/interrupts-test': typeof ApiInterruptsTestRoute '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -579,6 +599,7 @@ export interface FileRouteTypes { | '/devtools-tools' | '/foreign-interrupt' | '/generation-persistence' + | '/generation-persistence-server' | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' @@ -595,6 +616,7 @@ export interface FileRouteTypes { | '/api/durable-delivery' | '/api/foreign-interrupt' | '/api/generation-persistence' + | '/api/generation-persistence-server' | '/api/image' | '/api/interrupts-test' | '/api/lazy-tools-wire' @@ -641,6 +663,7 @@ export interface FileRouteTypes { | '/devtools-tools' | '/foreign-interrupt' | '/generation-persistence' + | '/generation-persistence-server' | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' @@ -657,6 +680,7 @@ export interface FileRouteTypes { | '/api/durable-delivery' | '/api/foreign-interrupt' | '/api/generation-persistence' + | '/api/generation-persistence-server' | '/api/image' | '/api/interrupts-test' | '/api/lazy-tools-wire' @@ -703,6 +727,7 @@ export interface FileRouteTypes { | '/devtools-tools' | '/foreign-interrupt' | '/generation-persistence' + | '/generation-persistence-server' | '/interrupts-test' | '/markdown-cjk' | '/middleware-test' @@ -719,6 +744,7 @@ export interface FileRouteTypes { | '/api/durable-delivery' | '/api/foreign-interrupt' | '/api/generation-persistence' + | '/api/generation-persistence-server' | '/api/image' | '/api/interrupts-test' | '/api/lazy-tools-wire' @@ -766,6 +792,7 @@ export interface RootRouteChildren { DevtoolsToolsRoute: typeof DevtoolsToolsRoute ForeignInterruptRoute: typeof ForeignInterruptRoute GenerationPersistenceRoute: typeof GenerationPersistenceRoute + GenerationPersistenceServerRoute: typeof GenerationPersistenceServerRoute InterruptsTestRoute: typeof InterruptsTestRoute MarkdownCjkRoute: typeof MarkdownCjkRoute MiddlewareTestRoute: typeof MiddlewareTestRoute @@ -782,6 +809,7 @@ export interface RootRouteChildren { ApiDurableDeliveryRoute: typeof ApiDurableDeliveryRoute ApiForeignInterruptRoute: typeof ApiForeignInterruptRoute ApiGenerationPersistenceRoute: typeof ApiGenerationPersistenceRoute + ApiGenerationPersistenceServerRoute: typeof ApiGenerationPersistenceServerRoute ApiImageRoute: typeof ApiImageRouteWithChildren ApiInterruptsTestRoute: typeof ApiInterruptsTestRoute ApiLazyToolsWireRoute: typeof ApiLazyToolsWireRoute @@ -849,6 +877,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InterruptsTestRouteImport parentRoute: typeof rootRouteImport } + '/generation-persistence-server': { + id: '/generation-persistence-server' + path: '/generation-persistence-server' + fullPath: '/generation-persistence-server' + preLoaderRoute: typeof GenerationPersistenceServerRouteImport + parentRoute: typeof rootRouteImport + } '/generation-persistence': { id: '/generation-persistence' path: '/generation-persistence' @@ -1122,6 +1157,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiImageRouteImport parentRoute: typeof rootRouteImport } + '/api/generation-persistence-server': { + id: '/api/generation-persistence-server' + path: '/api/generation-persistence-server' + fullPath: '/api/generation-persistence-server' + preLoaderRoute: typeof ApiGenerationPersistenceServerRouteImport + parentRoute: typeof rootRouteImport + } '/api/generation-persistence': { id: '/api/generation-persistence' path: '/api/generation-persistence' @@ -1307,6 +1349,7 @@ const rootRouteChildren: RootRouteChildren = { DevtoolsToolsRoute: DevtoolsToolsRoute, ForeignInterruptRoute: ForeignInterruptRoute, GenerationPersistenceRoute: GenerationPersistenceRoute, + GenerationPersistenceServerRoute: GenerationPersistenceServerRoute, InterruptsTestRoute: InterruptsTestRoute, MarkdownCjkRoute: MarkdownCjkRoute, MiddlewareTestRoute: MiddlewareTestRoute, @@ -1323,6 +1366,7 @@ const rootRouteChildren: RootRouteChildren = { ApiDurableDeliveryRoute: ApiDurableDeliveryRoute, ApiForeignInterruptRoute: ApiForeignInterruptRoute, ApiGenerationPersistenceRoute: ApiGenerationPersistenceRoute, + ApiGenerationPersistenceServerRoute: ApiGenerationPersistenceServerRoute, ApiImageRoute: ApiImageRouteWithChildren, ApiInterruptsTestRoute: ApiInterruptsTestRoute, ApiLazyToolsWireRoute: ApiLazyToolsWireRoute, diff --git a/testing/e2e/src/routes/api.generation-persistence-server.ts b/testing/e2e/src/routes/api.generation-persistence-server.ts new file mode 100644 index 000000000..12d3250b0 --- /dev/null +++ b/testing/e2e/src/routes/api.generation-persistence-server.ts @@ -0,0 +1,124 @@ +import { createFileRoute } from '@tanstack/react-router' +import { toServerSentEventsResponse } from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Provider-free harness route for the SERVER-DRIVEN generation-persistence + * story (`persistence: true` + `threadId`). It is the server-authoritative + * counterpart to `api.generation-persistence.ts` (the client-driven + * `localStoragePersistence` variant). + * + * The client keeps NO local store; on mount it probes the GET below with a + * `?threadId=` query and repaints from the returned `reconstructGeneration`- + * shaped JSON (`{ resumeSnapshot, activeRun }`). To make the round-trip real, + * POST records the finished job in a module-level in-memory map keyed by + * `threadId`, and GET reads it back — so a full `page.reload()` (empty client + * storage) still restores the last run's status + result metadata FROM THE + * SERVER, exactly the path `reconstructGeneration` serves in production. + * + * We hand-build the JSON here rather than pull `@tanstack/ai-persistence` into + * the e2e app (it is not a dependency) — mirroring the `server-interrupt` + * scenario in `api.persistence-durability.ts`. `reconstructGeneration` itself is + * unit-tested in `@tanstack/ai-persistence`; this proves the CLIENT hydrate. + * + * Exempt from the aimock policy: this route streams a fixed AG-UI sequence and + * never reaches an LLM provider's HTTP layer, so there is nothing to mock. + */ + +// 1x1 transparent PNG — small enough to prove media bytes are NOT persisted. +const TINY_PNG_B64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + +// Server-authoritative record of the last completed generation per thread. In +// production this is a `GenerationJobStore` row; here a process-lifetime map is +// enough for the reload round-trip (the e2e server stays up across reloads). +const completedByThread = new Map< + string, + { id: string; model: string } +>() + +function stringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) { + return undefined + } + const value: unknown = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +function imageRun(threadId: string, runId: string): AsyncIterable { + return (async function* () { + yield { + type: 'RUN_STARTED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'CUSTOM', + name: 'generation:result', + value: { + id: 'image-1', + model: 'mock-image-model', + images: [{ b64Json: TINY_PNG_B64 }], + }, + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + yield { + type: 'RUN_FINISHED', + threadId, + runId, + timestamp: Date.now(), + } as StreamChunk + // The run finished: record the job the way `withGenerationPersistence` + // would, so the GET hydrate below can restore it after a reload. + completedByThread.set(threadId, { id: 'image-1', model: 'mock-image-model' }) + })() +} + +export const Route = createFileRoute('/api/generation-persistence-server')({ + server: { + handlers: { + POST: async ({ request }) => { + // The client sends an AG-UI RunAgentInput body carrying the hook's + // stable `threadId` (via runContext) — the same id the GET hydrate + // probe queries — plus the run id in the X-Run-Id header. + const body: unknown = await request.json() + const threadId = stringField(body, 'threadId') ?? 'generation-server' + const runId = + request.headers.get('X-Run-Id') ?? + stringField(body, 'runId') ?? + `run-${Date.now()}` + return toServerSentEventsResponse(imageRun(threadId, runId)) + }, + + // Server-authoritative hydration: the `persistence: true` client's mount + // probe (`?threadId=`). Returns the same `{ resumeSnapshot, activeRun }` + // shape `reconstructGeneration` produces — a `complete` snapshot once the + // thread has a recorded job, else the empty first-load answer. + GET: ({ request }) => { + const threadId = new URL(request.url).searchParams.get('threadId') ?? '' + const job = threadId ? completedByThread.get(threadId) : undefined + const body = job + ? { + resumeSnapshot: { + schemaVersion: 1, + resumeState: null, + status: 'complete', + result: job, + activity: 'image', + }, + activeRun: null, + } + : { resumeSnapshot: null, activeRun: null } + return new Response(JSON.stringify(body), { + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + }) + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/generation-persistence-server.tsx b/testing/e2e/src/routes/generation-persistence-server.tsx new file mode 100644 index 000000000..ae0720341 --- /dev/null +++ b/testing/e2e/src/routes/generation-persistence-server.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' + +/** + * Server-driven generation-persistence harness (server half). + * + * `persistence: true` + a stable `threadId` — the hook keeps NO local store. + * On mount it hydrates from the endpoint's GET (a `?threadId=` probe answered by + * `/api/generation-persistence-server`), so a full `page.reload()` restores the + * last run's status + result metadata FROM THE SERVER, with `localStorage` + * empty. The generated bytes are never persisted anywhere. + */ + +const THREAD_ID = 'generation-server-thread' +const connection = fetchServerSentEvents('/api/generation-persistence-server') + +export const Route = createFileRoute('/generation-persistence-server')({ + component: GenerationPersistenceServerPage, +}) + +function GenerationPersistenceServerPage() { + const image = useGenerateImage({ + id: 'generation-server', + threadId: THREAD_ID, + connection, + persistence: true, + }) + + // The page is SSR'd; the spec must not click the server-rendered button + // before React attaches handlers. This flag flips only after hydration. + const [hydrated, setHydrated] = useState(false) + useEffect(() => setHydrated(true), []) + + return ( +
+

Generation persistence (server-driven)

+ {hydrated ?
: null} + + +
{image.status}
+
+ {image.resumeSnapshot?.status ?? 'none'} +
+
+ {image.resumeSnapshot?.result?.id ?? 'none'} +
+ + {image.result?.images.map((img, i) => ( + generated + ))} +
+ ) +} diff --git a/testing/e2e/tests/generation-persistence-server.spec.ts b/testing/e2e/tests/generation-persistence-server.spec.ts new file mode 100644 index 000000000..9fdc98273 --- /dev/null +++ b/testing/e2e/tests/generation-persistence-server.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test' + +/** + * Server-driven generation resume-snapshot persistence (`persistence: true`). + * + * The counterpart to `generation-persistence.spec.ts` (client-driven + * `localStoragePersistence`). Here the hook keeps NO local store: as a run + * streams the server records the job, and on mount / after a full + * `page.reload()` the client hydrates the last run FROM THE SERVER via a + * `?threadId=` GET (the `reconstructGeneration` shape). Run status + result + * metadata survive the reload with `localStorage` empty; generated bytes are + * never persisted, and no run is auto-started. + * + * Provider-free: `/api/generation-persistence-server` streams a fixed AG-UI + * sequence and answers the hydrate GET from an in-memory job record, so there + * is no LLM in the loop and nothing to mock (exempt from the aimock policy). + */ + +test.describe('generation persistence (server-driven)', () => { + test('records the run server-side and rehydrates it after reload with no local store', async ({ + page, + }) => { + await page.goto('/generation-persistence-server') + await expect(page.getByTestId('hydration-marker')).toBeAttached() + // Empty first load: the server has no job for this thread yet. + await expect(page.getByTestId('snapshot-status')).toHaveText('none') + + await page.getByTestId('generate-button').click() + await expect(page.getByTestId('snapshot-status')).toHaveText('complete') + await expect(page.getByTestId('snapshot-result-id')).toHaveText('image-1') + await expect(page.getByTestId('generated-image')).toHaveCount(1) + + // Server-driven mode writes NOTHING to browser storage — the record lives + // on the server. No localStorage key mentions the generation id. + const localKeys = await page.evaluate(() => Object.keys(window.localStorage)) + expect(localKeys.some((k) => k.includes('generation-server'))).toBe(false) + + // Reload with empty client storage: the snapshot is restored purely from + // the server hydrate. Nothing auto-runs; the image (never persisted) is gone. + await page.reload() + await expect(page.getByTestId('hydration-marker')).toBeAttached() + await expect(page.getByTestId('snapshot-status')).toHaveText('complete') + await expect(page.getByTestId('snapshot-result-id')).toHaveText('image-1') + await expect(page.getByTestId('client-status')).toHaveText('idle') + await expect(page.getByTestId('generated-image')).toHaveCount(0) + }) +}) From ff17c96b52a07454b164e50bccf9f2bbea983275 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:09:47 +0000 Subject: [PATCH 20/66] ci: apply automated fixes --- packages/ai-client/src/generation-client.ts | 3 +- .../ai-client/src/video-generation-client.ts | 3 +- .../ai-client/tests/generation-client.test.ts | 4 +-- .../skills/ai-persistence/SKILL.md | 34 +++++++++---------- .../build-cloudflare-artifact-store/SKILL.md | 34 +++++++++++-------- packages/ai-persistence/src/memory.ts | 4 +-- .../tests/generation-artifacts.test.ts | 4 ++- .../api.generation-persistence-server.ts | 10 +++--- .../generation-persistence-server.spec.ts | 4 ++- 9 files changed, 53 insertions(+), 47 deletions(-) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 3bc769a07..720de2316 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -719,7 +719,8 @@ export class GenerationClient< const snapshot = parseGenerationResumeSnapshot(res.resumeSnapshot) if (!snapshot) return // Re-check: a send may have started while the fetch was in flight. - if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') + return this.resumeSnapshot = snapshot this.callbacksRef.onResumeSnapshotChange?.(snapshot) })() diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 14dda2eb4..4e1034db4 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -805,7 +805,8 @@ export class VideoGenerationClient { const snapshot = parseGenerationResumeSnapshot(res.resumeSnapshot) if (!snapshot) return // Re-check: a send may have started while the fetch was in flight. - if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') + return this.resumeSnapshot = snapshot this.callbacksRef.onResumeSnapshotChange?.(snapshot) })() diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index 469b46716..4da09c4ac 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -1784,9 +1784,7 @@ describe('GenerationClient', () => { activeRun: null, } - function createHydratingConnection( - result: GenerationHydrationResult, - ): { + function createHydratingConnection(result: GenerationHydrationResult): { connection: ConnectConnectionAdapter hydrateGeneration: ReturnType } { diff --git a/packages/ai-persistence/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md index f032660cb..28c94f48e 100644 --- a/packages/ai-persistence/skills/ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -42,23 +42,23 @@ drives them, an in-memory reference backend, and a conformance testkit. It does stores against whatever you already run — Postgres, SQLite, D1, Mongo — and hand the result to `withPersistence`. The core never inspects your tables. -| Ships in the package | What it is | -| --------------------------------------------------------------------------- | ------------------------------------------------------ | -| `MessageStore` / `RunStore` / `InterruptStore` / `MetadataStore` | The four **chat** state contracts | -| `GenerationJobStore` / `ArtifactStore` / `BlobStore` | The **generation** contracts (job lifecycle + bytes) | -| `withPersistence` / `withGenerationPersistence` | Chat + generation middleware | +| Ships in the package | What it is | +| --------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `MessageStore` / `RunStore` / `InterruptStore` / `MetadataStore` | The four **chat** state contracts | +| `GenerationJobStore` / `ArtifactStore` / `BlobStore` | The **generation** contracts (job lifecycle + bytes) | +| `withPersistence` / `withGenerationPersistence` | Chat + generation middleware | | `memoryPersistence()` | In-process reference backend, all seven stores (dev, tests) | -| `reconstructChat` / `reconstructGeneration` | Server hydrate route helpers (chat / generation) | -| `retrieveArtifact` / `retrieveBlob` / `artifactBlobKey` | Serve persisted generation-media bytes back | -| `LockStore` / `withLocks` / `InMemoryLockStore` (from `@tanstack/ai/locks`) | Coordination, **not** this package — see ai-core/locks | -| `@tanstack/ai-persistence/testkit` | `runPersistenceConformance` gate (chat state stores) | +| `reconstructChat` / `reconstructGeneration` | Server hydrate route helpers (chat / generation) | +| `retrieveArtifact` / `retrieveBlob` / `artifactBlobKey` | Serve persisted generation-media bytes back | +| `LockStore` / `withLocks` / `InMemoryLockStore` (from `@tanstack/ai/locks`) | Coordination, **not** this package — see ai-core/locks | +| `@tanstack/ai-persistence/testkit` | `runPersistenceConformance` gate (chat state stores) | **Chat vs generation stores.** Chat persistence keys on `threadId` and uses `messages` + optional `runs` / `interrupts` / `metadata`. Generation persistence keys on `jobId` and uses `jobs` (required by `withGenerationPersistence`) plus an optional `artifacts` + `blobs` **pair** — provide both or neither — to store the generated media bytes at blob key `artifacts//`. `threadId` on -a generation is only an optional *link* to a chat, never the job's identity. To +a generation is only an optional _link_ to a chat, never the job's identity. To build the R2/D1-backed byte stores for a Worker, see **ai-persistence/build-cloudflare-artifact-store**. @@ -75,13 +75,13 @@ Adding persistence to an app? Pick the recipe that matches what it already runs — each one writes a single `chat-persistence.ts` against the app's existing database client and schema: -| The app runs... | Read | -| -------------------------------------------------------- | ------------------------------------------------------ | -| Drizzle ORM (SQLite / Postgres / MySQL) | ai-persistence/build-drizzle-adapter/SKILL.md | -| Prisma | ai-persistence/build-prisma-adapter/SKILL.md | -| Cloudflare Workers + D1 (± Durable Object locks) | ai-persistence/build-cloudflare-adapter/SKILL.md | -| Cloudflare Workers + R2/D1 for generated media bytes | ai-persistence/build-cloudflare-artifact-store/SKILL.md | -| Anything else — raw `pg`, Kysely, SQLite, Mongo | ai-persistence/build-custom-adapter/SKILL.md | +| The app runs... | Read | +| ---------------------------------------------------- | ------------------------------------------------------- | +| Drizzle ORM (SQLite / Postgres / MySQL) | ai-persistence/build-drizzle-adapter/SKILL.md | +| Prisma | ai-persistence/build-prisma-adapter/SKILL.md | +| Cloudflare Workers + D1 (± Durable Object locks) | ai-persistence/build-cloudflare-adapter/SKILL.md | +| Cloudflare Workers + R2/D1 for generated media bytes | ai-persistence/build-cloudflare-artifact-store/SKILL.md | +| Anything else — raw `pg`, Kysely, SQLite, Mongo | ai-persistence/build-custom-adapter/SKILL.md | ## State persistence has two halves diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index dc9edf01b..bc17818a2 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -30,20 +30,24 @@ annotation). ```ts // BlobStore — the byte layer. R2 backs it. interface BlobStore { - put(key: string, body: BlobBody, options?: BlobPutOptions): Promise - get(key: string): Promise // metadata + byte accessors - head(key: string): Promise // metadata only - delete(key: string): Promise // no-op if absent + put( + key: string, + body: BlobBody, + options?: BlobPutOptions, + ): Promise + get(key: string): Promise // metadata + byte accessors + head(key: string): Promise // metadata only + delete(key: string): Promise // no-op if absent list(options?: BlobListOptions): Promise } // ArtifactStore — the metadata layer. D1 (or KV) backs it. interface ArtifactStore { - save(record: ArtifactRecord): Promise // insert or overwrite + save(record: ArtifactRecord): Promise // insert or overwrite get(artifactId: string): Promise - list(runId: string): Promise> // [] when none - delete?(artifactId: string): Promise // OPTIONAL - deleteForRun?(runId: string): Promise // OPTIONAL + list(runId: string): Promise> // [] when none + delete?(artifactId: string): Promise // OPTIONAL + deleteForRun?(runId: string): Promise // OPTIONAL } ``` @@ -366,13 +370,13 @@ factory, keep everything else. `put` maps to the SDK's upload, `get` to a download that exposes `body`/`arrayBuffer`/`text`, `head` to a metadata fetch, `delete` to a delete, `list` to a prefixed, cursor-paged list. -| Backend | npm | One-line sketch | -| --- | --- | --- | -| **AWS S3** | `@aws-sdk/client-s3` | `put`→`PutObjectCommand`; `get`→`GetObjectCommand` (`Body` is a stream → `body`, `.transformToByteArray()`/`.transformToString()`); `head`→`HeadObjectCommand`; `delete`→`DeleteObjectCommand`; `list`→`ListObjectsV2Command` (`Prefix`, `ContinuationToken`↔`cursor`, `MaxKeys`↔`limit`, `IsTruncated`↔`truncated`). | -| **Google Cloud Storage** | `@google-cloud/storage` | `bucket.file(key)`: `put`→`.save(body, { contentType, metadata })`; `get`→`.createReadStream()` for `body` + `.download()` for bytes; `head`→`.getMetadata()`; `delete`→`.delete({ ignoreNotFound: true })`; `list`→`bucket.getFiles({ prefix, maxResults, pageToken })`. | -| **Vercel Blob** | `@vercel/blob` | `put`→`put(key, body, { access: 'public', contentType })`; `get`→`fetch(head(key).url)` (stream `res.body`); `head`→`head(key)` (returns `null`→catch as absent); `delete`→`del(key)`; `list`→`list({ prefix, cursor, limit })` (`hasMore`↔`truncated`). | -| **Supabase Storage** | `@supabase/supabase-js` | `storage.from(bucket)`: `put`→`.upload(key, body, { contentType, upsert: true })`; `get`→`.download(key)` (returns a `Blob` → `body`/`arrayBuffer`/`text`); `head`→`.info(key)` or list-one; `delete`→`.remove([key])`; `list`→`.list(prefix, { limit })` (offset/limit paging → synthesize a `cursor`). | -| **Filesystem (dev only)** | `node:fs/promises` | Root each key under a dir: `put`→`mkdir(dirname, { recursive: true })` + `writeFile`; `get`→`createReadStream` for `body` + `readFile`; `head`→`stat` (`size`, `mtimeMs`→`updatedAt`); `delete`→`rm(path, { force: true })`; `list`→recursive `readdir` filtered by prefix, sorted, sliced by `limit`, cursor = last key. Not for production — no concurrency guarantees. | +| Backend | npm | One-line sketch | +| ------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **AWS S3** | `@aws-sdk/client-s3` | `put`→`PutObjectCommand`; `get`→`GetObjectCommand` (`Body` is a stream → `body`, `.transformToByteArray()`/`.transformToString()`); `head`→`HeadObjectCommand`; `delete`→`DeleteObjectCommand`; `list`→`ListObjectsV2Command` (`Prefix`, `ContinuationToken`↔`cursor`, `MaxKeys`↔`limit`, `IsTruncated`↔`truncated`). | +| **Google Cloud Storage** | `@google-cloud/storage` | `bucket.file(key)`: `put`→`.save(body, { contentType, metadata })`; `get`→`.createReadStream()` for `body` + `.download()` for bytes; `head`→`.getMetadata()`; `delete`→`.delete({ ignoreNotFound: true })`; `list`→`bucket.getFiles({ prefix, maxResults, pageToken })`. | +| **Vercel Blob** | `@vercel/blob` | `put`→`put(key, body, { access: 'public', contentType })`; `get`→`fetch(head(key).url)` (stream `res.body`); `head`→`head(key)` (returns `null`→catch as absent); `delete`→`del(key)`; `list`→`list({ prefix, cursor, limit })` (`hasMore`↔`truncated`). | +| **Supabase Storage** | `@supabase/supabase-js` | `storage.from(bucket)`: `put`→`.upload(key, body, { contentType, upsert: true })`; `get`→`.download(key)` (returns a `Blob` → `body`/`arrayBuffer`/`text`); `head`→`.info(key)` or list-one; `delete`→`.remove([key])`; `list`→`.list(prefix, { limit })` (offset/limit paging → synthesize a `cursor`). | +| **Filesystem (dev only)** | `node:fs/promises` | Root each key under a dir: `put`→`mkdir(dirname, { recursive: true })` + `writeFile`; `get`→`createReadStream` for `body` + `readFile`; `head`→`stat` (`size`, `mtimeMs`→`updatedAt`); `delete`→`rm(path, { force: true })`; `list`→recursive `readdir` filtered by prefix, sorted, sliced by `limit`, cursor = last key. Not for production — no concurrency guarantees. | For each: `contentType` and `customMetadata` ride the SDK's own metadata fields; `BlobRecord.createdAt`/`updatedAt` come from the object's stored timestamps diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts index 81136ae51..905ba29d1 100644 --- a/packages/ai-persistence/src/memory.ts +++ b/packages/ai-persistence/src/memory.ts @@ -107,9 +107,7 @@ class MemoryGenerationJobStore implements GenerationJobStore { get(jobId: string): Promise { return Promise.resolve(this.jobs.get(jobId) ?? null) } - findLatestForThread( - threadId: string, - ): Promise { + findLatestForThread(threadId: string): Promise { const linked = [...this.jobs.values()] .filter((job) => job.threadId === threadId) .sort((a, b) => b.startedAt - a.startedAt) diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index 0dbc2b17c..c3d8f1c04 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -292,7 +292,9 @@ describe('withGenerationPersistence generation artifacts', () => { expect(job?.result).toBeDefined() // Persisted artifact refs land on the job too. expect(job?.artifacts).toHaveLength(1) - expect(job?.artifacts?.[0]?.artifactId).toBe(result.artifacts![0]!.artifactId) + expect(job?.artifacts?.[0]?.artifactId).toBe( + result.artifacts![0]!.artifactId, + ) }) it('links the job to a thread and finds the latest for that thread', async () => { diff --git a/testing/e2e/src/routes/api.generation-persistence-server.ts b/testing/e2e/src/routes/api.generation-persistence-server.ts index 12d3250b0..11bf28347 100644 --- a/testing/e2e/src/routes/api.generation-persistence-server.ts +++ b/testing/e2e/src/routes/api.generation-persistence-server.ts @@ -32,10 +32,7 @@ const TINY_PNG_B64 = // Server-authoritative record of the last completed generation per thread. In // production this is a `GenerationJobStore` row; here a process-lifetime map is // enough for the reload round-trip (the e2e server stays up across reloads). -const completedByThread = new Map< - string, - { id: string; model: string } ->() +const completedByThread = new Map() function stringField(body: unknown, key: string): string | undefined { if (typeof body !== 'object' || body === null || !(key in body)) { @@ -73,7 +70,10 @@ function imageRun(threadId: string, runId: string): AsyncIterable { } as StreamChunk // The run finished: record the job the way `withGenerationPersistence` // would, so the GET hydrate below can restore it after a reload. - completedByThread.set(threadId, { id: 'image-1', model: 'mock-image-model' }) + completedByThread.set(threadId, { + id: 'image-1', + model: 'mock-image-model', + }) })() } diff --git a/testing/e2e/tests/generation-persistence-server.spec.ts b/testing/e2e/tests/generation-persistence-server.spec.ts index 9fdc98273..0fb33c281 100644 --- a/testing/e2e/tests/generation-persistence-server.spec.ts +++ b/testing/e2e/tests/generation-persistence-server.spec.ts @@ -32,7 +32,9 @@ test.describe('generation persistence (server-driven)', () => { // Server-driven mode writes NOTHING to browser storage — the record lives // on the server. No localStorage key mentions the generation id. - const localKeys = await page.evaluate(() => Object.keys(window.localStorage)) + const localKeys = await page.evaluate(() => + Object.keys(window.localStorage), + ) expect(localKeys.some((k) => k.includes('generation-server'))).toBe(false) // Reload with empty client storage: the snapshot is restored purely from From 37a615a476f4a962086e5408562e128371ff0dba Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 28 Jul 2026 13:37:46 +0200 Subject: [PATCH 21/66] 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. --- docs/config.json | 19 ++-- docs/media/audio-generation.md | 4 + docs/media/generation-hooks.md | 5 + docs/media/image-generation.md | 3 + docs/media/transcription.md | 4 + docs/media/video-generation.md | 7 ++ docs/persistence/client-persistence.md | 6 ++ docs/persistence/generation-persistence.md | 88 +----------------- docs/persistence/keep-generated-files.md | 101 +++++++++++++++++++++ docs/persistence/overview.md | 24 +++++ 10 files changed, 170 insertions(+), 91 deletions(-) create mode 100644 docs/persistence/keep-generated-files.md diff --git a/docs/config.json b/docs/config.json index cd515691d..d59f5d315 100644 --- a/docs/config.json +++ b/docs/config.json @@ -242,7 +242,7 @@ "label": "Overview", "to": "persistence/overview", "addedAt": "2026-07-22", - "updatedAt": "2026-07-27" + "updatedAt": "2026-07-28" }, { "label": "Chat Persistence", @@ -254,7 +254,7 @@ "label": "Client Persistence", "to": "persistence/client-persistence", "addedAt": "2026-07-22", - "updatedAt": "2026-07-27" + "updatedAt": "2026-07-28" }, { "label": "Generation Persistence", @@ -262,6 +262,11 @@ "addedAt": "2026-07-28", "updatedAt": "2026-07-28" }, + { + "label": "Keep Generated Files", + "to": "persistence/keep-generated-files", + "addedAt": "2026-07-28" + }, { "label": "Controls", "to": "persistence/controls", @@ -383,7 +388,7 @@ "label": "Transcription", "to": "media/transcription", "addedAt": "2026-04-15", - "updatedAt": "2026-07-03" + "updatedAt": "2026-07-28" }, { "label": "Audio Recording", @@ -395,25 +400,25 @@ "label": "Audio Generation", "to": "media/audio-generation", "addedAt": "2026-04-23", - "updatedAt": "2026-06-08" + "updatedAt": "2026-07-28" }, { "label": "Image Generation", "to": "media/image-generation", "addedAt": "2026-04-15", - "updatedAt": "2026-07-07" + "updatedAt": "2026-07-28" }, { "label": "Video Generation", "to": "media/video-generation", "addedAt": "2026-04-15", - "updatedAt": "2026-07-02" + "updatedAt": "2026-07-28" }, { "label": "Generation Hooks", "to": "media/generation-hooks", "addedAt": "2026-04-15", - "updatedAt": "2026-07-10" + "updatedAt": "2026-07-28" } ] }, diff --git a/docs/media/audio-generation.md b/docs/media/audio-generation.md index 2fbb97651..4b323d1a4 100644 --- a/docs/media/audio-generation.md +++ b/docs/media/audio-generation.md @@ -161,6 +161,10 @@ flow. It mirrors the API of `useGenerateSpeech`, `useGenerateImage`, and other media hooks — see [Generation Hooks](./generation-hooks) for the full shape. +> **Note:** For long tracks, keep the run's status and result across a reload or +> a dropped connection — and the audio after the provider's URL expires — with +> [Generation Persistence](../persistence/generation-persistence). + ### Server (streaming SSE route) ```typescript diff --git a/docs/media/generation-hooks.md b/docs/media/generation-hooks.md index 69cb7b7c9..b87e8b133 100644 --- a/docs/media/generation-hooks.md +++ b/docs/media/generation-hooks.md @@ -19,6 +19,11 @@ keywords: TanStack AI provides framework hooks for every generation type: image, audio, speech, transcription, summarization, and video. Each hook connects to a server endpoint and manages loading, error, and result state for you. +> **Surviving reloads and dropped connections:** every generation hook takes the +> same `persistence` option `useChat` does, so a long run's status and result +> come back after a page reload or a dropped connection. See +> [Generation Persistence](../persistence/generation-persistence). + ## Overview Generation hooks share a consistent API across all media types: diff --git a/docs/media/image-generation.md b/docs/media/image-generation.md index 9f20e59ab..6db756904 100644 --- a/docs/media/image-generation.md +++ b/docs/media/image-generation.md @@ -508,6 +508,9 @@ try { TanStack AI provides React hooks and server-side streaming helpers to build full-stack image generation with minimal boilerplate. +> **Note:** To keep a batch across reloads, or to keep the images after the +> provider's URLs expire, add [Generation Persistence](../persistence/generation-persistence). + ### Streaming Mode (Server Route + Client Hook) **Server** — Create an API route that wraps `generateImage` as a streaming response: diff --git a/docs/media/transcription.md b/docs/media/transcription.md index bd92c633a..f752efa14 100644 --- a/docs/media/transcription.md +++ b/docs/media/transcription.md @@ -397,6 +397,10 @@ export async function POST(request: Request) { TanStack AI provides React hooks and server-side streaming helpers to build full-stack audio transcription with minimal boilerplate. +> **Note:** Transcribing a big file can run long — keep its status and result +> across a reload or a dropped connection with +> [Generation Persistence](../persistence/generation-persistence). + ### Streaming Mode (Server Route + Client Hook) **Server** — Create an API route that wraps `generateTranscription` as a streaming response: diff --git a/docs/media/video-generation.md b/docs/media/video-generation.md index 2386de6fa..518ae5757 100644 --- a/docs/media/video-generation.md +++ b/docs/media/video-generation.md @@ -46,6 +46,13 @@ Currently supported: - **Grok (xAI)**: grok-imagine-video (text-to-video + image-to-video) and grok-imagine-video-1.5 (image-to-video only) models - **fal.ai**: MiniMax, Luma, Kling, Hunyuan, and other hosted video models +> **Video runs take minutes — don't lose them to a reload.** This is the +> strongest case for [Generation Persistence](../persistence/generation-persistence): +> it keeps a record of each job so a page reload or dropped connection picks the +> run back up instead of starting over. And because provider video URLs expire, +> [keep the finished clip](../persistence/keep-generated-files) by saving its +> bytes to your own storage. + ## Basic Usage ### Creating a Video Job diff --git a/docs/persistence/client-persistence.md b/docs/persistence/client-persistence.md index 29b1103a9..cf65c4a06 100644 --- a/docs/persistence/client-persistence.md +++ b/docs/persistence/client-persistence.md @@ -70,6 +70,12 @@ pointer. On the next load `useChat` reads it and: - **`true`** is server-authoritative. - **`false`** (or omitted) is off: messages live in memory only and a reload starts empty. +The generation hooks (`useGenerateImage` / `useGenerateVideo` / …) make the same +choice: `persistence: true` is server-driven and a storage adapter is +client-driven, so a long media run survives a reload the way a conversation does. +See [Generation persistence](./generation-persistence) for the generation-specific +setup. + ### An adapter: client-authoritative Pass the adapter directly, `persistence: localStoragePersistence()`. The diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index a2a254470..5856c79d0 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -18,7 +18,8 @@ It helps with three things: snapshot in browser storage — either way it is read back automatically on mount. - **Keep the generated files.** On the server, save the generated bytes to your - own storage so they outlive the provider's expiring URLs. + own storage so they outlive the provider's expiring URLs — see + [Keep generated files](./keep-generated-files). - **While a run is still streaming**, let a dropped connection re-attach to it instead of starting over. This reuses the same resumable streams the chat client uses. @@ -33,7 +34,7 @@ can skip it. The record never holds the generated bytes, only run identity, status, errors, and result metadata (like the result id, model, or video job id). Storing the bytes themselves is a server-side opt-in, shown in -[Store the generated bytes](#store-the-generated-bytes). +[Keep generated files](./keep-generated-files). ## Choose a mode @@ -271,87 +272,6 @@ the value yourself and pass it as `initialResumeSnapshot` — that skips the automatic read. Validate untrusted values with `parseGenerationResumeSnapshot` from `@tanstack/ai-client`. -## Store the generated bytes - -Provider URLs for generated media expire. To keep the output, give your -persistence backend an `artifacts` store (metadata) and a `blobs` store (the -bytes), **on top of** the `jobs` store `withGenerationPersistence` already -requires. Byte storage is an optional add-on: when both `artifacts` and `blobs` -are present, `withGenerationPersistence` writes each generated file's bytes to -the blob store, records an `ArtifactRecord`, and attaches durable references to -the result and the job record. `memoryPersistence()` ships all three stores, so -it works out of the box; any backend that implements `ArtifactStore` and -`BlobStore` (see -[Build your own adapter](./build-your-own-adapter#generation--media-stores)) -works the same way. - -The bytes land under the blob key `artifacts//`. To fetch a -generated file later (to render it, download it, or hand it to another request), -add a `GET` route that reads the artifact back with the `retrieveArtifact` / -`retrieveBlob` helpers and streams it from your own origin: - -```ts group=generation-bytes -import { - generateImage, - generationParamsFromRequest, - toServerSentEventsResponse, -} from '@tanstack/ai' -import { openaiImage } from '@tanstack/ai-openai' -import { - memoryPersistence, - retrieveArtifact, - retrieveBlob, - withGenerationPersistence, -} from '@tanstack/ai-persistence' - -const persistence = memoryPersistence() - -export async function POST(request: Request) { - const { input } = await generationParamsFromRequest('image', request) - - if (typeof input.prompt !== 'string') { - throw new Error('This endpoint accepts text image prompts only.') - } - - const stream = generateImage({ - adapter: openaiImage('gpt-image-2'), - prompt: input.prompt, - stream: true, - middleware: [withGenerationPersistence(persistence)], - }) - - return toServerSentEventsResponse(stream) -} - -// Serve a stored artifact's bytes by id. This is a plain file endpoint: it -// serves one stored file, it does not resume a run or rebuild a conversation. -export async function GET(request: Request) { - const artifactId = new URL(request.url).searchParams.get('id') - if (!artifactId) return new Response('missing id', { status: 400 }) - - const artifact = await retrieveArtifact(persistence, artifactId) - if (!artifact) return new Response('not found', { status: 404 }) - - const blob = await retrieveBlob(persistence, artifact) - if (!blob) return new Response('not found', { status: 404 }) - - return new Response(blob.body ?? (await blob.arrayBuffer()), { - headers: { - 'content-type': artifact.mimeType, - 'content-length': String(artifact.size), - }, - }) -} -``` - -`memoryPersistence` keeps everything in process memory, which is right for -development and tests; point `jobs` / `artifacts` / `blobs` at a durable backend -for production. Control what gets captured with `withGenerationPersistence`'s -`extractArtifacts` (return your own descriptors) and `nameArtifact` (name each -file) options. On the client, the observed references show up on the generation -hook as `resultArtifacts` (and `pendingArtifacts` while streaming), so the UI -can link straight to this serve route. - ## Reconnect to a run that is still streaming The server-driven endpoint above already wires this: a `durability` adapter on @@ -376,5 +296,5 @@ browser snapshot — holds run identity and result metadata (ids, model, status, a video `jobId`, an expiry timestamp), never the generated bytes, and it does not carry the provider's media URL. To keep the media itself, persist the bytes on the server with an `artifacts` + `blobs` backend (see -[Store the generated bytes](#store-the-generated-bytes)) and serve them from +[Keep generated files](./keep-generated-files)) and serve them from your own origin. diff --git a/docs/persistence/keep-generated-files.md b/docs/persistence/keep-generated-files.md new file mode 100644 index 000000000..5ad59cfd2 --- /dev/null +++ b/docs/persistence/keep-generated-files.md @@ -0,0 +1,101 @@ +--- +title: Keep Generated Files +id: keep-generated-files +--- + +# Keep Generated Files + +Provider URLs for generated media expire. A Sora clip, a batch of images, a long +audio track — the model hands you a URL that stops working after a while, and +once it does the output is gone. To keep the output, save the generated bytes to +your own storage and serve them from your own origin, where they outlive the +provider's link. + +This is a server-side opt-in that layers on **top of** the `jobs` store +[Generation persistence](./generation-persistence) already requires. Byte storage +adds two more stores: an `artifacts` store (metadata) and a `blobs` store (the +bytes). They are a both-or-neither pair — provide both and +`withGenerationPersistence` writes each generated file's bytes to the blob store, +records an `ArtifactRecord`, and attaches durable references to the result and the +job record; provide neither and only the job record is kept. +`memoryPersistence()` ships all three stores (`jobs`, `artifacts`, `blobs`), so it +works out of the box; any backend that implements `ArtifactStore` and `BlobStore` +(see [Build your own adapter](./build-your-own-adapter#generation--media-stores)) +works the same way. + +## Serve the stored bytes + +The bytes land under the blob key `artifacts//`. To fetch a +generated file later (to render it, download it, or hand it to another request), +add a `GET` route that reads the artifact back with the `retrieveArtifact` / +`retrieveBlob` helpers and streams it from your own origin: + +```ts group=generation-bytes +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + memoryPersistence, + retrieveArtifact, + retrieveBlob, + withGenerationPersistence, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() + +export async function POST(request: Request) { + const { input } = await generationParamsFromRequest('image', request) + + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + const stream = generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + stream: true, + middleware: [withGenerationPersistence(persistence)], + }) + + return toServerSentEventsResponse(stream) +} + +// Serve a stored artifact's bytes by id. This is a plain file endpoint: it +// serves one stored file, it does not resume a run or rebuild a conversation. +export async function GET(request: Request) { + const artifactId = new URL(request.url).searchParams.get('id') + if (!artifactId) return new Response('missing id', { status: 400 }) + + const artifact = await retrieveArtifact(persistence, artifactId) + if (!artifact) return new Response('not found', { status: 404 }) + + const blob = await retrieveBlob(persistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + }, + }) +} +``` + +`memoryPersistence` keeps everything in process memory, which is right for +development and tests; point `jobs` / `artifacts` / `blobs` at a durable backend +for production. Control what gets captured with `withGenerationPersistence`'s +`extractArtifacts` (return your own descriptors) and `nameArtifact` (name each +file) options. On the client, the observed references show up on the generation +hook as `resultArtifacts` (and `pendingArtifacts` while streaming), so the UI +can link straight to this serve route. + +## Where to go next + +- [Generation persistence](./generation-persistence): the two-mode record that + survives a reload or a dropped connection, and the `jobs` store byte storage + builds on. +- [Build your own adapter](./build-your-own-adapter#generation--media-stores): a + custom `ArtifactStore` / `BlobStore` on your own database. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index 73d037a20..cf2ef5f50 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -274,6 +274,21 @@ Why this wins over the alternatives: Client-only persistence can't do multi-device and bloats storage. Caching everything client-side duplicates the source of truth. This combination avoids both: one server-resolved `GET` on mount restores history and rejoins any live run, so a reload and a second device follow the identical path. +## Generation persistence + +Everything above is about chat. Media generation — image, audio, TTS, video, and +transcription — persists as a sibling to it: the generation hooks +(`useGenerateImage`, `useGenerateVideo`, …) take the same `persistence: true | +adapter` modes, so a long run's status and result survive a reload or a dropped +connection the same way a conversation does. On the server it is backed by a +`jobs` store (the generation counterpart to chat's `runs`), with optional +`artifacts` + `blobs` stores to keep the generated bytes after the provider's +URLs expire. + +See [Generation persistence](./generation-persistence) for the two modes and the +server wiring, and [Keep generated files](./keep-generated-files) for byte +storage. + ## The store contract Server **state** persistence is a set of stores. Middleware activates behavior @@ -286,6 +301,13 @@ from whichever stores are present (with entrypoint requirements — see | `runs` | Run status, timing, errors, and usage. Required on full `ChatPersistence`. | | `interrupts` | Pending, resolved, or cancelled human/tool waits (needs `runs`). | | `metadata` | App and integration key/value state. | +| `jobs` | Generation run status, result metadata, and artifact refs, keyed by `jobId`. Required by generation persistence. | +| `artifacts` | Generated-file metadata (needs `blobs`). | +| `blobs` | The generated bytes (needs `artifacts`). | + +The last three are the generation counterpart to the chat stores, used by +`withGenerationPersistence` rather than `withPersistence` — see +[Generation persistence](./generation-persistence). Named shapes: `ChatTranscriptStores` (messages floor), `ChatPersistenceStores` (all four), `ChatWithInterruptsStores`. See [Controls](./controls). @@ -307,6 +329,8 @@ matching your database loads itself. The full skill list is in - [Chat persistence](./chat-persistence): the server middleware, the authoritative-history contract, and durable interrupts. - [Client persistence](./client-persistence): client- vs server-authoritative modes (`persistence: true`), reload restore, storage backends, and mid-stream rejoin. +- [Generation persistence](./generation-persistence): the same modes for media runs (image, audio, TTS, video, transcription), backed by a `jobs` store. +- [Keep generated files](./keep-generated-files): save the generated bytes to your own storage so they outlive the provider's expiring URLs. - [Controls](./controls): compose backends per store and choose which stores to run. - [Build your own adapter](./build-your-own-adapter): a complete SQLite example on the core, plus the store interface reference. - [Resumable streams](../resumable-streams/overview): the delivery-durability layer in full. From 336ec967b258c3cd9ebc0986d8bc3d389b7d4648 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 28 Jul 2026 16:21:38 +0200 Subject: [PATCH 22/66] 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. --- .changeset/generation-persistence.md | 16 +- docs/config.json | 3 +- docs/persistence/generation-persistence.md | 130 ++++++---- docs/persistence/keep-generated-files.md | 32 ++- .../src/routes/generations.image.tsx | 30 ++- .../ai-angular/src/inject-generate-audio.ts | 2 + .../ai-angular/src/inject-generate-image.ts | 4 +- .../ai-angular/src/inject-generate-speech.ts | 2 +- .../ai-angular/src/inject-generate-video.ts | 31 +-- packages/ai-angular/src/inject-generation.ts | 45 +--- packages/ai-angular/src/inject-summarize.ts | 4 +- .../ai-angular/src/inject-transcription.ts | 4 +- .../tests/inject-generation.test.ts | 108 ++++++-- packages/ai-client/src/generation-client.ts | 105 +++++++- .../ai-client/src/generation-reconstruct.ts | 87 +++++++ packages/ai-client/src/generation-types.ts | 103 +++++++- packages/ai-client/src/index.ts | 9 + packages/ai-client/src/storage-adapters.ts | 31 +-- .../ai-client/src/video-generation-client.ts | 90 ++++++- .../ai-client/tests/generation-client.test.ts | 130 ++++++++++ packages/ai-persistence/src/middleware.ts | 65 ++++- .../tests/generation-artifacts.test.ts | 24 ++ packages/ai-react/src/use-generate-audio.ts | 10 +- packages/ai-react/src/use-generate-image.ts | 10 +- packages/ai-react/src/use-generate-speech.ts | 8 - packages/ai-react/src/use-generate-video.ts | 32 +-- packages/ai-react/src/use-generation.ts | 40 ++- packages/ai-react/src/use-summarize.ts | 10 +- packages/ai-react/src/use-transcription.ts | 11 +- .../ai-react/tests/use-generation.test.ts | 121 +++++++-- packages/ai-solid/src/use-generate-audio.ts | 2 + packages/ai-solid/src/use-generate-image.ts | 2 + packages/ai-solid/src/use-generate-video.ts | 35 +-- packages/ai-solid/src/use-generation.ts | 42 ++- packages/ai-solid/src/use-summarize.ts | 2 + packages/ai-solid/src/use-transcription.ts | 3 +- .../ai-solid/tests/use-generation.test.ts | 243 ++++++++++++++++-- .../src/create-generate-audio.svelte.ts | 11 +- .../src/create-generate-image.svelte.ts | 11 +- .../src/create-generate-speech.svelte.ts | 9 - .../src/create-generate-video.svelte.ts | 35 +-- .../ai-svelte/src/create-generation.svelte.ts | 45 ++-- .../ai-svelte/src/create-summarize.svelte.ts | 11 +- .../src/create-transcription.svelte.ts | 11 +- .../ai-svelte/tests/create-generation.test.ts | 112 +++++++- packages/ai-vue/src/use-generate-audio.ts | 2 + packages/ai-vue/src/use-generate-image.ts | 2 + packages/ai-vue/src/use-generate-video.ts | 42 +-- packages/ai-vue/src/use-generation.ts | 53 ++-- packages/ai-vue/src/use-summarize.ts | 2 + packages/ai-vue/src/use-transcription.ts | 3 +- packages/ai-vue/tests/use-generation.test.ts | 215 +++++++++++----- .../ai-core/client-persistence/SKILL.md | 58 ++++- .../skills/ai-core/media-generation/SKILL.md | 55 ++-- packages/ai/src/types.ts | 9 + .../api.generation-persistence-server.ts | 81 ++++-- .../src/routes/api.generation-persistence.ts | 31 ++- .../routes/generation-persistence-server.tsx | 17 +- .../e2e/src/routes/generation-persistence.tsx | 31 +-- .../generation-persistence-server.spec.ts | 47 ++-- .../e2e/tests/generation-persistence.spec.ts | 57 ++-- 61 files changed, 1798 insertions(+), 778 deletions(-) create mode 100644 packages/ai-client/src/generation-reconstruct.ts diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index ef82522fe..774c0fdbc 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -11,18 +11,14 @@ '@tanstack/ai-angular': minor --- -Add generation persistence: a lightweight client resume snapshot plus optional durable storage of the generated media bytes. +Add generation persistence, mirroring chat: media generation runs survive a reload or dropped connection, restoring transparently into the normal hook fields, with optional durable storage of the generated bytes. -**Generation job store (server).** `withGenerationPersistence` now records each run in a dedicated `jobs` (`GenerationJobStore`) store, keyed by the job's `jobId` (`runId`), with `threadId` only an optional link — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `jobs` store, and `defineGenerationJobStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. +**Generation job store (server).** `withGenerationPersistence` records each run in a dedicated `jobs` (`GenerationJobStore`) store, keyed by the job's `jobId` (`runId`), with `threadId` only an optional link — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `jobs` store, and `defineGenerationJobStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. -**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?jobId=` (or `?threadId=`) from the request, authorizes it via an optional `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client can restore the last run on mount. Requires the `jobs` store. +**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?jobId=` (or `?threadId=`) from the request, authorizes it via an optional `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `jobs` store. -**Media byte storage (server).** When the persistence backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, attaches `PersistedArtifactRef`s to the result and onto the job record, and emits a `generation:artifacts` event so the client records them. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options and run their result through middleware result transforms. `@tanstack/ai-utils` adds `base64ToUint8Array`. +**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the job record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. -Add client-side generation persistence: a lightweight resume snapshot for media generation activities. +**Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take the same `persistence` option chat does: `true` (server-driven — cache nothing, hydrate the last run for a stable `threadId` on mount) or a storage adapter (client-driven — write a lightweight snapshot under `generation:` as the run streams and read it back on mount). Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and keeps `resumeState` for the in-flight run identity — there is no `resumeSnapshot` / `pendingArtifacts` / `resultArtifacts` hook field. 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. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. -Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) now accept a `persistence` storage adapter and an `initialResumeSnapshot`, and expose `resumeSnapshot` / `resumeState` (plus `pendingArtifacts` / `resultArtifacts`, populated from the server-side artifact pipeline above when an `artifacts` + `blobs` backend is configured). - -As a run streams, the client builds a `GenerationResumeSnapshot` — run identity, status, errors, and result metadata, but **never** the generated media bytes — and writes it to the adapter under the key `generation:`, skipping writes with no material change. On construction the client reads the snapshot back (validated with the new `parseGenerationResumeSnapshot`) unless an explicit `initialResumeSnapshot` seed is provided, so the last run's outcome survives a reload. `stop()` and transport-level failures record terminal snapshots, and `reset()` clears the persisted record. The snapshot never exposes a `resume()` action and never restarts provider work — generation still only begins when `generate(...)` is called. - -The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too — inferred contextually when written inline; a standalone generation store takes an explicit `localStoragePersistence()` type argument. This pairs with the existing `withGenerationPersistence` server middleware, which records run status in the shared `RunStore`. +The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too with no type argument (a bare call is correct on either side). Untrusted snapshots are validated with the new `parseGenerationResumeSnapshot`. diff --git a/docs/config.json b/docs/config.json index d59f5d315..516e70215 100644 --- a/docs/config.json +++ b/docs/config.json @@ -265,7 +265,8 @@ { "label": "Keep Generated Files", "to": "persistence/keep-generated-files", - "addedAt": "2026-07-28" + "addedAt": "2026-07-28", + "updatedAt": "2026-07-28" }, { "label": "Controls", diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 5856c79d0..e83fbb342 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -16,7 +16,8 @@ It helps with three things: failed, and metadata like the result id or video job id. Depending on the mode you choose, that record is either hydrated from the server or kept as a small snapshot in browser storage — either way it is read back automatically on - mount. + mount and repainted into the hook's normal `status` / `result` / `error` + fields, so a restored run reads exactly like a fresh one. - **Keep the generated files.** On the server, save the generated bytes to your own storage so they outlive the provider's expiring URLs — see [Keep generated files](./keep-generated-files). @@ -110,12 +111,24 @@ export async function POST(request: Request) { // findable only by its own jobId. ...(threadId !== undefined ? { threadId } : {}), stream: true, - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { + // memoryPersistence() ships the `artifacts` + `blobs` byte stores, so + // each generated file's bytes are saved. `artifactUrl` stamps a + // durable, app-origin serve URL onto every persisted artifact ref and + // rewrites the live result's media to it, so the live result already + // points at your own origin — and a reload restores that same media. + // The serve URL is the GET artifact route (retrieveArtifact / + // retrieveBlob) shown in Keep generated files. + artifactUrl: (ref) => + `/api/generate/image/artifact?id=${ref.artifactId}`, + }), + ], }) // withGenerationPersistence records the job's status, result metadata, and - // usage in the jobs store. durability records the stream so a reload can - // re-attach to it. + // durable artifact refs in the jobs store; durability records the stream so a + // reload can re-attach to it. return toServerSentEventsResponse(stream, { durability: { adapter: durability }, }) @@ -137,15 +150,17 @@ export function GET(request: Request): Response | Promise { ``` `reconstructGeneration(persistence, request)` reads a `?jobId` (preferred) or -`?threadId` from the query and returns `{ resumeSnapshot, activeRun }`: -`resumeSnapshot` is the last job mapped to a client snapshot (its status, -result metadata, error, and — while still running — a `resumeState` cursor), -and `activeRun` is `{ runId }` when that job is still generating. It requires a -`jobs` store and resolves the latest job for a thread through the store's -`findLatestForThread`. It does **not** enforce tenancy on its own — pass its -`authorize` option before exposing it on a public route. - -**Client** — a connection, a stable `threadId`, and `persistence: true`: +`?threadId` from the query and returns the last job's persisted record plus a +cursor to any still-running run. The client consumes that on mount and repaints +it into the hook's **normal** fields — you never touch the wire shape. It +requires a `jobs` store and resolves the latest job for a thread through the +store's `findLatestForThread`. It does **not** enforce tenancy on its own — pass +its `authorize` option before exposing it on a public route. + +**Client** — a connection, a stable `threadId`, and `persistence: true`. The +hook is transparent: it exposes the same `result` / `status` / `error` fields as +a fresh run, so a reload repaints the last generation as if it had just +finished — exactly the way `useChat` restores into `messages`: ```tsx import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' @@ -172,26 +187,27 @@ export function HeroImageGenerator({ threadId }: { threadId: string }) { Generate - {image.resumeSnapshot?.status === 'running' ? ( + {image.resumeState ? (

A run was still generating when the page last closed…

) : null} - {image.resumeSnapshot?.status === 'complete' ? ( -

- Last run finished - {image.resumeSnapshot.result?.id - ? ` (${image.resumeSnapshot.result.id})` - : ''} - . -

- ) : null} - {image.resumeSnapshot?.error ? ( -

Last run failed: {image.resumeSnapshot.error.message}

+ {image.status === 'success' ? ( +

Last run finished{image.result?.id ? ` (${image.result.id})` : ''}.

) : null} + {image.error ?

Last run failed: {image.error.message}

: null} + {image.result?.images.map((img, index) => + img.url ? : null, + )} ) } ``` +Because the server example stamps a durable `artifactUrl` onto each ref, the +restored `image.result.images[i].url` points at your own serve route, so the +media renders after a reload just as it did live. Without byte storage the +reload still restores `image.status` and `image.error`, but `image.result` +stays `null` — the metadata survives, the bytes do not. + You never fetch or seed the snapshot yourself. A reload and the same thread opened on another device follow the identical path, because the thread id is the stable key and the server resolves everything from it — no loader, no extra @@ -208,13 +224,11 @@ hydration — the record lives entirely in the browser: ```tsx import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -import type { GenerationResumeSnapshot } from '@tanstack/ai-react' -// The explicit type argument is only needed for a standalone store like this -// one; written inline in the hook options, the value type is inferred. -const snapshots = localStoragePersistence({ - keyPrefix: 'my-app:', -}) +// A bare call is all it takes — no type argument. The adapter defaults to the +// generation snapshot shape, exactly as it defaults to the chat record on a +// chat client. +const snapshots = localStoragePersistence({ keyPrefix: 'my-app:' }) export function HeroImageGenerator() { const image = useGenerateImage({ @@ -237,12 +251,10 @@ export function HeroImageGenerator() { {image.resumeState ? (

Run {image.resumeState.runId} is streaming…

) : null} - {image.resumeSnapshot?.status === 'complete' ? ( -

Last run finished{image.resumeSnapshot.result?.id ? ` (${image.resumeSnapshot.result.id})` : ''}.

- ) : null} - {image.resumeSnapshot?.error ? ( -

Last run failed: {image.resumeSnapshot.error.message}

+ {image.status === 'success' ? ( +

Last run finished{image.result?.id ? ` (${image.result.id})` : ''}.

) : null} + {image.error ?

Last run failed: {image.error.message}

: null} ) } @@ -250,13 +262,24 @@ export function HeroImageGenerator() { A few things to know: -- **`resumeSnapshot`** is the whole record: `status` (`idle` / `running` / - `complete` / `error`), an `error` if the run failed, and result metadata. - After a reload it holds the last run's outcome. +- **The hook is transparent.** After a reload it repaints the same fields a + fresh run does — `status` (`'idle'` / `'generating'` / `'success'` / + `'error'`), `error` if the run failed, and `result` for a finished run. You + read a restored generation exactly like a live one, the way `useChat` restores + into `messages`. +- **`result` needs byte storage to come back.** A client-side snapshot never + holds the generated bytes, so on its own a reload restores `status` and + `error` while `result` stays `null`. Pair it with the server-side byte storage + and `artifactUrl` from the server-driven example (or + [Keep generated files](./keep-generated-files)) and the restored `result` is + rebuilt from the durable refs — `result.images[i].url` (or a video's + `result.url`) serves from your own origin, and `result.artifacts` carries the + refs. - **`resumeState`** is non-null only while a run is in flight — it is the - identity (`threadId` / `runId`) of the streaming run, and it is cleared when - the run ends. Use `resumeSnapshot`, not `resumeState`, to display a finished - run. + identity (`threadId` / `runId`, plus `pendingArtifacts` while media streams) of + the streaming run, and it is cleared when the run ends. Use `status` / + `result`, not `resumeState`, to display a finished run; use `resumeState` to + tell that a run was still going. - **The `id` is the storage key.** The snapshot is written under `generation:` (plus the adapter's `keyPrefix`), so a stable `id` is what makes the record findable after a reload. Omitting `id` generates a fresh @@ -283,18 +306,21 @@ In production, swap `memoryStream` for `durableStream` from `@tanstack/ai-durable-stream`, where requests span processes. A full page reload is different: the hooks never start or resume a run on -mount, and the snapshot alone cannot re-attach to the stream — it records that -a run was in flight, not a stream position. Treat a `running` snapshot after a -reload as informational ("a run was still going when the page closed"). See -[Resumable Streams](../resumable-streams/overview) for the durability contract, -production adapters, and the one-time-side-effects note. +mount, and the record alone cannot re-attach to the stream — it records that a +run was in flight, not a stream position. After a reload, a non-null +`resumeState` is informational ("a run was still going when the page closed"). +See [Resumable Streams](../resumable-streams/overview) for the durability +contract, production adapters, and the one-time-side-effects note. ## What the record holds The generation record — whether hydrated from the server or read from the browser snapshot — holds run identity and result metadata (ids, model, status, -a video `jobId`, an expiry timestamp), never the generated bytes, and it does -not carry the provider's media URL. To keep the media itself, persist the bytes -on the server with an `artifacts` + `blobs` backend (see -[Keep generated files](./keep-generated-files)) and serve them from -your own origin. +a video `jobId`, an expiry timestamp), never the generated bytes. On its own it +does not carry a usable media URL, so a reload restores `status` and `error` +while `result` stays `null`. To bring the media back too, persist the bytes on +the server with an `artifacts` + `blobs` backend and stamp a durable serve URL +onto each ref with `withGenerationPersistence`'s `artifactUrl` (see +[Keep generated files](./keep-generated-files)). Those durable refs travel on +the record, so the restored `result` is rebuilt with its media resolved to your +own origin. diff --git a/docs/persistence/keep-generated-files.md b/docs/persistence/keep-generated-files.md index 5ad59cfd2..3da0ac1c8 100644 --- a/docs/persistence/keep-generated-files.md +++ b/docs/persistence/keep-generated-files.md @@ -57,7 +57,15 @@ export async function POST(request: Request) { adapter: openaiImage('gpt-image-2'), prompt: input.prompt, stream: true, - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { + // Stamp the durable serve URL (the GET route below) onto every + // persisted artifact ref, and rewrite the live result's media field to + // it. Both the live and the restored result then render from your own + // origin instead of the provider's expiring link. + artifactUrl: (ref) => `/api/generate/image?id=${ref.artifactId}`, + }), + ], }) return toServerSentEventsResponse(stream) @@ -88,9 +96,25 @@ export async function GET(request: Request) { development and tests; point `jobs` / `artifacts` / `blobs` at a durable backend for production. Control what gets captured with `withGenerationPersistence`'s `extractArtifacts` (return your own descriptors) and `nameArtifact` (name each -file) options. On the client, the observed references show up on the generation -hook as `resultArtifacts` (and `pendingArtifacts` while streaming), so the UI -can link straight to this serve route. +file) options. + +## Wire the durable URL through to the client + +`artifactUrl` is what makes the stored bytes reachable from the client without +any extra plumbing. For each persisted ref it returns the app-origin URL that +serves those bytes (the `GET` route above), and `withGenerationPersistence` +does two things with it: it stamps the URL onto the ref (`ref.url`) and it +rewrites the live result's media field to the same URL — `result.images[i].url` +for images, `result.url` for a video, `result.audio.url` for audio. So the live +result already points at your origin, not the provider's expiring link. + +Those durable refs ride along on `result.artifacts`, and they are what a reload +restores from. In [Generation persistence](./generation-persistence), the +generation hook rebuilds `result` from the persisted refs on mount, resolving +each media field to its durable `ref.url` — so the restored result renders the +same media the live run showed. The refs are the only artifact surface: there +are no separate top-level artifact fields on the hook, final refs live on +`result.artifacts` and in-flight ones on `resumeState.pendingArtifacts`. ## Where to go next diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 7bbff2401..8d942129f 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -1,10 +1,7 @@ import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useGenerateImage } from '@tanstack/ai-react' -import type { - GenerationResumeSnapshot, - UseGenerateImageReturn, -} from '@tanstack/ai-react' +import type { UseGenerateImageReturn } from '@tanstack/ai-react' import { fetchServerSentEvents, localStoragePersistence, @@ -14,12 +11,12 @@ import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' // Reuse the shared web-storage adapter for the lightweight generation resume // snapshot. Only run identity, status, errors, and result metadata are stored -// — never the generated image bytes. The client namespaces its record under -// `generation:`, and reads it back on mount so the last run's outcome +// — never the generated image bytes. A bare call needs no type argument: the +// adapter defaults to the generation snapshot shape. The client namespaces its +// record under `generation:` and reads it back on mount, repainting the +// hook's normal `status` / `result` / `error` fields so the last run's outcome // survives a full page reload. -const imageSnapshots = localStoragePersistence({ - keyPrefix: 'example:', -}) +const imageSnapshots = localStoragePersistence({ keyPrefix: 'example:' }) function StreamingImageGeneration() { const [prompt, setPrompt] = useState('') @@ -94,7 +91,6 @@ function PersistedImageGeneration() { persistence: imageSnapshots, }) - const snapshot = hookReturn.resumeSnapshot return (
@@ -103,13 +99,19 @@ function PersistedImageGeneration() { Run in flight:{' '} {hookReturn.resumeState.runId}

- ) : snapshot ? ( + ) : hookReturn.status === 'success' ? ( +

+ Last run:{' '} + + success + {hookReturn.result?.model ? ` (${hookReturn.result.model})` : ''} + +

+ ) : hookReturn.status === 'error' ? (

Last run:{' '} - {snapshot.status} - {snapshot.error ? ` — ${snapshot.error.message}` : ''} - {snapshot.result?.model ? ` (${snapshot.result.model})` : ''} + error{hookReturn.error ? ` — ${hookReturn.error.message}` : ''}

) : ( diff --git a/packages/ai-angular/src/inject-generate-audio.ts b/packages/ai-angular/src/inject-generate-audio.ts index e9312d667..b1d5a4323 100644 --- a/packages/ai-angular/src/inject-generate-audio.ts +++ b/packages/ai-angular/src/inject-generate-audio.ts @@ -1,4 +1,5 @@ import { injectGeneration } from './inject-generation' +import { reconstructAudioResult } from '@tanstack/ai-client' import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -123,6 +124,7 @@ export function injectGenerateAudio( >({ ...options, devtools, + reconstructResult: reconstructAudioResult, }) return generation diff --git a/packages/ai-angular/src/inject-generate-image.ts b/packages/ai-angular/src/inject-generate-image.ts index e54d66006..84c66ac58 100644 --- a/packages/ai-angular/src/inject-generate-image.ts +++ b/packages/ai-angular/src/inject-generate-image.ts @@ -1,4 +1,5 @@ import { injectGeneration } from './inject-generation' +import { reconstructImageResult } from '@tanstack/ai-client' import type { Signal } from '@angular/core' import type { ImageGenerationResult } from '@tanstack/ai' import type { @@ -13,7 +14,7 @@ import type { export type InjectGenerateImageOptions = Omit< InjectGenerationOptions, - 'onResult' + 'onResult' | 'reconstructResult' > & { onResult?: (result: ImageGenerationResult) => TOutput | null | void } @@ -48,6 +49,7 @@ export function injectGenerateImage( >({ ...options, devtools, + reconstructResult: reconstructImageResult, }) return generation } diff --git a/packages/ai-angular/src/inject-generate-speech.ts b/packages/ai-angular/src/inject-generate-speech.ts index 7c03b8221..cdc1ff8b8 100644 --- a/packages/ai-angular/src/inject-generate-speech.ts +++ b/packages/ai-angular/src/inject-generate-speech.ts @@ -13,7 +13,7 @@ import type { export type InjectGenerateSpeechOptions = Omit< InjectGenerationOptions, - 'onResult' + 'onResult' | 'reconstructResult' > & { onResult?: (result: TTSResult) => TOutput | null | void } diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index 957a71c2d..f799d9a1e 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -17,7 +17,6 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, @@ -27,7 +26,6 @@ import type { VideoStatusInfo, } from '@tanstack/ai-client' import type { StreamChunk } from '@tanstack/ai' -import type { PersistedArtifactRef } from '@tanstack/ai/client' let nextId = 0 @@ -70,10 +68,7 @@ export interface InjectGenerateVideoResult { status: Signal stop: () => void reset: () => void - resumeSnapshot: Signal resumeState: Signal - pendingArtifacts: Signal> - resultArtifacts: Signal> } // `TTransformed` infers from the `onResult` return position so the callback @@ -103,30 +98,11 @@ export function injectGenerateVideo( const isLoading = signal(false) const error = signal(undefined) const status = signal('idle') - const resumeSnapshot = signal( - options.initialResumeSnapshot, - ) const resumeState = signal( options.initialResumeSnapshot?.resumeState ?? null, ) - const pendingArtifacts = signal>( - options.initialResumeSnapshot?.pendingArtifacts ?? [], - ) - const resultArtifacts = signal>( - options.initialResumeSnapshot?.result?.artifacts ?? [], - ) let disposed = false - const setResumeSnapshotState = ( - snapshot: GenerationResumeSnapshot | undefined, - ) => { - if (disposed) return - resumeSnapshot.set(snapshot) - resumeState.set(snapshot?.resumeState ?? null) - pendingArtifacts.set(snapshot?.pendingArtifacts ?? []) - resultArtifacts.set(snapshot?.result?.artifacts ?? []) - } - const bodySource = options.body !== undefined ? toReactive(options.body) : undefined @@ -186,7 +162,9 @@ export function injectGenerateVideo( onVideoStatusChange: (s: VideoStatusInfo | null) => { if (!disposed) videoStatus.set(s) }, - onResumeSnapshotChange: setResumeSnapshotState, + onResumeStateChange: (rs: GenerationResumeState | null) => { + if (!disposed) resumeState.set(rs) + }, } let client: VideoGenerationClient @@ -240,9 +218,6 @@ export function injectGenerateVideo( status: status.asReadonly(), stop: () => client.stop(), reset: () => client.reset(), - resumeSnapshot: resumeSnapshot.asReadonly(), resumeState: resumeState.asReadonly(), - pendingArtifacts: pendingArtifacts.asReadonly(), - resultArtifacts: resultArtifacts.asReadonly(), } } diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index 0df2a9ccd..54a33682f 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -18,13 +18,12 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, + GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { ReactiveOption } from './internal/to-reactive' let nextId = 0 @@ -69,6 +68,13 @@ export interface InjectGenerationOptions { onProgress?: (progress: number, message?: string) => void /** Callback for each stream chunk (connect-based adapter mode only) */ onChunk?: (chunk: StreamChunk) => void + /** + * @internal Rebuild a typed result from a restored snapshot, injected by each + * specialized injectable (image / audio / transcription / summarize). + * Forwarded to the client so a client-store / server-hydrate restore repaints + * `result`. + */ + reconstructResult?: (restored: GenerationRestoredResult) => TResult | null } /** @@ -95,14 +101,8 @@ export interface InjectGenerationResult< stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: Signal /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: Signal - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Signal> - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Signal> } // `TTransformed` infers from the `onResult` return position (a covariant @@ -135,30 +135,11 @@ export function injectGeneration< const isLoading = signal(false) const error = signal(undefined) const status = signal('idle') - const resumeSnapshot = signal( - options.initialResumeSnapshot, - ) const resumeState = signal( options.initialResumeSnapshot?.resumeState ?? null, ) - const pendingArtifacts = signal>( - options.initialResumeSnapshot?.pendingArtifacts ?? [], - ) - const resultArtifacts = signal>( - options.initialResumeSnapshot?.result?.artifacts ?? [], - ) let disposed = false - const setResumeSnapshotState = ( - snapshot: GenerationResumeSnapshot | undefined, - ) => { - if (disposed) return - resumeSnapshot.set(snapshot) - resumeState.set(snapshot?.resumeState ?? null) - pendingArtifacts.set(snapshot?.pendingArtifacts ?? []) - resultArtifacts.set(snapshot?.result?.artifacts ?? []) - } - const bodySource = options.body !== undefined ? toReactive(options.body) : undefined @@ -172,6 +153,9 @@ export function injectGeneration< ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.reconstructResult + ? { reconstructResult: options.reconstructResult } + : {}), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { ...options.devtools, @@ -205,7 +189,9 @@ export function injectGeneration< onStatusChange: (s: GenerationClientState) => { if (!disposed) status.set(s) }, - onResumeSnapshotChange: setResumeSnapshotState, + onResumeStateChange: (rs: GenerationResumeState | null) => { + if (!disposed) resumeState.set(rs) + }, } let client: GenerationClient @@ -257,9 +243,6 @@ export function injectGeneration< status: status.asReadonly(), stop: () => client.stop(), reset: () => client.reset(), - resumeSnapshot: resumeSnapshot.asReadonly(), resumeState: resumeState.asReadonly(), - pendingArtifacts: pendingArtifacts.asReadonly(), - resultArtifacts: resultArtifacts.asReadonly(), } } diff --git a/packages/ai-angular/src/inject-summarize.ts b/packages/ai-angular/src/inject-summarize.ts index 0bb1e7e86..2edb5e165 100644 --- a/packages/ai-angular/src/inject-summarize.ts +++ b/packages/ai-angular/src/inject-summarize.ts @@ -1,4 +1,5 @@ import { injectGeneration } from './inject-generation' +import { reconstructSummarizeResult } from '@tanstack/ai-client' import type { Signal } from '@angular/core' import type { SummarizationResult } from '@tanstack/ai' import type { @@ -13,7 +14,7 @@ import type { export type InjectSummarizeOptions = Omit< InjectGenerationOptions, - 'onResult' + 'onResult' | 'reconstructResult' > & { onResult?: (result: SummarizationResult) => TOutput | null | void } @@ -48,6 +49,7 @@ export function injectSummarize( >({ ...options, devtools, + reconstructResult: reconstructSummarizeResult, }) return generation } diff --git a/packages/ai-angular/src/inject-transcription.ts b/packages/ai-angular/src/inject-transcription.ts index 561ce0008..fe2e240a4 100644 --- a/packages/ai-angular/src/inject-transcription.ts +++ b/packages/ai-angular/src/inject-transcription.ts @@ -1,4 +1,5 @@ import { injectGeneration } from './inject-generation' +import { reconstructTranscriptionResult } from '@tanstack/ai-client' import type { Signal } from '@angular/core' import type { TranscriptionResult } from '@tanstack/ai' import type { @@ -17,7 +18,7 @@ export type InjectTranscriptionOptions = Omit< TranscriptionResult, TOutput >, - 'onResult' + 'onResult' | 'reconstructResult' > & { onResult?: (result: TranscriptionResult) => TOutput | null | void } @@ -52,6 +53,7 @@ export function injectTranscription( >({ ...options, devtools, + reconstructResult: reconstructTranscriptionResult, }) return generation } diff --git a/packages/ai-angular/tests/inject-generation.test.ts b/packages/ai-angular/tests/inject-generation.test.ts index 05fc05c56..2f9068506 100644 --- a/packages/ai-angular/tests/inject-generation.test.ts +++ b/packages/ai-angular/tests/inject-generation.test.ts @@ -7,7 +7,8 @@ import { import { describe, expect, it, vi } from 'vitest' import { injectGeneration } from '../src/inject-generation' import { injectGenerateVideo } from '../src/inject-generate-video' -import type { StreamChunk } from '@tanstack/ai' +import { injectGenerateImage } from '../src/inject-generate-image' +import type { PersistedArtifactRef, StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter, GenerationPersistence, @@ -62,6 +63,22 @@ function renderInjectGenerateVideo(options: any) { } } +function renderInjectGenerateImage(options: any) { + @Component({ standalone: true, template: '' }) + class Host { + gen = injectGenerateImage(options) + } + const fixture = TestBed.createComponent(Host) + fixture.detectChanges() + return { + get result() { + return fixture.componentInstance.gen + }, + flush: () => fixture.detectChanges(), + destroy: () => fixture.destroy(), + } +} + const videoResumeSnapshot: GenerationResumeSnapshot = { resumeState: { threadId: 'thread-resume', @@ -206,18 +223,17 @@ describe('injectGeneration', () => { expect(getItem).toHaveBeenCalledWith('generation:hydrate-me') // Hydration only surfaces state; it never restarts the run. expect(connect).not.toHaveBeenCalled() - expect(result.resumeSnapshot()).toEqual({ - schemaVersion: 1, - resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, - status: 'running', - }) + // A restored running run repaints `status` to `generating` but never + // auto-tails, and its in-flight identity is exposed as `resumeState`. + expect(result.status()).toBe('generating') + expect(result.isLoading()).toBe(false) expect(result.resumeState()).toEqual({ threadId: 'thread-stored', runId: 'run-stored', }) }) - it('clears the snapshot and removes the persisted record on reset', async () => { + it('clears resume state and removes the persisted record on reset', async () => { const snapshot: GenerationResumeSnapshot = { resumeState: { threadId: 'thread-reset', runId: 'run-reset' }, status: 'running', @@ -233,15 +249,13 @@ describe('injectGeneration', () => { initialResumeSnapshot: snapshot, }) - expect(result.resumeSnapshot()).toEqual(snapshot) + // The seeded in-flight identity is exposed as read-only `resumeState`. + expect(result.resumeState()).toEqual(snapshot.resumeState) result.reset() await flushPromises() - expect(result.resumeSnapshot()).toBeUndefined() expect(result.resumeState()).toBeNull() - expect(result.pendingArtifacts()).toEqual([]) - expect(result.resultArtifacts()).toEqual([]) expect(removeItem).toHaveBeenCalledWith('generation:reset-me') expect(store.has('generation:reset-me')).toBe(false) }) @@ -265,8 +279,7 @@ describe('injectGenerateVideo', () => { expect(getItem).not.toHaveBeenCalled() expect(result.isLoading()).toBe(false) expect(result.status()).toBe('idle') - // The persisted snapshot remains exposed as read-only state. - expect(result.resumeSnapshot()).toEqual(videoResumeSnapshot) + // The seeded in-flight identity is exposed as read-only `resumeState`. expect(result.resumeState()).toEqual(videoResumeSnapshot.resumeState) }) @@ -286,17 +299,76 @@ describe('injectGenerateVideo', () => { expect(getItem).toHaveBeenCalledTimes(1) expect(getItem).toHaveBeenCalledWith('generation:video-hydrate') expect(connect).not.toHaveBeenCalled() - expect(result.resumeSnapshot()).toEqual({ - schemaVersion: 1, - ...videoResumeSnapshot, - }) + // A restored running run repaints `status` to `generating` and exposes its + // in-flight identity as `resumeState`, but never auto-tails. + expect(result.status()).toBe('generating') + expect(result.isLoading()).toBe(false) + expect(result.resumeState()).toEqual(videoResumeSnapshot.resumeState) result.reset() await flushPromises() - expect(result.resumeSnapshot()).toBeUndefined() expect(result.resumeState()).toBeNull() expect(removeItem).toHaveBeenCalledWith('generation:video-hydrate') expect(store.has('generation:video-hydrate')).toBe(false) }) }) + +describe('injectGenerateImage', () => { + it('restores a completed image result from a durable artifact url', async () => { + // injectGenerateImage injects `reconstructImageResult`, so a restored + // complete snapshot repaints `result` with the durable serve url — as if the + // run had just finished. + const artifact: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-image-1', + threadId: 'thread-img', + runId: 'run-img', + name: 'image.png', + mimeType: 'image/png', + size: 2048, + createdAt: '2026-07-06T00:00:00.000Z', + url: '/api/artifacts/artifact-image-1', + source: { + activity: 'image', + path: 'runs/run-img/image.png', + provider: 'test', + model: 'test-image', + mediaType: 'image', + }, + } + const { adapter, connect } = createRunContextCaptureAdapter([]) + const { persistence, getItem } = createMapPersistence({ + 'generation:img-hydrate': { + resumeState: null, + status: 'complete', + activity: 'image', + result: { + id: 'img-restored', + model: 'test-image', + artifacts: [artifact], + }, + }, + }) + const { result } = renderInjectGenerateImage({ + id: 'img-hydrate', + connection: adapter, + persistence, + }) + + await flushPromises() + + expect(getItem).toHaveBeenCalledWith('generation:img-hydrate') + + // A completed snapshot repaints the normal `status` field to `success`. + expect(result.status()).toBe('success') + expect(connect).not.toHaveBeenCalled() + expect(result.result()).toEqual({ + id: 'img-restored', + model: 'test-image', + images: [{ url: '/api/artifacts/artifact-image-1' }], + artifacts: [artifact], + }) + expect(result.resumeState()).toBeNull() + }) +}) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 720de2316..e09b82925 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -1,5 +1,6 @@ import { GENERATION_EVENTS, + clientStateFromResumeStatus, createGenerationResultSnapshot, parseGenerationResumeSnapshot, updateGenerationResumeSnapshot, @@ -23,6 +24,8 @@ import type { GenerationClientState, GenerationFetcher, GenerationResumeSnapshot, + GenerationRestoredResult, + GenerationResumeState, GenerationPersistence, } from './generation-types' @@ -44,6 +47,12 @@ interface GenerationCallbacks { onResumeSnapshotChange?: | ((snapshot: GenerationResumeSnapshot | undefined) => void) | undefined + onResumeStateChange?: + | ((resumeState: GenerationResumeState | null) => void) + | undefined + reconstructResult?: + | ((restored: GenerationRestoredResult) => TResult | null) + | undefined } /** @@ -152,6 +161,8 @@ export class GenerationClient< onErrorChange: options.onErrorChange, onStatusChange: options.onStatusChange, onResumeSnapshotChange: options.onResumeSnapshotChange, + onResumeStateChange: options.onResumeStateChange, + reconstructResult: options.reconstructResult, } this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools) @@ -384,7 +395,7 @@ export class GenerationClient< resumeState: null, status: 'idle', } - this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.notifyResumeSnapshotChanged() void this.persistResumeSnapshot(this.resumeSnapshot) } } @@ -586,10 +597,88 @@ export class GenerationClient< this.resumeSnapshot, chunk, ) - this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.notifyResumeSnapshotChanged() void this.persistResumeSnapshot(this.resumeSnapshot) } + /** + * Notify the (internal) snapshot listener AND emit the public resume state. + * The snapshot stays internal (persistence + devtools); the hook consumes + * `resumeState`, mirroring the chat client. + */ + private notifyResumeSnapshotChanged(): void { + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.emitResumeState() + } + + /** + * Derive the public `resumeState` from the internal snapshot: the in-flight + * run identity, with any in-flight artifact refs folded under it. `null` once + * no run is in flight. + */ + private emitResumeState(): void { + const snapshot = this.resumeSnapshot + const state = snapshot?.resumeState + const resumeState: GenerationResumeState | null = state + ? { + ...state, + ...(snapshot?.pendingArtifacts && snapshot.pendingArtifacts.length > 0 + ? { pendingArtifacts: [...snapshot.pendingArtifacts] } + : {}), + } + : null + this.callbacksRef.onResumeStateChange?.(resumeState) + } + + /** + * Repaint the normal fields from a restored snapshot (client store or server + * hydrate), so a reload presents the run in `result` / `status` / `error` + * exactly as a just-finished run would, never a bolt-on snapshot object. + * `isLoading` stays false: the client never auto-tails a restored run. The + * snapshot is not re-persisted here (it came from storage / the server). + */ + private repaintFromSnapshot(snapshot: GenerationResumeSnapshot): void { + this.resumeSnapshot = snapshot + this.notifyResumeSnapshotChanged() + this.setStatus(clientStateFromResumeStatus(snapshot.status)) + this.setError( + snapshot.error + ? Object.assign( + new Error(snapshot.error.message), + snapshot.error.code ? { code: snapshot.error.code } : {}, + ) + : undefined, + ) + const restored = this.reconstructRestoredResult(snapshot) + if (restored !== null) this.setResult(restored) + } + + /** + * Build the restorable result shape from the snapshot and hand it to the + * per-activity `reconstructResult` mapper (injected by the specialized + * client/hook, which knows the concrete result type). Returns `null` when no + * mapper is set or it declines — then `result` simply stays null. + */ + private reconstructRestoredResult( + snapshot: GenerationResumeSnapshot, + ): TResult | null { + const build = this.callbacksRef.reconstructResult + if (!build) return null + const result = snapshot.result + const restored: GenerationRestoredResult = { + ...(result?.id !== undefined ? { id: result.id } : {}), + ...(result?.model !== undefined ? { model: result.model } : {}), + ...(result?.status !== undefined ? { status: result.status } : {}), + ...(result?.jobId !== undefined ? { jobId: result.jobId } : {}), + ...(result?.expiresAt !== undefined ? { expiresAt: result.expiresAt } : {}), + ...(result?.text !== undefined ? { text: result.text } : {}), + ...(result?.usage !== undefined ? { usage: result.usage } : {}), + ...(snapshot.activity !== undefined ? { activity: snapshot.activity } : {}), + artifacts: result?.artifacts ?? [], + } + return build(restored) + } + /** * The plain (non-Response) fetcher path never observes stream chunks, so * the terminal snapshot is built here from the fetcher's own result. A @@ -613,7 +702,7 @@ export class GenerationClient< ? { result: { ...previous.result } } : {}), } - this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.notifyResumeSnapshotChanged() void this.persistResumeSnapshot(this.resumeSnapshot) } @@ -638,14 +727,14 @@ export class GenerationClient< ...(previous?.result ? { result: { ...previous.result } } : {}), error: { message: error.message }, } - this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.notifyResumeSnapshotChanged() void this.persistResumeSnapshot(this.resumeSnapshot) } private clearResumeSnapshot(): void { this.resumeSnapshot = undefined this.queuedSnapshotSignature = undefined - this.callbacksRef.onResumeSnapshotChange?.(undefined) + this.notifyResumeSnapshotChanged() if (!this.resumePersistence) { return } @@ -691,8 +780,7 @@ export class GenerationClient< // Live state wins: adopt the stored snapshot only if nothing has been // observed since construction. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return - this.resumeSnapshot = snapshot - this.callbacksRef.onResumeSnapshotChange?.(snapshot) + this.repaintFromSnapshot(snapshot) } /** @@ -721,8 +809,7 @@ export class GenerationClient< // Re-check: a send may have started while the fetch was in flight. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return - this.resumeSnapshot = snapshot - this.callbacksRef.onResumeSnapshotChange?.(snapshot) + this.repaintFromSnapshot(snapshot) })() } diff --git a/packages/ai-client/src/generation-reconstruct.ts b/packages/ai-client/src/generation-reconstruct.ts new file mode 100644 index 000000000..650204e06 --- /dev/null +++ b/packages/ai-client/src/generation-reconstruct.ts @@ -0,0 +1,87 @@ +import type { + AudioGenerationResult, + ImageGenerationResult, + PersistedArtifactRef, + SummarizationResult, + TranscriptionResult, +} from '@tanstack/ai' +import type { GenerationRestoredResult } from './generation-types' + +/** + * Per-activity `reconstructResult` mappers. On mount restore the generic + * `GenerationClient` hands each specialized hook a {@link GenerationRestoredResult} + * (the metadata that survived persistence plus the durable artifact refs, each + * carrying its serve `url`); the mapper rebuilds the concrete typed result so + * `result` repaints as if the run had just finished, with media resolved to the + * durable serve route rather than the provider's expired link. + * + * A mapper returns `null` when the snapshot cannot rebuild a valid result; then + * `result` stays null while `status` / `error` / `resumeState` still repaint. + */ + +/** Output artifact refs of a given media type that carry a durable serve URL. */ +function mediaUrls( + restored: GenerationRestoredResult, + mediaType: PersistedArtifactRef['source']['mediaType'], +): Array { + return restored.artifacts + .filter( + (a) => + a.role === 'output' && a.source.mediaType === mediaType && a.url != null, + ) + .map((a) => a.url as string) +} + +/** image → `{ id, model, images: [{ url }], artifacts }`. */ +export function reconstructImageResult( + restored: GenerationRestoredResult, +): ImageGenerationResult | null { + const urls = mediaUrls(restored, 'image') + if (urls.length === 0) return null + return { + id: restored.id ?? '', + model: restored.model ?? '', + images: urls.map((url) => ({ url })), + ...(restored.artifacts.length > 0 ? { artifacts: restored.artifacts } : {}), + } +} + +/** audio → `{ id, model, audio: { url }, artifacts }`. */ +export function reconstructAudioResult( + restored: GenerationRestoredResult, +): AudioGenerationResult | null { + const [url] = mediaUrls(restored, 'audio') + if (!url) return null + return { + id: restored.id ?? '', + model: restored.model ?? '', + audio: { url }, + ...(restored.artifacts.length > 0 ? { artifacts: restored.artifacts } : {}), + } +} + +/** transcription → `{ id, model, text, artifacts }`. */ +export function reconstructTranscriptionResult( + restored: GenerationRestoredResult, +): TranscriptionResult | null { + if (restored.text === undefined) return null + return { + id: restored.id ?? '', + model: restored.model ?? '', + text: restored.text, + ...(restored.artifacts.length > 0 ? { artifacts: restored.artifacts } : {}), + } +} + +/** summarize → `{ id, model, summary, usage }` (needs persisted `usage`). */ +export function reconstructSummarizeResult( + restored: GenerationRestoredResult, +): SummarizationResult | null { + if (restored.text === undefined || restored.usage === undefined) return null + return { + id: restored.id ?? '', + model: restored.model ?? '', + summary: restored.text, + usage: restored.usage, + } +} diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index 929332e78..f642e4879 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -3,7 +3,7 @@ import type { PersistedArtifactRef, StreamChunk, } from '@tanstack/ai/client' -import type { TranscriptionResponseFormat } from '@tanstack/ai' +import type { TokenUsage, TranscriptionResponseFormat } from '@tanstack/ai' import type { ConnectConnectionAdapter } from './connection-adapters' import type { AIDevtoolsClientMetadata } from './devtools' import type { ChatStorageAdapter } from './types' @@ -65,9 +65,36 @@ export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error' export type GenerationResumeStatus = 'idle' | 'running' | 'complete' | 'error' +/** + * Map a persisted resume status to the client's live state machine on restore: + * complete → success, error → error, running → generating, idle → idle. A + * restored `running` presents as `generating` with `isLoading` false (the client + * never auto-tails a restored run). + */ +export function clientStateFromResumeStatus( + status: GenerationResumeStatus, +): GenerationClientState { + switch (status) { + case 'complete': + return 'success' + case 'error': + return 'error' + case 'running': + return 'generating' + case 'idle': + return 'idle' + } +} + export interface GenerationResumeState { threadId: string runId: string + /** + * Artifact refs observed while the run is still in flight. Non-null only while + * a run is streaming (`resumeState` itself is null once it ends); the final + * refs move onto `result.artifacts` when the run completes. + */ + pendingArtifacts?: Array } export type GenerationPendingArtifact = PersistedArtifactRef @@ -78,6 +105,15 @@ export interface GenerationResultSnapshot { status?: string jobId?: string expiresAt?: string + /** + * The text output of a text activity (a transcription's `text` or a summary's + * `summary`). Persisted so a text generation restores its result on reload + * (text is small and not bytes). Absent for media activities, whose output + * restores from `artifacts`. + */ + text?: string + /** Token usage, persisted so a text result that requires it can be rebuilt. */ + usage?: TokenUsage artifacts?: Array } @@ -285,6 +321,37 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { onResumeSnapshotChange?: ( snapshot: GenerationResumeSnapshot | undefined, ) => void + /** @internal Called when the in-flight run identity changes. `null` once no run is in flight. Mirrors the chat client's resume-state callback. */ + onResumeStateChange?: (resumeState: GenerationResumeState | null) => void + + /** + * @internal Rebuild a typed result from a restored snapshot, injected by each + * specialized client/hook (which knows the concrete result shape). Called on + * mount restore (client store or server hydrate) so `result` repaints as if the + * run had just finished, with media resolved to the durable serve URL. Returns + * `null` when the snapshot cannot rebuild a result (then `result` stays null; + * `status` / `error` / `resumeState` still repaint). + */ + reconstructResult?: (restored: GenerationRestoredResult) => TResult | null +} + +/** + * The restorable shape handed to a client's `reconstructResult` mapper: the + * result metadata that survived persistence plus the durable artifact refs (each + * carrying its serve {@link PersistedArtifactRef.url}). The specialized client + * turns this into its own typed result (image `images`, video `url`, text + * `text`, ...). + */ +export interface GenerationRestoredResult { + id?: string + model?: string + status?: string + jobId?: string + expiresAt?: string + text?: string + usage?: TokenUsage + activity?: PersistedArtifactRef['source']['activity'] + artifacts: Array } /** @@ -627,10 +694,17 @@ export function createGenerationResultSnapshot( const model = stringField(value, 'model') const status = stringField(value, 'status') const jobId = stringField(value, 'jobId') + // A transcription's output is `text`; a summary's is `summary`. Capture either + // under `text` so a text result restores on reload. + const text = stringField(value, 'text') ?? stringField(value, 'summary') + const usage = Reflect.get(value, 'usage') if (id) snapshot.id = id if (model) snapshot.model = model if (status) snapshot.status = status if (jobId) snapshot.jobId = jobId + if (text) snapshot.text = text + // Passthrough opaque token-usage metadata (untrusted; not deeply validated). + if (isObject(usage)) snapshot.usage = usage as TokenUsage const expiresAt = Reflect.get(value, 'expiresAt') if (typeof expiresAt === 'string') { snapshot.expiresAt = expiresAt @@ -707,6 +781,7 @@ function createPersistedArtifactRefSnapshot( } const externalUrl = durableUrlField(value, 'externalUrl') + const url = serveUrlField(value, 'url') const mediaType = persistedArtifactMediaTypeField(source, 'mediaType') const jobId = stringField(source, 'jobId') const expiresAt = stringField(source, 'expiresAt') @@ -721,6 +796,7 @@ function createPersistedArtifactRefSnapshot( size, createdAt, ...(externalUrl ? { externalUrl } : {}), + ...(url ? { url } : {}), source: { activity, path, @@ -746,6 +822,31 @@ function durableUrlField(value: object, key: string): string | undefined { } } +/** + * Validates an app-origin serve URL, which unlike a provider URL is usually a + * same-origin path (`/api/.../artifact?id=...`). Accepts an absolute http(s) URL + * or a path-absolute same-origin URL (single leading `/`); rejects + * protocol-relative (`//host`), `javascript:` / `data:`, and anything else, since + * this value is rendered as media `src`. + */ +function serveUrlField(value: object, key: string): string | undefined { + const field = stringField(value, key) + if (!field || field.length > 2048) return undefined + // A single leading `/` is a safe same-origin path. Reject protocol-relative + // `//host` AND a backslash bypass (`/\host` — the URL parser treats `\` as `/` + // for http(s), so it would resolve to a foreign origin as an ``). + if (field.startsWith('/') && !field.startsWith('//') && !field.includes('\\')) + return field + try { + const url = new URL(field) + return url.protocol === 'http:' || url.protocol === 'https:' + ? field + : undefined + } catch { + return undefined + } +} + function persistedArtifactRoleField( value: object, key: string, diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index 796bb7cfe..38a5da277 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -92,12 +92,21 @@ export type { TranscriptionGenerateInput, SummarizeGenerateInput, VideoGenerateInput, + GenerationRestoredResult, } from './generation-types' export { GENERATION_EVENTS, parseGenerationResumeSnapshot, updateGenerationResumeSnapshot, } from './generation-types' +// Per-activity result reconstruction mappers (used by the framework hooks to +// repaint a typed `result` on restore) +export { + reconstructImageResult, + reconstructAudioResult, + reconstructTranscriptionResult, + reconstructSummarizeResult, +} from './generation-reconstruct' export { UnsupportedResponseStreamError } from './response-stream' export { clientTools, createChatClientOptions } from './types' // Web storage adapters for durable chat persistence (messages + resume snapshot) diff --git a/packages/ai-client/src/storage-adapters.ts b/packages/ai-client/src/storage-adapters.ts index 3c402704e..6cd9447de 100644 --- a/packages/ai-client/src/storage-adapters.ts +++ b/packages/ai-client/src/storage-adapters.ts @@ -1,4 +1,4 @@ -import type { ChatPersistedState, ChatStorageAdapter } from './types' +import type { ChatStorageAdapter } from './types' export interface WebStoragePersistenceOptions { keyPrefix?: string @@ -88,14 +88,12 @@ function createWebStoragePersistence( * adapter can be constructed safely on the server. * * The `serialize` / `deserialize` codec defaults to `JSON.stringify` / - * `JSON.parse`, so the common case needs no codec. `TValue` defaults to the - * chat persisted-state shape; passed **inline** to any `persistence` option - * (chat or generation) the value type is inferred contextually, so no type - * argument is needed either way. Only a *standalone* store built for - * generations needs the explicit argument: - * `localStoragePersistence()`. + * `JSON.parse`, so the common case needs no codec. A bare call works for both + * the chat and generation `persistence` options with no type argument; pass an + * explicit `TValue` only when you want a standalone store's own read/write + * typed to a specific shape. */ -export function localStoragePersistence( +export function localStoragePersistence( options: WebStoragePersistenceOptions = {}, ): ChatStorageAdapter { return createWebStoragePersistence('localStorage', options) @@ -104,12 +102,12 @@ export function localStoragePersistence( /** * A `ChatStorageAdapter` backed by `window.sessionStorage` (scoped to the tab * and cleared when it closes). Identical to {@link localStoragePersistence} in - * every other respect: chat persisted-state default `TValue` (inferred - * contextually when passed inline), `tanstack-ai:` default `keyPrefix`, lazy - * per-operation {@link StorageUnavailableError} on SSR, and a JSON codec that - * defaults to `JSON.stringify` / `JSON.parse`. + * every other respect: a bare call works for both chat and generation + * `persistence` options with no type argument, `tanstack-ai:` default + * `keyPrefix`, lazy per-operation {@link StorageUnavailableError} on SSR, and a + * JSON codec that defaults to `JSON.stringify` / `JSON.parse`. */ -export function sessionStoragePersistence( +export function sessionStoragePersistence( options: WebStoragePersistenceOptions = {}, ): ChatStorageAdapter { return createWebStoragePersistence('sessionStorage', options) @@ -124,11 +122,10 @@ export function sessionStoragePersistence( * * No serialize/deserialize codec is needed or accepted — values are stored via * IndexedDB's native structured clone, so `Date`, `Map`, `ArrayBuffer`, etc. - * round-trip without a JSON step. `TValue` defaults to the chat - * persisted-state shape and is inferred contextually when passed inline; a - * standalone store for generations takes the explicit argument. + * round-trip without a JSON step. A bare call works for both chat and + * generation `persistence` options with no type argument. */ -export function indexedDBPersistence( +export function indexedDBPersistence( options: IndexedDBPersistenceOptions = {}, ): ChatStorageAdapter { const databaseName = options.databaseName ?? 'tanstack-ai' diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 4e1034db4..c5e808add 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -1,5 +1,6 @@ import { GENERATION_EVENTS, + clientStateFromResumeStatus, createGenerationResultSnapshot, parseGenerationResumeSnapshot, updateGenerationResumeSnapshot, @@ -23,6 +24,7 @@ import type { GenerationFetcher, GenerationResumeSnapshot, GenerationPersistence, + GenerationResumeState, VideoGenerateInput, VideoGenerateResult, VideoGenerationClientOptions, @@ -53,6 +55,9 @@ interface VideoCallbacks { onResumeSnapshotChange?: | ((snapshot: GenerationResumeSnapshot | undefined) => void) | undefined + onResumeStateChange?: + | ((resumeState: GenerationResumeState | null) => void) + | undefined } /** @@ -166,6 +171,7 @@ export class VideoGenerationClient { onJobIdChange: options.onJobIdChange, onVideoStatusChange: options.onVideoStatusChange, onResumeSnapshotChange: options.onResumeSnapshotChange, + onResumeStateChange: options.onResumeStateChange, } this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools) @@ -417,7 +423,7 @@ export class VideoGenerationClient { resumeState: null, status: 'idle', } - this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.notifyResumeSnapshotChanged() void this.persistResumeSnapshot(this.resumeSnapshot) } } @@ -672,10 +678,78 @@ export class VideoGenerationClient { this.resumeSnapshot, chunk, ) - this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.notifyResumeSnapshotChanged() void this.persistResumeSnapshot(this.resumeSnapshot) } + /** Notify the internal snapshot listener AND emit the public resume state. */ + private notifyResumeSnapshotChanged(): void { + this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.emitResumeState() + } + + /** Derive the public `resumeState` from the internal snapshot. */ + private emitResumeState(): void { + const snapshot = this.resumeSnapshot + const state = snapshot?.resumeState + const resumeState: GenerationResumeState | null = state + ? { + ...state, + ...(snapshot?.pendingArtifacts && snapshot.pendingArtifacts.length > 0 + ? { pendingArtifacts: [...snapshot.pendingArtifacts] } + : {}), + } + : null + this.callbacksRef.onResumeStateChange?.(resumeState) + } + + /** + * Repaint the normal fields from a restored snapshot so a reload presents the + * video in `result` / `status` / `error` / `jobId`, never a snapshot object. + * `isLoading` stays false (no auto-tail). Not re-persisted (it came from + * storage / the server). + */ + private repaintFromSnapshot(snapshot: GenerationResumeSnapshot): void { + this.resumeSnapshot = snapshot + this.notifyResumeSnapshotChanged() + this.setStatus(clientStateFromResumeStatus(snapshot.status)) + this.setError( + snapshot.error + ? Object.assign( + new Error(snapshot.error.message), + snapshot.error.code ? { code: snapshot.error.code } : {}, + ) + : undefined, + ) + if (snapshot.result?.jobId) this.setJobId(snapshot.result.jobId) + const restored = this.reconstructVideoResult(snapshot) + if (restored !== null) this.setResult(restored) + } + + /** + * Rebuild a `VideoGenerateResult` from a restored snapshot: the video's bytes + * are served from the durable artifact URL, so the restored result renders + * from your own origin. Returns `null` when there is no durable video artifact. + */ + private reconstructVideoResult( + snapshot: GenerationResumeSnapshot, + ): VideoGenerateResult | null { + const result = snapshot.result + const artifacts = result?.artifacts ?? [] + const output = artifacts.find( + (a) => + a.role === 'output' && a.source.mediaType === 'video' && a.url != null, + ) + if (!output?.url) return null + return { + jobId: result?.jobId ?? '', + status: 'completed', + url: output.url, + ...(result?.expiresAt ? { expiresAt: new Date(result.expiresAt) } : {}), + artifacts, + } + } + /** * The plain (non-Response) fetcher path never observes stream chunks, so * the terminal snapshot is built here from the fetcher's own result. A @@ -699,7 +773,7 @@ export class VideoGenerationClient { ? { result: { ...previous.result } } : {}), } - this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.notifyResumeSnapshotChanged() void this.persistResumeSnapshot(this.resumeSnapshot) } @@ -724,14 +798,14 @@ export class VideoGenerationClient { ...(previous?.result ? { result: { ...previous.result } } : {}), error: { message: error.message }, } - this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) + this.notifyResumeSnapshotChanged() void this.persistResumeSnapshot(this.resumeSnapshot) } private clearResumeSnapshot(): void { this.resumeSnapshot = undefined this.queuedSnapshotSignature = undefined - this.callbacksRef.onResumeSnapshotChange?.(undefined) + this.notifyResumeSnapshotChanged() if (!this.resumePersistence) { return } @@ -777,8 +851,7 @@ export class VideoGenerationClient { // Live state wins: adopt the stored snapshot only if nothing has been // observed since construction. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return - this.resumeSnapshot = snapshot - this.callbacksRef.onResumeSnapshotChange?.(snapshot) + this.repaintFromSnapshot(snapshot) } /** @@ -807,8 +880,7 @@ export class VideoGenerationClient { // Re-check: a send may have started while the fetch was in flight. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return - this.resumeSnapshot = snapshot - this.callbacksRef.onResumeSnapshotChange?.(snapshot) + this.repaintFromSnapshot(snapshot) })() } diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index 4da09c4ac..af5ac443a 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -4,14 +4,37 @@ import { GenerationClient, UnsupportedResponseStreamError, VideoGenerationClient, + reconstructImageResult, } from '../src' import type { StreamChunk } from '@tanstack/ai/client' +import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { ConnectConnectionAdapter, GenerationHydrationResult, } from '../src/connection-adapters' import type { GenerationResumeSnapshot, GenerationPersistence } from '../src' +// A durable output image artifact carrying an app-origin serve URL, used to +// verify the restore-path result repaint via reconstructImageResult. +const restoredImageArtifact: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-image-1', + threadId: 'thread-img', + runId: 'run-img', + name: 'image.png', + mimeType: 'image/png', + size: 2048, + createdAt: '2026-07-06T00:00:00.000Z', + url: '/api/artifacts/artifact-image-1', + source: { + activity: 'image', + path: 'runs/run-img/image.png', + provider: 'test', + model: 'test-image', + mediaType: 'image', + }, +} + // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( chunks: Array, @@ -1506,6 +1529,77 @@ describe('GenerationClient', () => { ) }) + it('repaints status/result from a stored complete image snapshot via reconstructResult', async () => { + const snapshot: GenerationResumeSnapshot = { + schemaVersion: 1, + resumeState: null, + status: 'complete', + activity: 'image', + result: { + id: 'img-1', + model: 'test-image', + artifacts: [restoredImageArtifact], + }, + } + const { persistence } = createMapPersistence({ + 'generation:img': snapshot, + }) + const onStatusChange = vi.fn() + const onResumeStateChange = vi.fn() + const client = new GenerationClient({ + id: 'img', + connection: createMockConnection([]), + persistence, + // Injected by the image hook/client — rebuilds the typed result from + // the durable artifact refs. + reconstructResult: reconstructImageResult, + onStatusChange, + onResumeStateChange, + }) + + // The completed snapshot repaints `result` (with the durable serve url) + // and `status`, as if the run had just finished. + await waitForCondition(() => { + expect(client.getResult()).toEqual({ + id: 'img-1', + model: 'test-image', + images: [{ url: '/api/artifacts/artifact-image-1' }], + artifacts: [restoredImageArtifact], + }) + }) + expect(client.getStatus()).toBe('success') + // A completed run has no in-flight identity. + expect(onResumeStateChange).toHaveBeenLastCalledWith(null) + }) + + it('emits onResumeStateChange with the in-flight identity from a stored running snapshot', async () => { + const snapshot: GenerationResumeSnapshot = { + schemaVersion: 1, + resumeState: { threadId: 'thread-run', runId: 'run-run' }, + status: 'running', + } + const { persistence } = createMapPersistence({ + 'generation:running': snapshot, + }) + const onResumeStateChange = vi.fn() + const client = new GenerationClient({ + id: 'running', + connection: createMockConnection([]), + persistence, + onResumeStateChange, + }) + + await waitForCondition(() => { + expect(onResumeStateChange).toHaveBeenCalledWith({ + threadId: 'thread-run', + runId: 'run-run', + }) + }) + // A restored running run presents as `generating` but never auto-tails. + expect(client.getStatus()).toBe('generating') + expect(client.getIsLoading()).toBe(false) + }) + it('skips hydration when an explicit initialResumeSnapshot seed is provided', async () => { const { persistence } = createMapPersistence({ 'generation:hero': storedSnapshot, @@ -1818,6 +1912,42 @@ describe('GenerationClient', () => { ) }) + it('repaints result from the server snapshot via reconstructResult (image)', async () => { + const { connection } = createHydratingConnection({ + resumeSnapshot: { + schemaVersion: 1, + resumeState: null, + status: 'complete', + activity: 'image', + result: { + id: 'srv-img', + model: 'test-image', + artifacts: [restoredImageArtifact], + }, + }, + activeRun: null, + }) + const onResumeStateChange = vi.fn() + const client = new GenerationClient({ + threadId: 'thread-img-server', + connection, + persistence: true, + reconstructResult: reconstructImageResult, + onResumeStateChange, + }) + + await waitForCondition(() => { + expect(client.getResult()).toEqual({ + id: 'srv-img', + model: 'test-image', + images: [{ url: '/api/artifacts/artifact-image-1' }], + artifacts: [restoredImageArtifact], + }) + }) + expect(client.getStatus()).toBe('success') + expect(onResumeStateChange).toHaveBeenLastCalledWith(null) + }) + it('does nothing when the connection exposes no hydrateGeneration', async () => { const client = new GenerationClient({ threadId: 'thread-server', diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 3a73dca95..c1843f512 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -50,6 +50,15 @@ export interface WithPersistenceOptions { | Array | Promise> nameArtifact?: (input: GenerationArtifactNameInput) => string + /** + * Map a freshly-persisted artifact ref to the durable app-origin URL that + * serves its bytes (your `GET` route around `retrieveArtifact` / + * `retrieveBlob`). The returned URL is stamped onto `ref.url` and written into + * the result's media field, so both the live and the restored result render + * durable media from your own origin instead of the provider's expiring link. + * Return `undefined` to leave a ref without a durable URL. + */ + artifactUrl?: (ref: PersistedArtifactRef) => string | undefined } export interface GenerationArtifactDescriptor { @@ -766,9 +775,55 @@ async function persistGenerationArtifacts( }) } + // Stamp the durable app-origin serve URL onto every ref that lacks one, so + // clients render + restore media from your own origin, not the provider link. + if (opts?.artifactUrl) { + for (let i = 0; i < refs.length; i++) { + const ref = refs[i] + if (ref && !ref.url) { + const url = opts.artifactUrl(ref) + if (url) refs[i] = { ...ref, url } + } + } + } + return refs } +/** + * Rewrite the live result's media fields to each output ref's durable serve URL + * (`ref.url`), so the live result matches what a reload restores. Keyed off the + * ref's `source.path`: `images.` → `result.images[i].url`, `video` → + * `result.url`, `audio` (object) → `result.audio.url`. tts (a base64 string) and + * transcription (json) have no media-URL field, so they are left as-is; their + * durable bytes are reachable via `result.artifacts`. A no-op when no ref has a + * `url`. + */ +function applyDurableMediaUrls( + result: Record, + refs: Array, +): Record { + let next = result + for (const ref of refs) { + if (ref.role !== 'output' || !ref.url) continue + const path = ref.source.path + if (path.startsWith('images.')) { + const index = Number(path.slice('images.'.length)) + const images = next.images + if (Array.isArray(images) && objectValue(images[index])) { + const cloned = [...images] + cloned[index] = { ...objectValue(images[index]), url: ref.url } + next = { ...next, images: cloned } + } + } else if (path === 'video') { + next = { ...next, url: ref.url } + } else if (path === 'audio' && objectValue(next.audio)) { + next = { ...next, audio: { ...objectValue(next.audio), url: ref.url } } + } + } + return next +} + // --------------------------------------------------------------------------- // Shared store / feature plan // --------------------------------------------------------------------------- @@ -1206,11 +1261,15 @@ export function withGenerationPersistence( result, ) if (refs.length === 0) return undefined - const existing = objectValue(result)?.artifacts - return { - ...(objectValue(result) ?? {}), + const base = objectValue(result) ?? {} + const existing = base.artifacts + const withArtifacts = { + ...base, artifacts: [...(Array.isArray(existing) ? existing : []), ...refs], } + // Point the live result's media at the durable serve URL (when + // `artifactUrl` stamped one), so live and restored results match. + return applyDurableMediaUrls(withArtifacts, refs) }) } diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index c3d8f1c04..98c8e2736 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -154,6 +154,30 @@ describe('withGenerationPersistence generation artifacts', () => { await expect(blob?.text()).resolves.toBe('output-image') }) + it('stamps a durable url on refs and rewrites the live result media (artifactUrl)', async () => { + const persistence = memoryPersistence() + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-url', + runId: 'run-url', + middleware: [ + withGenerationPersistence(persistence, { + artifactUrl: (ref) => `/artifacts/${ref.artifactId}`, + }), + ], + }) + + const ref = result.artifacts?.[0] + expect(ref).toBeDefined() + const expectedUrl = `/artifacts/${ref!.artifactId}` + // The ref carries the durable serve URL... + expect(ref!.url).toBe(expectedUrl) + // ...and the live result's media points at it too (durable everywhere). + expect(result.images[0]?.url).toBe(expectedUrl) + }) + it('retrieveArtifact / retrieveBlob fetch a persisted artifact and its bytes', async () => { const persistence = memoryPersistence() diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index 4c5b3f4ae..af976c48a 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructAudioResult } from '@tanstack/ai-client' import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -6,13 +7,11 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGenerateAudio hook. @@ -81,14 +80,8 @@ export interface UseGenerateAudioReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Array } /** @@ -141,6 +134,7 @@ export function useGenerateAudio( >({ ...options, devtools, + reconstructResult: reconstructAudioResult, }) return generation diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index 20e53bd13..693ff2f7c 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -1,18 +1,17 @@ import { useGeneration } from './use-generation' +import { reconstructImageResult } from '@tanstack/ai-client' import type { ImageGenerationResult, StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, ImageGenerateInput, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGenerateImage hook. @@ -81,14 +80,8 @@ export interface UseGenerateImageReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Array } /** @@ -143,6 +136,7 @@ export function useGenerateImage( >({ ...options, devtools, + reconstructResult: reconstructImageResult, }) return generation diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index a51fdc9c6..3afe373b0 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -5,14 +5,12 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, SpeechGenerateInput, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGenerateSpeech hook. @@ -81,14 +79,8 @@ export interface UseGenerateSpeechReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Array } /** diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index c54ddb5bc..688b37a3a 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -7,7 +7,6 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, @@ -16,7 +15,6 @@ import type { VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGenerateVideo hook. @@ -91,14 +89,8 @@ export interface UseGenerateVideoReturn { stop: () => void /** Clear all state and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Array } /** @@ -155,9 +147,9 @@ export function useGenerateVideo( const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(undefined) const [status, setStatus] = useState('idle') - const [resumeSnapshot, setResumeSnapshot] = useState< - GenerationResumeSnapshot | undefined - >(options.initialResumeSnapshot) + const [resumeState, setResumeState] = useState( + options.initialResumeSnapshot?.resumeState ?? null, + ) const optionsRef = useRef(options) optionsRef.current = options @@ -226,10 +218,8 @@ export function useGenerateVideo( onVideoStatusChange: (s: VideoStatusInfo | null) => { if (!disposedRef.current) setVideoStatus(s) }, - onResumeSnapshotChange: ( - snapshot: GenerationResumeSnapshot | undefined, - ) => { - if (!disposedRef.current) setResumeSnapshot(snapshot) + onResumeStateChange: (rs: GenerationResumeState | null) => { + if (!disposedRef.current) setResumeState(rs) }, } @@ -298,16 +288,6 @@ export function useGenerateVideo( status, stop, reset, - resumeSnapshot, - resumeState: resumeSnapshot?.resumeState ?? null, - pendingArtifacts: - resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, - resultArtifacts: - resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, + resumeState, } } - -// Stable fallbacks so consumers can safely depend on these arrays in effect -// dependency lists when no snapshot exists. -const EMPTY_PENDING_ARTIFACTS: Array = [] -const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 45f680ae4..0d00bbae6 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -8,13 +8,12 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, + GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useGeneration hook. @@ -65,6 +64,12 @@ export interface UseGenerationOptions { onProgress?: (progress: number, message?: string) => void /** Callback for each stream chunk (connect-based adapter mode only) */ onChunk?: (chunk: StreamChunk) => void + /** + * @internal Rebuild a typed result from a restored snapshot, injected by each + * specialized hook (image / audio / transcription / summarize). Forwarded to + * the client so a client-store / server-hydrate restore repaints `result`. + */ + reconstructResult?: (restored: GenerationRestoredResult) => TResult | null } /** @@ -91,14 +96,8 @@ export interface UseGenerationReturn< stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation state snapshot, if one is available */ - resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Array } /** @@ -146,9 +145,9 @@ export function useGeneration< const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(undefined) const [status, setStatus] = useState('idle') - const [resumeSnapshot, setResumeSnapshot] = useState< - GenerationResumeSnapshot | undefined - >(options.initialResumeSnapshot) + const [resumeState, setResumeState] = useState( + options.initialResumeSnapshot?.resumeState ?? null, + ) const optionsRef = useRef(options) optionsRef.current = options @@ -169,6 +168,9 @@ export function useGeneration< ...(opts.initialResumeSnapshot !== undefined && { initialResumeSnapshot: opts.initialResumeSnapshot, }), + ...(opts.reconstructResult + ? { reconstructResult: opts.reconstructResult } + : {}), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { hookName: 'useGeneration', @@ -202,8 +204,8 @@ export function useGeneration< onStatusChange: (s) => { if (!disposedRef.current) setStatus(s) }, - onResumeSnapshotChange: (snapshot) => { - if (!disposedRef.current) setResumeSnapshot(snapshot) + onResumeStateChange: (rs) => { + if (!disposedRef.current) setResumeState(rs) }, } @@ -270,16 +272,6 @@ export function useGeneration< status, stop, reset, - resumeSnapshot, - resumeState: resumeSnapshot?.resumeState ?? null, - pendingArtifacts: - resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, - resultArtifacts: - resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, + resumeState, } } - -// Stable fallbacks so consumers can safely depend on these arrays in effect -// dependency lists when no snapshot exists. -const EMPTY_PENDING_ARTIFACTS: Array = [] -const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index ba6694de4..03703fa32 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -1,18 +1,17 @@ import { useGeneration } from './use-generation' +import { reconstructSummarizeResult } from '@tanstack/ai-client' import type { StreamChunk, SummarizationResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, SummarizeGenerateInput, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useSummarize hook. @@ -81,14 +80,8 @@ export interface UseSummarizeReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Array } /** @@ -140,6 +133,7 @@ export function useSummarize( >({ ...options, devtools, + reconstructResult: reconstructSummarizeResult, }) return generation diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index fd1d1e4fe..316d50871 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -1,18 +1,17 @@ import { useGeneration } from './use-generation' +import { reconstructTranscriptionResult } from '@tanstack/ai-client' import type { StreamChunk, TranscriptionResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, TranscriptionGenerateInput, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the useTranscription hook. @@ -81,14 +80,8 @@ export interface UseTranscriptionReturn { stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Array } /** @@ -142,7 +135,7 @@ export function useTranscription( TranscriptionGenerateInput, TranscriptionResult, TTransformed - >({ ...options, devtools }) + >({ ...options, devtools, reconstructResult: reconstructTranscriptionResult }) return generation } diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index 27ded90c1..dc6693602 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -418,7 +418,7 @@ describe('useGeneration', () => { }) describe('persistence', () => { - it('hydrates the resume snapshot from storage on mount without starting a run', async () => { + it('repaints status from a stored complete snapshot on mount without starting a run', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createGenerationChunks({ id: '1' }), ) @@ -440,15 +440,48 @@ describe('useGeneration', () => { }), ) + // A completed snapshot repaints the normal `status` field to `success`. await waitFor(() => { - expect(result.current.resumeSnapshot).toMatchObject({ - status: 'complete', - result: { id: 'result-1' }, - }) + expect(result.current.status).toBe('success') }) expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrated') expect(connect).not.toHaveBeenCalled() - expect(result.current.status).toBe('idle') + // The base hook injects no reconstructResult, so `result` stays null. + expect(result.current.result).toBeNull() + expect(result.current.resumeState).toBeNull() + }) + + it('repaints resumeState from a stored running snapshot on mount', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const persistence: GenerationPersistence = { + getItem: vi.fn(() => ({ + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderHook(() => + useGeneration({ + id: 'running-hydrate', + connection: adapter, + persistence: persistence, + }), + ) + + await waitFor(() => { + expect(result.current.resumeState).toEqual({ + threadId: 'thread-resume', + runId: 'run-resume', + }) + }) + // A restored running run presents as `generating` but never auto-tails. + expect(result.current.status).toBe('generating') + expect(result.current.isLoading).toBe(false) + expect(connect).not.toHaveBeenCalled() }) it('server-driven (persistence: true) hydrates from the connection by threadId without a local store', async () => { @@ -473,15 +506,12 @@ describe('useGeneration', () => { }), ) + // The server snapshot repaints `status` to `success`. await waitFor(() => { - expect(result.current.resumeSnapshot).toMatchObject({ - status: 'complete', - result: { id: 'server-result' }, - }) + expect(result.current.status).toBe('success') }) expect(hydrateGeneration).toHaveBeenCalledWith('thread-server') expect(connect).not.toHaveBeenCalled() - expect(result.current.status).toBe('idle') }) it('generates normally under React StrictMode (dispose → remount replay)', async () => { @@ -505,7 +535,7 @@ describe('useGeneration', () => { }) }) - it('exposes sanitized artifact refs observed from a streamed result', async () => { + it('clears resumeState once a streamed run finishes', async () => { const adapter = createMockConnectionAdapter({ chunks: createReplayVideoChunks(), }) @@ -519,10 +549,10 @@ describe('useGeneration', () => { }) await waitFor(() => { - expect(result.current.resultArtifacts).toEqual([replayedVideoArtifact]) - expect(result.current.pendingArtifacts).toEqual([replayedVideoArtifact]) - expect(result.current.resumeSnapshot?.status).toBe('complete') + expect(result.current.status).toBe('success') }) + // Once the run ends the in-flight identity is gone. + expect(result.current.resumeState).toBeNull() }) }) }) @@ -610,6 +640,64 @@ describe('useGenerateImage', () => { expect(typeof result.current.stop).toBe('function') expect(typeof result.current.reset).toBe('function') }) + + it('restores a completed image result from a durable artifact url', async () => { + // useGenerateImage injects `reconstructImageResult`, so a restored complete + // snapshot repaints `result` with the durable serve url — as if the run had + // just finished. + const artifact: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-image-1', + threadId: 'thread-img', + runId: 'run-img', + name: 'image.png', + mimeType: 'image/png', + size: 2048, + createdAt: '2026-07-06T00:00:00.000Z', + url: '/api/artifacts/artifact-image-1', + source: { + activity: 'image', + path: 'runs/run-img/image.png', + provider: 'test', + model: 'test-image', + mediaType: 'image', + }, + } + const persistence: GenerationPersistence = { + getItem: vi.fn(() => ({ + resumeState: null, + status: 'complete' as const, + activity: 'image' as const, + result: { + id: 'img-restored', + model: 'test-image', + artifacts: [artifact], + }, + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + const adapter = createMockConnectionAdapter() + + const { result } = renderHook(() => + useGenerateImage({ + id: 'img-hydrate', + connection: adapter, + persistence: persistence, + }), + ) + + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + expect(result.current.result).toEqual({ + id: 'img-restored', + model: 'test-image', + images: [{ url: '/api/artifacts/artifact-image-1' }], + artifacts: [artifact], + }) + expect(result.current.resumeState).toBeNull() + }) }) describe('useGenerateSpeech', () => { @@ -958,8 +1046,7 @@ describe('useGenerateVideo', () => { expect(persistence.getItem).not.toHaveBeenCalled() expect(result.current.isLoading).toBe(false) expect(result.current.status).toBe('idle') - // The persisted snapshot remains exposed as read-only state. - expect(result.current.resumeSnapshot).toEqual(videoResumeSnapshot) + // The seeded in-flight identity is exposed as read-only `resumeState`. expect(result.current.resumeState).toEqual(videoResumeSnapshot.resumeState) }) }) diff --git a/packages/ai-solid/src/use-generate-audio.ts b/packages/ai-solid/src/use-generate-audio.ts index f790b1aa6..efafa8943 100644 --- a/packages/ai-solid/src/use-generate-audio.ts +++ b/packages/ai-solid/src/use-generate-audio.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructAudioResult } from '@tanstack/ai-client' import type { UseGenerationOptions, UseGenerationReturn, @@ -115,6 +116,7 @@ export function useGenerateAudio( >({ ...options, devtools, + reconstructResult: reconstructAudioResult, }) return generation diff --git a/packages/ai-solid/src/use-generate-image.ts b/packages/ai-solid/src/use-generate-image.ts index ce13d5a34..40f226604 100644 --- a/packages/ai-solid/src/use-generate-image.ts +++ b/packages/ai-solid/src/use-generate-image.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructImageResult } from '@tanstack/ai-client' import type { UseGenerationOptions, UseGenerationReturn, @@ -123,6 +124,7 @@ export function useGenerateImage( >({ ...options, devtools, + reconstructResult: reconstructImageResult, }) return generation diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index e520d3bc8..6fd991046 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -14,7 +14,6 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, @@ -23,7 +22,6 @@ import type { VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { Accessor } from 'solid-js' /** @@ -101,14 +99,8 @@ export interface UseGenerateVideoReturn { stop: () => void /** Clear all state and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: Accessor /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: Accessor - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Accessor> - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Accessor> } /** @@ -167,9 +159,10 @@ export function useGenerateVideo( const [isLoading, setIsLoading] = createSignal(false) const [error, setError] = createSignal(undefined) const [status, setStatus] = createSignal('idle') - const [resumeSnapshot, setResumeSnapshot] = createSignal< - GenerationResumeSnapshot | undefined - >(options.initialResumeSnapshot) + const [resumeState, setResumeState] = + createSignal( + options.initialResumeSnapshot?.resumeState ?? null, + ) let disposed = false // Built once. `untrack` keeps the option reads below from subscribing @@ -236,10 +229,8 @@ export function useGenerateVideo( onVideoStatusChange: (s: VideoStatusInfo | null) => { if (!disposed) setVideoStatus(s) }, - onResumeSnapshotChange: ( - snapshot: GenerationResumeSnapshot | undefined, - ) => { - if (!disposed) setResumeSnapshot(snapshot) + onResumeStateChange: (rs: GenerationResumeState | null) => { + if (!disposed) setResumeState(rs) }, } @@ -304,18 +295,6 @@ export function useGenerateVideo( status, stop, reset, - resumeSnapshot, - resumeState: () => resumeSnapshot()?.resumeState ?? null, - pendingArtifacts: () => - resumeSnapshot()?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, - resultArtifacts: () => - resumeSnapshot()?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, + resumeState, } } - -// Stable fallbacks so the accessors keep returning the same array identity -// while no snapshot exists. (A `createMemo` would buy nothing on top of this: -// a populated array's identity already comes from the snapshot object, and -// memos are one-shot under Solid's SSR build.) -const EMPTY_PENDING_ARTIFACTS: Array = [] -const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index f279c1e44..470a2947c 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -15,13 +15,12 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, + GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { Accessor } from 'solid-js' /** @@ -73,6 +72,12 @@ export interface UseGenerationOptions { onProgress?: (progress: number, message?: string) => void /** Callback for each stream chunk (connect-based adapter mode only) */ onChunk?: (chunk: StreamChunk) => void + /** + * @internal Rebuild a typed result from a restored snapshot, injected by each + * specialized hook (image / audio / transcription / summarize). Forwarded to + * the client so a client-store / server-hydrate restore repaints `result`. + */ + reconstructResult?: (restored: GenerationRestoredResult) => TResult | null } /** @@ -99,14 +104,8 @@ export interface UseGenerationReturn< stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: Accessor /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: Accessor - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: Accessor> - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: Accessor> } /** @@ -155,9 +154,9 @@ export function useGeneration< const [isLoading, setIsLoading] = createSignal(false) const [error, setError] = createSignal(undefined) const [status, setStatus] = createSignal('idle') - const [resumeSnapshot, setResumeSnapshot] = createSignal< - GenerationResumeSnapshot | undefined - >(options.initialResumeSnapshot) + const [resumeState, setResumeState] = createSignal( + options.initialResumeSnapshot?.resumeState ?? null, + ) let disposed = false // Built once. `untrack` keeps the option reads below from subscribing @@ -179,6 +178,9 @@ export function useGeneration< ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.reconstructResult + ? { reconstructResult: options.reconstructResult } + : {}), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { ...options.devtools, @@ -212,8 +214,8 @@ export function useGeneration< onStatusChange: (s) => { if (!disposed) setStatus(s) }, - onResumeSnapshotChange: (snapshot) => { - if (!disposed) setResumeSnapshot(snapshot) + onResumeStateChange: (rs) => { + if (!disposed) setResumeState(rs) }, } @@ -276,18 +278,6 @@ export function useGeneration< status, stop, reset, - resumeSnapshot, - resumeState: () => resumeSnapshot()?.resumeState ?? null, - pendingArtifacts: () => - resumeSnapshot()?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, - resultArtifacts: () => - resumeSnapshot()?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, + resumeState, } } - -// Stable fallbacks so the accessors keep returning the same array identity -// while no snapshot exists. (A `createMemo` would buy nothing on top of this: -// a populated array's identity already comes from the snapshot object, and -// memos are one-shot under Solid's SSR build.) -const EMPTY_PENDING_ARTIFACTS: Array = [] -const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-solid/src/use-summarize.ts b/packages/ai-solid/src/use-summarize.ts index b26268123..59380ea8b 100644 --- a/packages/ai-solid/src/use-summarize.ts +++ b/packages/ai-solid/src/use-summarize.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructSummarizeResult } from '@tanstack/ai-client' import type { UseGenerationOptions, UseGenerationReturn, @@ -121,6 +122,7 @@ export function useSummarize( >({ ...options, devtools, + reconstructResult: reconstructSummarizeResult, }) return generation diff --git a/packages/ai-solid/src/use-transcription.ts b/packages/ai-solid/src/use-transcription.ts index a2db667fe..00b139633 100644 --- a/packages/ai-solid/src/use-transcription.ts +++ b/packages/ai-solid/src/use-transcription.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructTranscriptionResult } from '@tanstack/ai-client' import type { UseGenerationOptions, UseGenerationReturn, @@ -127,7 +128,7 @@ export function useTranscription( TranscriptionGenerateInput, TranscriptionResult, TTransformed - >({ ...options, devtools }) + >({ ...options, devtools, reconstructResult: reconstructTranscriptionResult }) return generation } diff --git a/packages/ai-solid/tests/use-generation.test.ts b/packages/ai-solid/tests/use-generation.test.ts index a7c078686..77cd9896e 100644 --- a/packages/ai-solid/tests/use-generation.test.ts +++ b/packages/ai-solid/tests/use-generation.test.ts @@ -8,7 +8,12 @@ import { useTranscription } from '../src/use-transcription' import { useSummarize } from '../src/use-summarize' import { useGenerateVideo } from '../src/use-generate-video' import { createMockConnectionAdapter } from './test-utils' -import type { StreamChunk, TTSResult, TranscriptionResult } from '@tanstack/ai' +import type { + PersistedArtifactRef, + StreamChunk, + TTSResult, + TranscriptionResult, +} from '@tanstack/ai' import { EventType } from '@tanstack/ai' import type { ConnectConnectionAdapter, @@ -1164,8 +1169,7 @@ describe('useGenerateVideo', () => { expect(persistence.getItem).not.toHaveBeenCalled() expect(result.isLoading()).toBe(false) expect(result.status()).toBe('idle') - // The persisted snapshot remains exposed as read-only state. - expect(result.resumeSnapshot()).toEqual(videoResumeSnapshot) + // The seeded in-flight identity is exposed as read-only `resumeState`. expect(result.resumeState()).toEqual(videoResumeSnapshot.resumeState) }) }) @@ -1209,41 +1213,225 @@ describe('resume snapshot persistence', () => { } // Hydration and the persistence write queue are async; give both a turn. - const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + const flush = async () => { + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + } - it('hydrates a persisted snapshot into resumeSnapshot after mount', async () => { - const stored: GenerationResumeSnapshot = { - schemaVersion: 1, - resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, - status: 'running', + it('does not auto-fire a generation on mount from a persisted running snapshot', async () => { + // Regression guard for the removed generation resume surface: mounting a + // generation hook that has server persistence and a persisted `running` + // snapshot must NOT start a fresh empty-prompt generation. + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const persistence: GenerationPersistence = { + getItem: vi.fn(() => ({ + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, + })), + setItem: vi.fn(), + removeItem: vi.fn(), } - const { persistence } = createMapPersistence([ - ['generation:hydrate-me', stored], - ]) - const adapter = createMockConnectionAdapter() const { result } = renderHook(() => useGeneration({ - id: 'hydrate-me', + id: 'no-auto-fire', connection: adapter, persistence, + initialResumeSnapshot: { + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running', + }, }), ) - expect(result.resumeSnapshot()).toBeUndefined() - await flush() - expect(persistence.getItem).toHaveBeenCalledTimes(1) - expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrate-me') - expect(result.resumeSnapshot()).toEqual(stored) - expect(result.resumeState()).toEqual(stored.resumeState) - // Hydrating is display-only — it never starts a run. + expect(connect).not.toHaveBeenCalled() + // The explicit initialResumeSnapshot seed takes precedence over storage, + // so automatic hydration (getItem) is skipped entirely. + expect(persistence.getItem).not.toHaveBeenCalled() + expect(result.isLoading()).toBe(false) expect(result.status()).toBe('idle') + // The persisted snapshot is still exposed as read-only display state. + expect(result.resumeState()).toEqual({ + threadId: 'thread-resume', + runId: 'run-resume', + }) + }) + + it('repaints status from a stored complete snapshot on mount without starting a run', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const persistence: GenerationPersistence = { + getItem: vi.fn(() => ({ + resumeState: null, + status: 'complete' as const, + result: { id: 'result-1', model: 'image-model' }, + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderHook(() => + useGeneration({ + id: 'hydrated', + connection: adapter, + persistence, + }), + ) + + await flush() + + // A completed snapshot repaints the normal `status` field to `success`. + expect(result.status()).toBe('success') + expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrated') + expect(connect).not.toHaveBeenCalled() + // The base hook injects no reconstructResult, so `result` stays null. + expect(result.result()).toBeNull() + expect(result.resumeState()).toBeNull() + }) + + it('repaints resumeState from a stored running snapshot on mount', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const persistence: GenerationPersistence = { + getItem: vi.fn(() => ({ + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + const { result } = renderHook(() => + useGeneration({ + id: 'running-hydrate', + connection: adapter, + persistence, + }), + ) + + await flush() + + expect(result.resumeState()).toEqual({ + threadId: 'thread-resume', + runId: 'run-resume', + }) + // A restored running run presents as `generating` but never auto-tails. + expect(result.status()).toBe('generating') expect(result.isLoading()).toBe(false) + expect(connect).not.toHaveBeenCalled() + }) + + it('server-driven (persistence: true) hydrates from the connection by threadId without a local store', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: { + schemaVersion: 1 as const, + resumeState: null, + status: 'complete' as const, + result: { id: 'server-result', model: 'image-model' }, + }, + activeRun: null, + })) + + const { result } = renderHook(() => + useGeneration({ + threadId: 'thread-server', + connection: { ...adapter, hydrateGeneration }, + persistence: true, + }), + ) + + await flush() + + // The server snapshot repaints `status` to `success`. + expect(result.status()).toBe('success') + expect(hydrateGeneration).toHaveBeenCalledWith('thread-server') + expect(connect).not.toHaveBeenCalled() }) - it('hydrates a persisted snapshot for useGenerateVideo', async () => { + it('clears resumeState once a streamed run finishes', async () => { + const adapter = createMockConnectionAdapter({ + chunks: createReplayVideoChunks(), + }) + + const { result } = renderHook(() => + useGeneration({ connection: adapter }), + ) + + await result.generate({ prompt: 'replay' }) + + expect(result.status()).toBe('success') + // Once the run ends the in-flight identity is gone. + expect(result.resumeState()).toBeNull() + }) + + it('restores a completed image result from a durable artifact url', async () => { + // useGenerateImage injects `reconstructImageResult`, so a restored complete + // snapshot repaints `result` with the durable serve url — as if the run had + // just finished. + const artifact: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-image-1', + threadId: 'thread-img', + runId: 'run-img', + name: 'image.png', + mimeType: 'image/png', + size: 2048, + createdAt: '2026-07-06T00:00:00.000Z', + url: '/api/artifacts/artifact-image-1', + source: { + activity: 'image', + path: 'runs/run-img/image.png', + provider: 'test', + model: 'test-image', + mediaType: 'image', + }, + } + const persistence: GenerationPersistence = { + getItem: vi.fn(() => ({ + resumeState: null, + status: 'complete' as const, + activity: 'image' as const, + result: { + id: 'img-restored', + model: 'test-image', + artifacts: [artifact], + }, + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + const adapter = createMockConnectionAdapter() + + const { result } = renderHook(() => + useGenerateImage({ + id: 'img-hydrate', + connection: adapter, + persistence, + }), + ) + + await flush() + + expect(result.status()).toBe('success') + expect(result.result()).toEqual({ + id: 'img-restored', + model: 'test-image', + images: [{ url: '/api/artifacts/artifact-image-1' }], + artifacts: [artifact], + }) + expect(result.resumeState()).toBeNull() + }) + + it('repaints resumeState for useGenerateVideo from a stored running snapshot', async () => { const stored: GenerationResumeSnapshot = { schemaVersion: 1, resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, @@ -1267,11 +1455,13 @@ describe('resume snapshot persistence', () => { expect(persistence.getItem).toHaveBeenCalledWith( 'generation:video-hydrate-me', ) - expect(result.resumeSnapshot()).toEqual(stored) - expect(result.status()).toBe('idle') + expect(result.resumeState()).toEqual(stored.resumeState) + // A restored running run presents as `generating` but never auto-tails. + expect(result.status()).toBe('generating') + expect(result.isLoading()).toBe(false) }) - it('clears resumeSnapshot and removes the persisted record on reset', async () => { + it('clears resumeState and removes the persisted record on reset', async () => { const { persistence, store } = createMapPersistence() const { result } = renderHook(() => @@ -1289,11 +1479,10 @@ describe('resume snapshot persistence', () => { 'generation:reset-me', expect.objectContaining({ status: 'complete' }), ) - expect(result.resumeSnapshot()?.status).toBe('complete') result.reset() - expect(result.resumeSnapshot()).toBeUndefined() + expect(result.resumeState()).toBeNull() await flush() diff --git a/packages/ai-svelte/src/create-generate-audio.svelte.ts b/packages/ai-svelte/src/create-generate-audio.svelte.ts index f22128892..dd9da4c18 100644 --- a/packages/ai-svelte/src/create-generate-audio.svelte.ts +++ b/packages/ai-svelte/src/create-generate-audio.svelte.ts @@ -1,4 +1,5 @@ import { createGeneration } from './create-generation.svelte' +import { reconstructAudioResult } from '@tanstack/ai-client' import type { CreateGenerationOptions, CreateGenerationReturn, @@ -116,6 +117,7 @@ export function createGenerateAudio( >({ ...options, devtools, + reconstructResult: reconstructAudioResult, }) return { @@ -136,17 +138,8 @@ export function createGenerateAudio( reset: gen.reset, updateBody: gen.updateBody, dispose: gen.dispose, - get resumeSnapshot() { - return gen.resumeSnapshot - }, get resumeState() { return gen.resumeState }, - get pendingArtifacts() { - return gen.pendingArtifacts - }, - get resultArtifacts() { - return gen.resultArtifacts - }, } } diff --git a/packages/ai-svelte/src/create-generate-image.svelte.ts b/packages/ai-svelte/src/create-generate-image.svelte.ts index 762b6a862..684e5c366 100644 --- a/packages/ai-svelte/src/create-generate-image.svelte.ts +++ b/packages/ai-svelte/src/create-generate-image.svelte.ts @@ -1,4 +1,5 @@ import { createGeneration } from './create-generation.svelte' +import { reconstructImageResult } from '@tanstack/ai-client' import type { CreateGenerationOptions, CreateGenerationReturn, @@ -125,6 +126,7 @@ export function createGenerateImage( >({ ...options, devtools, + reconstructResult: reconstructImageResult, }) return { @@ -145,17 +147,8 @@ export function createGenerateImage( reset: gen.reset, updateBody: gen.updateBody, dispose: gen.dispose, - get resumeSnapshot() { - return gen.resumeSnapshot - }, get resumeState() { return gen.resumeState }, - get pendingArtifacts() { - return gen.pendingArtifacts - }, - get resultArtifacts() { - return gen.resultArtifacts - }, } } diff --git a/packages/ai-svelte/src/create-generate-speech.svelte.ts b/packages/ai-svelte/src/create-generate-speech.svelte.ts index 9f3d6ea6b..e41300655 100644 --- a/packages/ai-svelte/src/create-generate-speech.svelte.ts +++ b/packages/ai-svelte/src/create-generate-speech.svelte.ts @@ -131,17 +131,8 @@ export function createGenerateSpeech( reset: gen.reset, updateBody: gen.updateBody, dispose: gen.dispose, - get resumeSnapshot() { - return gen.resumeSnapshot - }, get resumeState() { return gen.resumeState }, - get pendingArtifacts() { - return gen.pendingArtifacts - }, - get resultArtifacts() { - return gen.resultArtifacts - }, } } diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index 32d29ded5..d39d8207c 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -6,7 +6,6 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, @@ -15,7 +14,6 @@ import type { VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the createGenerateVideo function. @@ -96,14 +94,8 @@ export interface CreateGenerateVideoReturn { dispose: () => void /** Update additional body parameters */ updateBody: (body: Record) => void - /** Lightweight generation resume snapshot, if one is available */ - readonly resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ readonly resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - readonly pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - readonly resultArtifacts: Array } /** @@ -161,18 +153,11 @@ export function createGenerateVideo( let isLoading = $state(false) let error = $state(undefined) let status = $state('idle') - let resumeSnapshot = $state( - options.initialResumeSnapshot, + let resumeState = $state( + options.initialResumeSnapshot?.resumeState ?? null, ) let disposed = false - const setResumeSnapshotState = ( - snapshot: GenerationResumeSnapshot | undefined, - ) => { - if (disposed) return - resumeSnapshot = snapshot - } - // `body` uses a conditional spread because `VideoGenerationClientOptions.body` // is declared `body?: Record` (absent vs. present) under // `exactOptionalPropertyTypes`. The optional caller `options.body` may be @@ -239,7 +224,10 @@ export function createGenerateVideo( if (disposed) return videoStatus = s }, - onResumeSnapshotChange: setResumeSnapshotState, + onResumeStateChange: (rs: GenerationResumeState | null) => { + if (disposed) return + resumeState = rs + }, } let client: VideoGenerationClient @@ -320,17 +308,8 @@ export function createGenerateVideo( reset, dispose, updateBody, - get resumeSnapshot() { - return resumeSnapshot - }, get resumeState() { - return resumeSnapshot?.resumeState ?? null - }, - get pendingArtifacts() { - return resumeSnapshot?.pendingArtifacts ?? [] - }, - get resultArtifacts() { - return resumeSnapshot?.result?.artifacts ?? [] + return resumeState }, } } diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index c7691cf91..0e61eccc4 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -7,13 +7,12 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, + GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' /** * Options for the createGeneration function. @@ -64,6 +63,12 @@ export interface CreateGenerationOptions { onProgress?: (progress: number, message?: string) => void /** Callback for each stream chunk (connect-based adapter mode only) */ onChunk?: (chunk: StreamChunk) => void + /** + * @internal Rebuild a typed result from a restored snapshot, injected by each + * specialized function (image / audio / transcription / summarize). Forwarded + * to the client so a client-store / server-hydrate restore repaints `result`. + */ + reconstructResult?: (restored: GenerationRestoredResult) => TResult | null } /** @@ -94,14 +99,8 @@ export interface CreateGenerationReturn< dispose: () => void /** Update additional body parameters */ updateBody: (body: Record) => void - /** Lightweight generation resume snapshot, if one is available */ - readonly resumeSnapshot: GenerationResumeSnapshot | undefined /** Identity of the in-flight run while one is streaming, or null after it ends */ readonly resumeState: GenerationResumeState | null - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - readonly pendingArtifacts: Array - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - readonly resultArtifacts: Array } /** @@ -162,18 +161,11 @@ export function createGeneration< let isLoading = $state(false) let error = $state(undefined) let status = $state('idle') - let resumeSnapshot = $state( - options.initialResumeSnapshot, + let resumeState = $state( + options.initialResumeSnapshot?.resumeState ?? null, ) let disposed = false - const setResumeSnapshotState = ( - snapshot: GenerationResumeSnapshot | undefined, - ) => { - if (disposed) return - resumeSnapshot = snapshot - } - // `body` uses a conditional spread because `GenerationClientOptions.body` // is declared `body?: Record` (absent vs. present) under // `exactOptionalPropertyTypes`. Assigning `undefined` directly would be @@ -189,6 +181,9 @@ export function createGeneration< ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.reconstructResult + ? { reconstructResult: options.reconstructResult } + : {}), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { ...options.devtools, @@ -226,7 +221,10 @@ export function createGeneration< if (disposed) return status = s }, - onResumeSnapshotChange: setResumeSnapshotState, + onResumeStateChange: (rs: GenerationResumeState | null) => { + if (disposed) return + resumeState = rs + }, } let client: GenerationClient @@ -301,17 +299,8 @@ export function createGeneration< reset, dispose, updateBody, - get resumeSnapshot() { - return resumeSnapshot - }, get resumeState() { - return resumeSnapshot?.resumeState ?? null - }, - get pendingArtifacts() { - return resumeSnapshot?.pendingArtifacts ?? [] - }, - get resultArtifacts() { - return resumeSnapshot?.result?.artifacts ?? [] + return resumeState }, } } diff --git a/packages/ai-svelte/src/create-summarize.svelte.ts b/packages/ai-svelte/src/create-summarize.svelte.ts index 38c24b62a..dde442282 100644 --- a/packages/ai-svelte/src/create-summarize.svelte.ts +++ b/packages/ai-svelte/src/create-summarize.svelte.ts @@ -1,4 +1,5 @@ import { createGeneration } from './create-generation.svelte' +import { reconstructSummarizeResult } from '@tanstack/ai-client' import type { CreateGenerationOptions, CreateGenerationReturn, @@ -120,6 +121,7 @@ export function createSummarize( >({ ...options, devtools, + reconstructResult: reconstructSummarizeResult, }) return { @@ -140,17 +142,8 @@ export function createSummarize( reset: gen.reset, updateBody: gen.updateBody, dispose: gen.dispose, - get resumeSnapshot() { - return gen.resumeSnapshot - }, get resumeState() { return gen.resumeState }, - get pendingArtifacts() { - return gen.pendingArtifacts - }, - get resultArtifacts() { - return gen.resultArtifacts - }, } } diff --git a/packages/ai-svelte/src/create-transcription.svelte.ts b/packages/ai-svelte/src/create-transcription.svelte.ts index ba0134b57..9403eb7a9 100644 --- a/packages/ai-svelte/src/create-transcription.svelte.ts +++ b/packages/ai-svelte/src/create-transcription.svelte.ts @@ -1,4 +1,5 @@ import { createGeneration } from './create-generation.svelte' +import { reconstructTranscriptionResult } from '@tanstack/ai-client' import type { CreateGenerationOptions, CreateGenerationReturn, @@ -129,6 +130,7 @@ export function createTranscription( >({ ...options, devtools, + reconstructResult: reconstructTranscriptionResult, }) return { @@ -149,17 +151,8 @@ export function createTranscription( reset: gen.reset, updateBody: gen.updateBody, dispose: gen.dispose, - get resumeSnapshot() { - return gen.resumeSnapshot - }, get resumeState() { return gen.resumeState }, - get pendingArtifacts() { - return gen.pendingArtifacts - }, - get resultArtifacts() { - return gen.resultArtifacts - }, } } diff --git a/packages/ai-svelte/tests/create-generation.test.ts b/packages/ai-svelte/tests/create-generation.test.ts index 24a7b4052..7216daa74 100644 --- a/packages/ai-svelte/tests/create-generation.test.ts +++ b/packages/ai-svelte/tests/create-generation.test.ts @@ -7,7 +7,11 @@ import { createSummarize } from '../src/create-summarize.svelte' import { createGenerateVideo } from '../src/create-generate-video.svelte' import { createMockConnectionAdapter } from './test-utils' import { EventType, type StreamChunk } from '@tanstack/ai' -import type { TTSResult, TranscriptionResult } from '@tanstack/ai' +import type { + PersistedArtifactRef, + TTSResult, + TranscriptionResult, +} from '@tanstack/ai' import type { ConnectConnectionAdapter, GenerationResumeSnapshot, @@ -274,7 +278,7 @@ describe('createGeneration', () => { }) describe('resume snapshot persistence', () => { - it('hydrates a persisted snapshot into resumeSnapshot on creation', async () => { + it('repaints resumeState from a stored running snapshot on creation', async () => { const persistence = createMapPersistence({ 'generation:hydrate-me': { schemaVersion: 1, @@ -290,28 +294,51 @@ describe('createGeneration', () => { }) // Hydration is async — the storage read is awaited off the constructor. - expect(gen.resumeSnapshot).toBeUndefined() + expect(gen.resumeState).toBeNull() await flushAsync() expect(persistence.getItem).toHaveBeenCalledTimes(1) expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrate-me') - expect(gen.resumeSnapshot).toEqual({ - schemaVersion: 1, - resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, - status: 'running', - }) + // A restored running run presents as `generating` but never auto-tails. expect(gen.resumeState).toEqual({ threadId: 'thread-stored', runId: 'run-stored', }) + expect(gen.status).toBe('generating') + expect(gen.isLoading).toBe(false) }) - it('clears resumeSnapshot and removes the persisted record on reset', async () => { + it('repaints status from a stored complete snapshot on creation without starting a run', async () => { const persistence = createMapPersistence({ - 'generation:reset-me': { + 'generation:complete-me': { schemaVersion: 1, resumeState: null, status: 'complete', + result: { id: 'result-1', model: 'image-model' }, + }, + }) + + const gen = createGeneration({ + id: 'complete-me', + connection: createMockConnectionAdapter(), + persistence, + }) + + await flushAsync() + + // A completed snapshot repaints the normal `status` field to `success`. + expect(gen.status).toBe('success') + // The base function injects no reconstructResult, so `result` stays null. + expect(gen.result).toBeNull() + expect(gen.resumeState).toBeNull() + }) + + it('clears resumeState and removes the persisted record on reset', async () => { + const persistence = createMapPersistence({ + 'generation:reset-me': { + schemaVersion: 1, + resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, + status: 'running', }, }) @@ -322,11 +349,13 @@ describe('createGeneration', () => { }) await flushAsync() - expect(gen.resumeSnapshot).toBeDefined() + expect(gen.resumeState).toEqual({ + threadId: 'thread-stored', + runId: 'run-stored', + }) gen.reset() - expect(gen.resumeSnapshot).toBeUndefined() expect(gen.resumeState).toBeNull() await flushAsync() @@ -450,6 +479,62 @@ describe('createGenerateImage', () => { expect(typeof gen.stop).toBe('function') expect(typeof gen.reset).toBe('function') }) + + it('restores a completed image result from a durable artifact url', async () => { + // createGenerateImage injects `reconstructImageResult`, so a restored + // complete snapshot repaints `result` with the durable serve url — as if the + // run had just finished. + const artifact: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-image-1', + threadId: 'thread-img', + runId: 'run-img', + name: 'image.png', + mimeType: 'image/png', + size: 2048, + createdAt: '2026-07-06T00:00:00.000Z', + url: '/api/artifacts/artifact-image-1', + source: { + activity: 'image', + path: 'runs/run-img/image.png', + provider: 'test', + model: 'test-image', + mediaType: 'image', + }, + } + const persistence = createMapPersistence({ + 'generation:img-hydrate': { + schemaVersion: 1, + resumeState: null, + status: 'complete', + activity: 'image', + result: { + id: 'img-restored', + model: 'test-image', + artifacts: [artifact], + }, + }, + }) + + const gen = createGenerateImage({ + id: 'img-hydrate', + connection: createMockConnectionAdapter(), + persistence, + }) + + await flushAsync() + + // The completed snapshot repaints `status` and rebuilds `result` from the + // durable serve url. + expect(gen.status).toBe('success') + expect(gen.result).toEqual({ + id: 'img-restored', + model: 'test-image', + images: [{ url: '/api/artifacts/artifact-image-1' }], + artifacts: [artifact], + }) + expect(gen.resumeState).toBeNull() + }) }) describe('createGenerateSpeech', () => { @@ -822,8 +907,7 @@ describe('createGenerateVideo', () => { expect(getItem).not.toHaveBeenCalled() expect(gen.isLoading).toBe(false) expect(gen.status).toBe('idle') - // The persisted snapshot remains exposed as read-only state. - expect(gen.resumeSnapshot).toEqual(videoResumeSnapshot) + // The seeded in-flight identity is exposed as read-only `resumeState`. expect(gen.resumeState).toEqual(videoResumeSnapshot.resumeState) }) diff --git a/packages/ai-vue/src/use-generate-audio.ts b/packages/ai-vue/src/use-generate-audio.ts index ee62ec9b3..3a325f5f7 100644 --- a/packages/ai-vue/src/use-generate-audio.ts +++ b/packages/ai-vue/src/use-generate-audio.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructAudioResult } from '@tanstack/ai-client' import type { UseGenerationOptions, UseGenerationReturn, @@ -115,6 +116,7 @@ export function useGenerateAudio( >({ ...options, devtools, + reconstructResult: reconstructAudioResult, }) return generation diff --git a/packages/ai-vue/src/use-generate-image.ts b/packages/ai-vue/src/use-generate-image.ts index 13dc2f74d..339f0666f 100644 --- a/packages/ai-vue/src/use-generate-image.ts +++ b/packages/ai-vue/src/use-generate-image.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructImageResult } from '@tanstack/ai-client' import type { UseGenerationOptions, UseGenerationReturn, @@ -125,6 +126,7 @@ export function useGenerateImage( >({ ...options, devtools, + reconstructResult: reconstructImageResult, }) return generation diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 80c430015..67e4c51de 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -14,7 +14,6 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, GenerationResumeSnapshot, GenerationResumeState, @@ -23,7 +22,6 @@ import type { VideoGenerateResult, VideoStatusInfo, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { DeepReadonly, ShallowRef } from 'vue' /** @@ -101,14 +99,8 @@ export interface UseGenerateVideoReturn { stop: () => void /** Clear all state and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: DeepReadonly> /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: DeepReadonly> - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: DeepReadonly>> - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: DeepReadonly>> } /** @@ -165,32 +157,11 @@ export function useGenerateVideo( const isLoading = shallowRef(false) const error = shallowRef(undefined) const status = shallowRef('idle') - const resumeSnapshot = shallowRef( - options.initialResumeSnapshot, - ) const resumeState = shallowRef( options.initialResumeSnapshot?.resumeState ?? null, ) - const pendingArtifacts = shallowRef>( - options.initialResumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, - ) - const resultArtifacts = shallowRef>( - options.initialResumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, - ) let disposed = false - const setResumeSnapshotState = ( - snapshot: GenerationResumeSnapshot | undefined, - ) => { - if (disposed) return - resumeSnapshot.value = snapshot - resumeState.value = snapshot?.resumeState ?? null - pendingArtifacts.value = - snapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS - resultArtifacts.value = - snapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS - } - // Conditional spread on `body`: `VideoGenerationClientOptions.body` is a // strict optional and under EOPT we must omit the key when absent rather // than assign `undefined`. @@ -256,7 +227,10 @@ export function useGenerateVideo( if (disposed) return videoStatus.value = s }, - onResumeSnapshotChange: setResumeSnapshotState, + onResumeStateChange: (rs: GenerationResumeState | null) => { + if (disposed) return + resumeState.value = rs + }, } let client: VideoGenerationClient @@ -327,14 +301,6 @@ export function useGenerateVideo( status: readonly(status), stop, reset, - resumeSnapshot: readonly(resumeSnapshot), resumeState: readonly(resumeState), - pendingArtifacts: readonly(pendingArtifacts), - resultArtifacts: readonly(resultArtifacts), } } - -// Shared fallbacks so a snapshot change that leaves the artifact lists empty -// reassigns the same reference and doesn't trigger dependent watchers. -const EMPTY_PENDING_ARTIFACTS: Array = [] -const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index edb69420a..e15114862 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -15,13 +15,12 @@ import type { GenerationClientOptions, GenerationClientState, GenerationFetcher, - GenerationPendingArtifact, GenerationPersistence, + GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' -import type { PersistedArtifactRef } from '@tanstack/ai/client' import type { DeepReadonly, ShallowRef } from 'vue' /** @@ -73,6 +72,13 @@ export interface UseGenerationOptions { onProgress?: (progress: number, message?: string) => void /** Callback for each stream chunk (connect-based adapter mode only) */ onChunk?: (chunk: StreamChunk) => void + /** + * @internal Rebuild a typed result from a restored snapshot, injected by each + * specialized composable (image / audio / transcription / summarize). + * Forwarded to the client so a client-store / server-hydrate restore repaints + * `result`. + */ + reconstructResult?: (restored: GenerationRestoredResult) => TResult | null } /** @@ -99,14 +105,8 @@ export interface UseGenerationReturn< stop: () => void /** Clear result, error, and return to idle */ reset: () => void - /** Lightweight generation resume snapshot, if one is available */ - resumeSnapshot: DeepReadonly> /** Identity of the in-flight run while one is streaming, or null after it ends */ resumeState: DeepReadonly> - /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ - pendingArtifacts: DeepReadonly>> - /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ - resultArtifacts: DeepReadonly>> } /** @@ -157,32 +157,11 @@ export function useGeneration< const isLoading = shallowRef(false) const error = shallowRef(undefined) const status = shallowRef('idle') - const resumeSnapshot = shallowRef( - options.initialResumeSnapshot, - ) const resumeState = shallowRef( options.initialResumeSnapshot?.resumeState ?? null, ) - const pendingArtifacts = shallowRef>( - options.initialResumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS, - ) - const resultArtifacts = shallowRef>( - options.initialResumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS, - ) let disposed = false - const setResumeSnapshotState = ( - snapshot: GenerationResumeSnapshot | undefined, - ) => { - if (disposed) return - resumeSnapshot.value = snapshot - resumeState.value = snapshot?.resumeState ?? null - pendingArtifacts.value = - snapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS - resultArtifacts.value = - snapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS - } - // Conditional spread on `body`: `GenerationClientOptions.body` is a strict // optional (`body?: Record`), and under EOPT we must omit the // key when absent rather than assign `undefined`. @@ -196,6 +175,9 @@ export function useGeneration< ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.reconstructResult + ? { reconstructResult: options.reconstructResult } + : {}), devtoolsBridgeFactory: createGenerationDevtoolsBridge, devtools: { ...options.devtools, @@ -233,7 +215,10 @@ export function useGeneration< if (disposed) return status.value = s }, - onResumeSnapshotChange: setResumeSnapshotState, + onResumeStateChange: (rs: GenerationResumeState | null) => { + if (disposed) return + resumeState.value = rs + }, } let client: GenerationClient @@ -302,14 +287,6 @@ export function useGeneration< status: readonly(status), stop, reset, - resumeSnapshot: readonly(resumeSnapshot), resumeState: readonly(resumeState), - pendingArtifacts: readonly(pendingArtifacts), - resultArtifacts: readonly(resultArtifacts), } } - -// Shared fallbacks so a snapshot change that leaves the artifact lists empty -// reassigns the same reference and doesn't trigger dependent watchers. -const EMPTY_PENDING_ARTIFACTS: Array = [] -const EMPTY_RESULT_ARTIFACTS: Array = [] diff --git a/packages/ai-vue/src/use-summarize.ts b/packages/ai-vue/src/use-summarize.ts index c96610ec1..d00a32e9b 100644 --- a/packages/ai-vue/src/use-summarize.ts +++ b/packages/ai-vue/src/use-summarize.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructSummarizeResult } from '@tanstack/ai-client' import type { UseGenerationOptions, UseGenerationReturn, @@ -121,6 +122,7 @@ export function useSummarize( >({ ...options, devtools, + reconstructResult: reconstructSummarizeResult, }) return generation diff --git a/packages/ai-vue/src/use-transcription.ts b/packages/ai-vue/src/use-transcription.ts index a4f960537..6bfae4c91 100644 --- a/packages/ai-vue/src/use-transcription.ts +++ b/packages/ai-vue/src/use-transcription.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructTranscriptionResult } from '@tanstack/ai-client' import type { UseGenerationOptions, UseGenerationReturn, @@ -126,7 +127,7 @@ export function useTranscription( TranscriptionGenerateInput, TranscriptionResult, TTransformed - >({ ...options, devtools }) + >({ ...options, devtools, reconstructResult: reconstructTranscriptionResult }) return generation } diff --git a/packages/ai-vue/tests/use-generation.test.ts b/packages/ai-vue/tests/use-generation.test.ts index 1b7b70985..dd30c72e0 100644 --- a/packages/ai-vue/tests/use-generation.test.ts +++ b/packages/ai-vue/tests/use-generation.test.ts @@ -9,10 +9,14 @@ import { useTranscription } from '../src/use-transcription' import { useSummarize } from '../src/use-summarize' import { useGenerateVideo } from '../src/use-generate-video' import { createMockConnectionAdapter } from './test-utils' -import type { StreamChunk, TTSResult, TranscriptionResult } from '@tanstack/ai' +import type { + PersistedArtifactRef, + StreamChunk, + TTSResult, + TranscriptionResult, +} from '@tanstack/ai' import type { ConnectConnectionAdapter, - GenerationPersistence, GenerationResumeSnapshot, RunAgentInputContext, } from '@tanstack/ai-client' @@ -21,7 +25,12 @@ import type { DeepReadonly } from 'vue' // Helper to create generation stream chunks function createGenerationChunks(result: unknown): Array { return [ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, + { + type: 'RUN_STARTED', + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, { type: 'CUSTOM', name: 'generation:result', @@ -31,6 +40,7 @@ function createGenerationChunks(result: unknown): Array { { type: 'RUN_FINISHED', runId: 'run-1', + threadId: 'thread-1', finishReason: 'stop', timestamp: Date.now(), }, @@ -122,25 +132,6 @@ function createRunContextCaptureAdapter(chunks: Array): { return { adapter, connect, runContexts } } -/** Map-backed storage adapter standing in for localStorage/IndexedDB. */ -function createMapPersistence( - seed?: Record, -): GenerationPersistence & { store: Map } { - const store = new Map( - Object.entries(seed ?? {}), - ) - return { - store, - getItem: vi.fn((key: string) => store.get(key) ?? null), - setItem: vi.fn((key: string, value: GenerationResumeSnapshot) => { - store.set(key, value) - }), - removeItem: vi.fn((key: string) => { - store.delete(key) - }), - } -} - // Helper to create error stream chunks function createErrorChunks(message: string): Array { return [ @@ -354,74 +345,114 @@ describe('useGeneration', () => { }) }) - describe('resume snapshot persistence', () => { - it('hydrates a persisted snapshot into resumeSnapshot on mount', async () => { - const stored: GenerationResumeSnapshot = { - schemaVersion: 1, - resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, - status: 'running', - } - const persistence = createMapPersistence({ - 'generation:hydrate-me': stored, - }) + describe('persistence', () => { + it('repaints status from a stored complete snapshot on mount without starting a run', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const getItem = vi.fn(() => ({ + resumeState: null, + status: 'complete' as const, + result: { id: 'result-1', model: 'image-model' }, + })) const { result } = renderHook(() => useGeneration({ - id: 'hydrate-me', - fetcher: async () => ({ id: '1' }), - persistence, + id: 'hydrated', + connection: adapter, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, + }), + ) + + await flushPromises() + await nextTick() + + // A completed snapshot repaints the normal `status` field to `success`. + expect(result.status.value).toBe('success') + expect(getItem).toHaveBeenCalledWith('generation:hydrated') + expect(connect).not.toHaveBeenCalled() + // The base hook injects no reconstructResult, so `result` stays null. + expect(result.result.value).toBeNull() + expect(result.resumeState.value).toBeNull() + }) + + it('repaints resumeState from a stored running snapshot on mount', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const getItem = vi.fn(() => ({ + resumeState: { threadId: 'thread-resume', runId: 'run-resume' }, + status: 'running' as const, + })) + + const { result } = renderHook(() => + useGeneration({ + id: 'running-hydrate', + connection: adapter, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, }), ) - // Hydration is async: it starts at construction and resolves before the - // first flush, so the snapshot is only visible after awaiting. - expect(result.resumeSnapshot.value).toBeUndefined() await flushPromises() await nextTick() - expect(persistence.getItem).toHaveBeenCalledTimes(1) - expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrate-me') - expect(result.resumeSnapshot.value).toEqual(stored) - expect(result.resumeState.value).toEqual(stored.resumeState) - // Hydration alone must not start a run. + expect(result.resumeState.value).toEqual({ + threadId: 'thread-resume', + runId: 'run-resume', + }) + // A restored running run presents as `generating` but never auto-tails. + expect(result.status.value).toBe('generating') expect(result.isLoading.value).toBe(false) - expect(result.status.value).toBe('idle') + expect(connect).not.toHaveBeenCalled() }) - it('clears resumeSnapshot and removes the persisted record on reset', async () => { - const persistence = createMapPersistence() + it('server-driven (persistence: true) hydrates from the connection by threadId without a local store', async () => { + const { adapter, connect } = createRunContextCaptureAdapter( + createGenerationChunks({ id: '1' }), + ) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: { + schemaVersion: 1 as const, + resumeState: null, + status: 'complete' as const, + result: { id: 'server-result', model: 'image-model' }, + }, + activeRun: null, + })) const { result } = renderHook(() => useGeneration({ - id: 'reset-me', - connection: createMockConnectionAdapter({ - chunks: createGenerationChunks({ id: '1' }), - }), - persistence, + threadId: 'thread-server', + connection: { ...adapter, hydrateGeneration }, + persistence: true, }), ) - await result.generate({ prompt: 'test' }) await flushPromises() await nextTick() - expect(result.resumeSnapshot.value).toBeDefined() - expect(persistence.setItem).toHaveBeenCalledWith( - 'generation:reset-me', - expect.objectContaining({ status: 'complete' }), + // The server snapshot repaints `status` to `success`. + expect(result.status.value).toBe('success') + expect(hydrateGeneration).toHaveBeenCalledWith('thread-server') + expect(connect).not.toHaveBeenCalled() + }) + + it('clears resumeState once a streamed run finishes', async () => { + const adapter = createMockConnectionAdapter({ + chunks: createGenerationChunks({ id: '1' }), + }) + + const { result } = renderHook(() => + useGeneration({ connection: adapter }), ) - expect(persistence.store.get('generation:reset-me')).toBeDefined() - result.reset() + await result.generate({ prompt: 'replay' }) await flushPromises() await nextTick() - expect(result.resumeSnapshot.value).toBeUndefined() + expect(result.status.value).toBe('success') + // Once the run ends the in-flight identity is gone. expect(result.resumeState.value).toBeNull() - expect(result.pendingArtifacts.value).toEqual([]) - expect(result.resultArtifacts.value).toEqual([]) - expect(persistence.removeItem).toHaveBeenCalledWith('generation:reset-me') - expect(persistence.store.has('generation:reset-me')).toBe(false) }) }) @@ -534,6 +565,61 @@ describe('useGenerateImage', () => { expect(typeof result.stop).toBe('function') expect(typeof result.reset).toBe('function') }) + + it('restores a completed image result from a durable artifact url', async () => { + // useGenerateImage injects `reconstructImageResult`, so a restored complete + // snapshot repaints `result` with the durable serve url — as if the run had + // just finished. + const artifact: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-image-1', + threadId: 'thread-img', + runId: 'run-img', + name: 'image.png', + mimeType: 'image/png', + size: 2048, + createdAt: '2026-07-06T00:00:00.000Z', + url: '/api/artifacts/artifact-image-1', + source: { + activity: 'image', + path: 'runs/run-img/image.png', + provider: 'test', + model: 'test-image', + mediaType: 'image', + }, + } + const getItem = vi.fn(() => ({ + resumeState: null, + status: 'complete' as const, + activity: 'image' as const, + result: { + id: 'img-restored', + model: 'test-image', + artifacts: [artifact], + }, + })) + const adapter = createMockConnectionAdapter() + + const { result } = renderHook(() => + useGenerateImage({ + id: 'img-hydrate', + connection: adapter, + persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, + }), + ) + + await flushPromises() + await nextTick() + + expect(result.status.value).toBe('success') + expect(result.result.value).toEqual({ + id: 'img-restored', + model: 'test-image', + images: [{ url: '/api/artifacts/artifact-image-1' }], + artifacts: [artifact], + }) + expect(result.resumeState.value).toBeNull() + }) }) describe('useGenerateSpeech', () => { @@ -963,8 +1049,7 @@ describe('useGenerateVideo', () => { expect(getItem).not.toHaveBeenCalled() expect(result.isLoading.value).toBe(false) expect(result.status.value).toBe('idle') - // The persisted snapshot remains exposed as read-only state. - expect(result.resumeSnapshot.value).toEqual(videoResumeSnapshot) + // The seeded in-flight identity is exposed as read-only `resumeState`. expect(result.resumeState.value).toEqual(videoResumeSnapshot.resumeState) }) diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index 886ab5359..343d50194 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -118,9 +118,18 @@ randomize per mount. The generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGeneration`, `useSummarize`, `useTranscription`, …) take the **same `persistence` option** as -`useChat` — `boolean | adapter` — with the same two-mode split. Whichever mode, -what persists is a `GenerationResumeSnapshot`: run identity, status, error, and -result metadata (ids, model, video `jobId`), **never the generated media bytes**. +`useChat` — `boolean | adapter` — with the same two-mode split. **The hooks are +transparent, mirroring `useChat`:** whichever mode, a reload repaints the hook's +**normal** fields — `status` (`'idle'` / `'generating'` / `'success'` / +`'error'`), `error`, and `result` — as if the run had just finished. There is +**no** `resumeSnapshot`, `pendingArtifacts`, or `resultArtifacts` field. The one +extra field is `resumeState`: the in-flight run identity (`{ threadId, runId, +pendingArtifacts? }`) or `null` — non-null only while a run streams. The +persisted record holds run identity, status, error, and result metadata (ids, +model, video `jobId`), **never the generated media bytes**. + +The hook return is exactly `generate` / `result` / `isLoading` / `error` / +`status` / `stop` / `reset` / `resumeState`. ### Mode A — client-driven (a storage adapter) @@ -128,19 +137,25 @@ result metadata (ids, model, video `jobId`), **never the generated media bytes** const image = useGenerateImage({ id: 'hero-image', // stable — the storage key is `generation:` connection: fetchServerSentEvents('/api/generate/image'), - persistence: localStoragePersistence(), + persistence: localStoragePersistence(), // bare — no type argument }) -// After a reload: image.resumeSnapshot?.status is the last run's outcome. -// image.resumeState is non-null only WHILE a run is streaming. +// After a reload: image.status / image.result / image.error are the last run's +// outcome, read exactly like a fresh run. image.resumeState is non-null only +// WHILE a run is streaming. ``` - The lightweight snapshot is cached in the browser under `generation:` as a run streams, and read back on mount. +- `localStoragePersistence()` (and the session / IndexedDB factories) take **no** + type argument here — a bare call defaults to the generation snapshot shape. - Hydration is automatic on mount and validated (`parseGenerationResumeSnapshot`); an explicit `initialResumeSnapshot` seed skips it. - The `generation:` key segment means a chat and a generation client can share an id and an adapter without colliding. +- `result` needs byte storage to come back. A client snapshot never holds the + bytes, so on its own a reload restores `status` and `error` while `result` + stays `null` (see byte storage below). ### Mode B — server-driven (`persistence: true`) @@ -150,14 +165,14 @@ const image = useGenerateImage({ connection: fetchServerSentEvents('/api/generate/image'), persistence: true, }) -// After a reload: image.resumeSnapshot is the last generation for `threadId`, -// fetched from the server — nothing was cached in the browser. +// After a reload: image.status / image.result / image.error are the last +// generation for `threadId`, fetched from the server — nothing was cached. ``` - Nothing is cached client-side. On mount the client hydrates the **last generation** for its `threadId` from the server via the connection's `hydrateGeneration` handler (the SSE/HTTP adapters issue a `GET` with - `?threadId=` to the same endpoint URL) and repaints that snapshot. + `?threadId=` to the same endpoint URL) and repaints it into the normal fields. - The server `GET` returns `reconstructGeneration(persistence, request)` from `@tanstack/ai-persistence` — it resolves the job by `?jobId=` (preferred) or the latest job linked to `?threadId=`, and needs `stores.jobs`. Pair it with @@ -166,12 +181,33 @@ const image = useGenerateImage({ - Best for multi-device / compliance (no generation metadata in browser storage), exactly like chat Mode B. +### Restoring media: byte storage + `artifactUrl` + +`result` comes back with its media only when the **server** persists the bytes +(`stores.artifacts` + `stores.blobs`) AND `withGenerationPersistence` is given an +`artifactUrl` mapper: + +```ts +withGenerationPersistence(persistence, { + artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, +}) +``` + +`artifactUrl` stamps a durable app-origin URL onto each persisted ref and +rewrites the live result's media to it, so live and restored results match. The +durable refs travel on `result.artifacts`; on restore the hook rebuilds `result` +from them, so `result.images[i].url` (or a video's `result.url`) serves from your +own origin. Final refs live on `result.artifacts`; in-flight ones on +`resumeState.pendingArtifacts`. Without byte storage, a reload restores `status` +/ `error` and `result` stays `null`. + Common to both modes: - `stop()` marks the record no longer resumable; `reset()` deletes it (Mode A) or clears the in-memory snapshot (Mode B). -- Nothing auto-runs from a hydrated snapshot — `generate(...)` is always - explicit. +- Nothing auto-runs from a hydrated record — `generate(...)` is always explicit. +- Use `status` / `result` for a finished run; use `resumeState` to tell that a + run was still generating when the page closed. ## Common mistakes diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index 78481c3da..0de7843cd 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -594,7 +594,14 @@ export async function POST(req: Request) { prompt, threadId, // optional link recorded on the job + artifacts stream: true, - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { + // Stamp a durable app-origin serve URL (the GET route below) onto + // each persisted artifact ref, and rewrite the live result's media to + // it. Both live and restored results then render from your origin. + artifactUrl: (ref) => `/api/artifacts?id=${ref.artifactId}`, + }), + ], }), ) } @@ -616,8 +623,13 @@ On the client, the generation hooks mirror chat's two persistence modes: `persistence: ` (client-driven, caches a lightweight snapshot under `generation:`) or `persistence: true` + a stable `threadId` (server-driven — hydrates the last generation for the thread on mount via the connection's -`hydrateGeneration` handler, backed by a `reconstructGeneration` GET route). -Neither mode ever stores the media bytes on the client. +`hydrateGeneration` handler, backed by a `reconstructGeneration` GET route). The +hooks are transparent (like `useChat`): a reload repaints `status` / `result` / +`error`, not a separate `resumeSnapshot`. Neither mode stores media bytes on the +client — but because the `artifactUrl` above stamps a durable URL onto each ref +(carried on `result.artifacts`), the restored `result` rebuilds its media from +those refs and serves from your own origin. Without byte storage, a reload +restores `status` / `error` and `result` stays `null`. - Building the R2/D1-backed byte stores for a Cloudflare Worker: **ai-persistence/build-cloudflare-artifact-store**. @@ -631,23 +643,26 @@ Neither mode ever stores the media bytes on the client. All generation hooks return the same shape: -| Property | Type | Description | -| ---------------- | --------------------------------------- | ------------------------------------------------ | -| `generate` | `(input) => Promise` | Trigger generation | -| `result` | `T \| null` | Result (optionally transformed via `onResult`) | -| `isLoading` | `boolean` | Whether generation is in progress | -| `error` | `Error \| undefined` | Current error | -| `status` | `GenerationClientState` | `'idle' \| 'generating' \| 'success' \| 'error'` | -| `stop` | `() => void` | Abort current generation | -| `reset` | `() => void` | Clear state (and any persisted snapshot) | -| `resumeSnapshot` | `GenerationResumeSnapshot \| undefined` | Last run's lightweight record (see below) | -| `resumeState` | `GenerationResumeState \| null` | Identity of the run WHILE it streams; null after | - -Hooks also accept `persistence` (a browser storage adapter such as -`localStoragePersistence()`) plus a stable `id`: the client then writes the -lightweight resume snapshot under `generation:` as the run streams and -hydrates it back on mount, so `resumeSnapshot` survives a reload (metadata -only — never media bytes). See `ai-core/client-persistence` for details. +| Property | Type | Description | +| ------------- | ------------------------------- | ------------------------------------------------ | +| `generate` | `(input) => Promise` | Trigger generation | +| `result` | `T \| null` | Result (optionally transformed via `onResult`) | +| `isLoading` | `boolean` | Whether generation is in progress | +| `error` | `Error \| undefined` | Current error | +| `status` | `GenerationClientState` | `'idle' \| 'generating' \| 'success' \| 'error'` | +| `stop` | `() => void` | Abort current generation | +| `reset` | `() => void` | Clear state (and any persisted snapshot) | +| `resumeState` | `GenerationResumeState \| null` | Identity of the run WHILE it streams; null after | + +The hook is **transparent**, mirroring `useChat`: there is no `resumeSnapshot`, +`pendingArtifacts`, or `resultArtifacts` field. Hooks also accept `persistence` +(a browser storage adapter such as `localStoragePersistence()` — a bare call, no +type argument) plus a stable `id`: the client then writes a lightweight snapshot +under `generation:` as the run streams and hydrates it back on mount, +repainting the **normal** `status` / `result` / `error` fields so the last run +survives a reload (metadata only — never media bytes; `result`'s media returns +only with server byte storage + `artifactUrl`). See `ai-core/client-persistence` +for details. Provide either `connection` (streaming SSE transport) or `fetcher` (direct async call / server function returning `Response`). Use `onResult` diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index cdc73d067..82d17f2c4 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -2157,7 +2157,16 @@ export interface PersistedArtifactRef { mimeType: string size: number createdAt: string + /** The provider's original media URL that was fetched (may be expiring). */ externalUrl?: string + /** + * Durable app-origin URL that serves this artifact's persisted bytes (your + * `GET` route around `retrieveArtifact` / `retrieveBlob`). Stamped by + * `withGenerationPersistence`'s `artifactUrl` option, so clients render and + * restore durable media from your own origin rather than the provider's + * expiring link. + */ + url?: string source: { activity: PersistedArtifactActivity path: string diff --git a/testing/e2e/src/routes/api.generation-persistence-server.ts b/testing/e2e/src/routes/api.generation-persistence-server.ts index 11bf28347..857d93f2d 100644 --- a/testing/e2e/src/routes/api.generation-persistence-server.ts +++ b/testing/e2e/src/routes/api.generation-persistence-server.ts @@ -5,34 +5,38 @@ import type { StreamChunk } from '@tanstack/ai' /** * Provider-free harness route for the SERVER-DRIVEN generation-persistence * story (`persistence: true` + `threadId`). It is the server-authoritative - * counterpart to `api.generation-persistence.ts` (the client-driven - * `localStoragePersistence` variant). + * counterpart to `api.generation-persistence.ts` (the client-driven variant). * * The client keeps NO local store; on mount it probes the GET below with a - * `?threadId=` query and repaints from the returned `reconstructGeneration`- - * shaped JSON (`{ resumeSnapshot, activeRun }`). To make the round-trip real, - * POST records the finished job in a module-level in-memory map keyed by - * `threadId`, and GET reads it back — so a full `page.reload()` (empty client - * storage) still restores the last run's status + result metadata FROM THE - * SERVER, exactly the path `reconstructGeneration` serves in production. + * `?threadId=` query and restores transparently from the returned + * `reconstructGeneration`-shaped JSON (`{ resumeSnapshot, activeRun }`) into the + * normal `result` / `status` fields. To make the round-trip real, POST records + * the finished job in a module-level in-memory map keyed by `threadId`, and GET + * reads it back — so a full `page.reload()` (empty client storage) still + * restores the last run's status + a `result` whose image comes from the durable + * artifact URL, exactly the path `reconstructGeneration` serves in production. * * We hand-build the JSON here rather than pull `@tanstack/ai-persistence` into - * the e2e app (it is not a dependency) — mirroring the `server-interrupt` + * the e2e app (it is not a dependency), mirroring the `server-interrupt` * scenario in `api.persistence-durability.ts`. `reconstructGeneration` itself is - * unit-tested in `@tanstack/ai-persistence`; this proves the CLIENT hydrate. + * unit-tested in `@tanstack/ai-persistence`; this proves the CLIENT restore. * * Exempt from the aimock policy: this route streams a fixed AG-UI sequence and * never reaches an LLM provider's HTTP layer, so there is nothing to mock. */ -// 1x1 transparent PNG — small enough to prove media bytes are NOT persisted. +// 1x1 transparent PNG — the live result's inline bytes, never persisted. const TINY_PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' +// Durable app-origin serve URL for the generated image (as +// `withGenerationPersistence`'s `artifactUrl` would stamp it). +const DURABLE_IMAGE_URL = '/durable/generation-server/image-1.png' + // Server-authoritative record of the last completed generation per thread. In // production this is a `GenerationJobStore` row; here a process-lifetime map is // enough for the reload round-trip (the e2e server stays up across reloads). -const completedByThread = new Map() +const completedByThread = new Map>() function stringField(body: unknown, key: string): string | undefined { if (typeof body !== 'object' || body === null || !(key in body)) { @@ -42,6 +46,36 @@ function stringField(body: unknown, key: string): string | undefined { return typeof value === 'string' ? value : undefined } +function imageArtifact(threadId: string, runId: string) { + return { + role: 'output', + artifactId: 'artifact-image-1', + threadId, + runId, + name: 'image-1.png', + mimeType: 'image/png', + size: 68, + createdAt: new Date(0).toISOString(), + url: DURABLE_IMAGE_URL, + source: { + activity: 'image', + path: 'images.0', + provider: 'mock', + model: 'mock-image-model', + mediaType: 'image', + }, + } +} + +/** The metadata + durable artifact ref the server persists (never the bytes). */ +function persistedResult(threadId: string, runId: string) { + return { + id: 'image-1', + model: 'mock-image-model', + artifacts: [imageArtifact(threadId, runId)], + } +} + function imageRun(threadId: string, runId: string): AsyncIterable { return (async function* () { yield { @@ -57,6 +91,7 @@ function imageRun(threadId: string, runId: string): AsyncIterable { id: 'image-1', model: 'mock-image-model', images: [{ b64Json: TINY_PNG_B64 }], + artifacts: [imageArtifact(threadId, runId)], }, threadId, runId, @@ -69,11 +104,8 @@ function imageRun(threadId: string, runId: string): AsyncIterable { timestamp: Date.now(), } as StreamChunk // The run finished: record the job the way `withGenerationPersistence` - // would, so the GET hydrate below can restore it after a reload. - completedByThread.set(threadId, { - id: 'image-1', - model: 'mock-image-model', - }) + // would, so the GET restore below can rebuild it after a reload. + completedByThread.set(threadId, persistedResult(threadId, runId)) })() } @@ -82,7 +114,7 @@ export const Route = createFileRoute('/api/generation-persistence-server')({ handlers: { POST: async ({ request }) => { // The client sends an AG-UI RunAgentInput body carrying the hook's - // stable `threadId` (via runContext) — the same id the GET hydrate + // stable `threadId` (via runContext) — the same id the GET restore // probe queries — plus the run id in the X-Run-Id header. const body: unknown = await request.json() const threadId = stringField(body, 'threadId') ?? 'generation-server' @@ -93,21 +125,22 @@ export const Route = createFileRoute('/api/generation-persistence-server')({ return toServerSentEventsResponse(imageRun(threadId, runId)) }, - // Server-authoritative hydration: the `persistence: true` client's mount + // Server-authoritative restore: the `persistence: true` client's mount // probe (`?threadId=`). Returns the same `{ resumeSnapshot, activeRun }` - // shape `reconstructGeneration` produces — a `complete` snapshot once the - // thread has a recorded job, else the empty first-load answer. + // shape `reconstructGeneration` produces — a `complete` snapshot whose + // result carries the durable artifact ref once the thread has a recorded + // job, else the empty first-load answer. GET: ({ request }) => { const threadId = new URL(request.url).searchParams.get('threadId') ?? '' - const job = threadId ? completedByThread.get(threadId) : undefined - const body = job + const result = threadId ? completedByThread.get(threadId) : undefined + const body = result ? { resumeSnapshot: { schemaVersion: 1, resumeState: null, status: 'complete', - result: job, activity: 'image', + result, }, activeRun: null, } diff --git a/testing/e2e/src/routes/api.generation-persistence.ts b/testing/e2e/src/routes/api.generation-persistence.ts index 89fb133c3..a1accaf07 100644 --- a/testing/e2e/src/routes/api.generation-persistence.ts +++ b/testing/e2e/src/routes/api.generation-persistence.ts @@ -12,10 +12,38 @@ import type { StreamChunk } from '@tanstack/ai' * never reaches an LLM provider's HTTP layer, so there is nothing to mock. */ -// 1x1 transparent PNG — small enough to prove media bytes are NOT persisted. +// 1x1 transparent PNG — the live result's inline bytes. Never persisted; the +// snapshot keeps only metadata + the durable artifact ref below. const TINY_PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' +// Durable app-origin serve URL for the generated image, the way +// `withGenerationPersistence`'s `artifactUrl` would stamp it. The reload path +// rebuilds `result.images` from this, so the restored image renders from our +// own origin instead of the (never-persisted) inline bytes. +const DURABLE_IMAGE_URL = '/durable/generation-persistence/image-1.png' + +function imageArtifact(threadId: string, runId: string) { + return { + role: 'output', + artifactId: 'artifact-image-1', + threadId, + runId, + name: 'image-1.png', + mimeType: 'image/png', + size: 68, + createdAt: new Date(0).toISOString(), + url: DURABLE_IMAGE_URL, + source: { + activity: 'image', + path: 'images.0', + provider: 'mock', + model: 'mock-image-model', + mediaType: 'image', + }, + } +} + function imageRun(threadId: string, runId: string): AsyncIterable { return (async function* () { yield { @@ -39,6 +67,7 @@ function imageRun(threadId: string, runId: string): AsyncIterable { id: 'image-1', model: 'mock-image-model', images: [{ b64Json: TINY_PNG_B64 }], + artifacts: [imageArtifact(threadId, runId)], }, threadId, runId, diff --git a/testing/e2e/src/routes/generation-persistence-server.tsx b/testing/e2e/src/routes/generation-persistence-server.tsx index ae0720341..48bb96789 100644 --- a/testing/e2e/src/routes/generation-persistence-server.tsx +++ b/testing/e2e/src/routes/generation-persistence-server.tsx @@ -7,9 +7,10 @@ import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' * * `persistence: true` + a stable `threadId` — the hook keeps NO local store. * On mount it hydrates from the endpoint's GET (a `?threadId=` probe answered by - * `/api/generation-persistence-server`), so a full `page.reload()` restores the - * last run's status + result metadata FROM THE SERVER, with `localStorage` - * empty. The generated bytes are never persisted anywhere. + * `/api/generation-persistence-server`) and restores transparently into the + * normal fields, so a full `page.reload()` brings back `status: 'success'` and a + * `result` whose image renders from the durable serve URL — FROM THE SERVER, + * with `localStorage` empty. There is no `resumeSnapshot` field. */ const THREAD_ID = 'generation-server-thread' @@ -44,13 +45,9 @@ function GenerationPersistenceServerPage() { Generate -
{image.status}
-
- {image.resumeSnapshot?.status ?? 'none'} -
-
- {image.resumeSnapshot?.result?.id ?? 'none'} -
+
{image.status}
+
{image.result?.id ?? 'none'}
+
{image.error?.message ?? 'none'}
{image.result?.images.map((img, i) => ( `. A full `page.reload()` hydrates the - * snapshot back into the hook — run status, error, and result metadata survive, - * while the generated image bytes do not (they are never written). The - * provider-free endpoint is `/api/generation-persistence`. + * A bare `localStoragePersistence()` adapter stores the lightweight resume + * snapshot under `tanstack-ai:generation:`. A full `page.reload()` restores + * it transparently into the NORMAL hook fields — `status` becomes `success`, + * `error` returns, and `result` is rebuilt from the durable artifact URL (the + * generated bytes themselves are never written). There is no `resumeSnapshot` + * field; persistence is invisible, exactly like `useChat` restoring `messages`. + * The provider-free endpoint is `/api/generation-persistence`. */ -const snapshots = localStoragePersistence() +const snapshots = localStoragePersistence() const connection = fetchServerSentEvents('/api/generation-persistence') export const Route = createFileRoute('/generation-persistence')({ @@ -51,15 +52,11 @@ function GenerationPersistencePage() { Reset -
{image.status}
-
- {image.resumeSnapshot?.status ?? 'none'} -
-
- {image.resumeSnapshot?.result?.id ?? 'none'} -
-
- {image.resumeSnapshot?.error?.message ?? 'none'} +
{image.status}
+
{image.result?.id ?? 'none'}
+
{image.error?.message ?? 'none'}
+
+ {image.resumeState ? image.resumeState.runId : 'none'}
{image.result?.images.map((img, i) => ( diff --git a/testing/e2e/tests/generation-persistence-server.spec.ts b/testing/e2e/tests/generation-persistence-server.spec.ts index 0fb33c281..3858dfbed 100644 --- a/testing/e2e/tests/generation-persistence-server.spec.ts +++ b/testing/e2e/tests/generation-persistence-server.spec.ts @@ -1,49 +1,50 @@ import { expect, test } from '@playwright/test' /** - * Server-driven generation resume-snapshot persistence (`persistence: true`). + * Server-driven generation persistence (`persistence: true`). * - * The counterpart to `generation-persistence.spec.ts` (client-driven - * `localStoragePersistence`). Here the hook keeps NO local store: as a run - * streams the server records the job, and on mount / after a full - * `page.reload()` the client hydrates the last run FROM THE SERVER via a - * `?threadId=` GET (the `reconstructGeneration` shape). Run status + result - * metadata survive the reload with `localStorage` empty; generated bytes are - * never persisted, and no run is auto-started. + * The counterpart to `generation-persistence.spec.ts` (client-driven). Here the + * hook keeps NO local store: as a run streams the server records the job, and on + * mount / after a full `page.reload()` the client restores the last run FROM THE + * SERVER via a `?threadId=` GET (the `reconstructGeneration` shape) straight into + * the normal `result` / `status` fields. The restored image renders from the + * durable serve URL, `localStorage` stays empty, and no run is auto-started. * * Provider-free: `/api/generation-persistence-server` streams a fixed AG-UI - * sequence and answers the hydrate GET from an in-memory job record, so there - * is no LLM in the loop and nothing to mock (exempt from the aimock policy). + * sequence and answers the restore GET from an in-memory job record (exempt from + * the aimock policy). */ test.describe('generation persistence (server-driven)', () => { - test('records the run server-side and rehydrates it after reload with no local store', async ({ + test('records the run server-side and restores it after reload with no local store', async ({ page, }) => { await page.goto('/generation-persistence-server') await expect(page.getByTestId('hydration-marker')).toBeAttached() // Empty first load: the server has no job for this thread yet. - await expect(page.getByTestId('snapshot-status')).toHaveText('none') + await expect(page.getByTestId('status')).toHaveText('idle') + await expect(page.getByTestId('result-id')).toHaveText('none') await page.getByTestId('generate-button').click() - await expect(page.getByTestId('snapshot-status')).toHaveText('complete') - await expect(page.getByTestId('snapshot-result-id')).toHaveText('image-1') + await expect(page.getByTestId('status')).toHaveText('success') + await expect(page.getByTestId('result-id')).toHaveText('image-1') await expect(page.getByTestId('generated-image')).toHaveCount(1) // Server-driven mode writes NOTHING to browser storage — the record lives // on the server. No localStorage key mentions the generation id. - const localKeys = await page.evaluate(() => - Object.keys(window.localStorage), - ) + const localKeys = await page.evaluate(() => Object.keys(window.localStorage)) expect(localKeys.some((k) => k.includes('generation-server'))).toBe(false) - // Reload with empty client storage: the snapshot is restored purely from - // the server hydrate. Nothing auto-runs; the image (never persisted) is gone. + // Reload with empty client storage: the run is restored purely from the + // server, into the normal fields, and the image comes from the durable URL. await page.reload() await expect(page.getByTestId('hydration-marker')).toBeAttached() - await expect(page.getByTestId('snapshot-status')).toHaveText('complete') - await expect(page.getByTestId('snapshot-result-id')).toHaveText('image-1') - await expect(page.getByTestId('client-status')).toHaveText('idle') - await expect(page.getByTestId('generated-image')).toHaveCount(0) + await expect(page.getByTestId('status')).toHaveText('success') + await expect(page.getByTestId('result-id')).toHaveText('image-1') + await expect(page.getByTestId('generated-image')).toHaveCount(1) + await expect(page.getByTestId('generated-image')).toHaveAttribute( + 'src', + '/durable/generation-server/image-1.png', + ) }) }) diff --git a/testing/e2e/tests/generation-persistence.spec.ts b/testing/e2e/tests/generation-persistence.spec.ts index b1a6305d9..ac94224cc 100644 --- a/testing/e2e/tests/generation-persistence.spec.ts +++ b/testing/e2e/tests/generation-persistence.spec.ts @@ -1,56 +1,65 @@ import { expect, test } from '@playwright/test' /** - * Generation resume-snapshot persistence (browser refresh). + * Generation resume persistence (browser refresh, client-driven). * - * Proves the story wired by `localStoragePersistence` + `useGenerateImage({ - * persistence, id })`: as a run streams, the client writes a lightweight - * snapshot under `tanstack-ai:generation:`, and a full `page.reload()` - * hydrates it back — run status and result metadata survive, generated bytes - * do not, and no run is auto-started. + * Proves the transparent restore wired by `localStoragePersistence` + + * `useGenerateImage({ persistence, id })`: as a run streams, the client writes a + * lightweight snapshot under `tanstack-ai:generation:` (metadata + the + * durable artifact URL, never the image bytes). A full `page.reload()` restores + * it straight into the NORMAL hook fields — `status` is `success`, and `result` + * is rebuilt so the image renders from the durable serve URL. There is no + * `resumeSnapshot` field; `reset()` clears everything. * - * Provider-free: `/api/generation-persistence` streams a fixed AG-UI sequence, - * so there is no LLM in the loop and nothing to mock (exempt from the aimock - * policy). + * Provider-free: `/api/generation-persistence` streams a fixed AG-UI sequence + * (exempt from the aimock policy). */ const STORAGE_KEY = 'tanstack-ai:generation:generation-persistence' test.describe('generation persistence (browser refresh)', () => { - test('persists the snapshot, hydrates it after reload, and clears it on reset', async ({ + test('restores status + result into the normal fields after reload, clears on reset', async ({ page, }) => { await page.goto('/generation-persistence') await expect(page.getByTestId('hydration-marker')).toBeAttached() - await expect(page.getByTestId('snapshot-status')).toHaveText('none') + await expect(page.getByTestId('status')).toHaveText('idle') + await expect(page.getByTestId('result-id')).toHaveText('none') await page.getByTestId('generate-button').click() - await expect(page.getByTestId('snapshot-status')).toHaveText('complete') - await expect(page.getByTestId('snapshot-result-id')).toHaveText('image-1') + await expect(page.getByTestId('status')).toHaveText('success') + await expect(page.getByTestId('result-id')).toHaveText('image-1') await expect(page.getByTestId('generated-image')).toHaveCount(1) - // The persisted record holds metadata only — never the image bytes. + // The persisted record holds metadata + the durable artifact URL, never the + // inline image bytes. const stored = await page.evaluate( (key) => window.localStorage.getItem(key), STORAGE_KEY, ) expect(stored).not.toBeNull() expect(stored).toContain('"status":"complete"') - expect(stored).not.toContain('b64Json') + expect(stored).toContain('/durable/generation-persistence/image-1.png') expect(stored).not.toContain('iVBOR') - // Reload: the snapshot hydrates from storage; nothing auto-runs and the - // image itself (never persisted) is gone. + // Reload: the run restores transparently into status + result; nothing + // auto-runs (resumeState stays null) and the image renders from the durable + // serve URL, not the inline bytes. await page.reload() await expect(page.getByTestId('hydration-marker')).toBeAttached() - await expect(page.getByTestId('snapshot-status')).toHaveText('complete') - await expect(page.getByTestId('snapshot-result-id')).toHaveText('image-1') - await expect(page.getByTestId('client-status')).toHaveText('idle') - await expect(page.getByTestId('generated-image')).toHaveCount(0) + await expect(page.getByTestId('status')).toHaveText('success') + await expect(page.getByTestId('result-id')).toHaveText('image-1') + await expect(page.getByTestId('resume-state')).toHaveText('none') + await expect(page.getByTestId('generated-image')).toHaveCount(1) + await expect(page.getByTestId('generated-image')).toHaveAttribute( + 'src', + '/durable/generation-persistence/image-1.png', + ) - // Reset clears the in-memory snapshot and deletes the persisted record. + // Reset clears the in-memory state and deletes the persisted record. await page.getByTestId('reset-button').click() - await expect(page.getByTestId('snapshot-status')).toHaveText('none') + await expect(page.getByTestId('status')).toHaveText('idle') + await expect(page.getByTestId('result-id')).toHaveText('none') await expect .poll(() => page.evaluate((key) => window.localStorage.getItem(key), STORAGE_KEY), @@ -59,6 +68,6 @@ test.describe('generation persistence (browser refresh)', () => { await page.reload() await expect(page.getByTestId('hydration-marker')).toBeAttached() - await expect(page.getByTestId('snapshot-status')).toHaveText('none') + await expect(page.getByTestId('status')).toHaveText('idle') }) }) From 11db2e0cf6bd7777159521c0666414bb2a3d1194 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:25:47 +0000 Subject: [PATCH 23/66] ci: apply automated fixes --- packages/ai-client/src/generation-client.ts | 8 ++++++-- packages/ai-client/src/generation-reconstruct.ts | 4 +++- packages/ai-solid/src/use-generation.ts | 7 ++++--- packages/ai-solid/tests/use-generation.test.ts | 4 +--- testing/e2e/tests/generation-persistence-server.spec.ts | 4 +++- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index e09b82925..740b3f426 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -670,10 +670,14 @@ export class GenerationClient< ...(result?.model !== undefined ? { model: result.model } : {}), ...(result?.status !== undefined ? { status: result.status } : {}), ...(result?.jobId !== undefined ? { jobId: result.jobId } : {}), - ...(result?.expiresAt !== undefined ? { expiresAt: result.expiresAt } : {}), + ...(result?.expiresAt !== undefined + ? { expiresAt: result.expiresAt } + : {}), ...(result?.text !== undefined ? { text: result.text } : {}), ...(result?.usage !== undefined ? { usage: result.usage } : {}), - ...(snapshot.activity !== undefined ? { activity: snapshot.activity } : {}), + ...(snapshot.activity !== undefined + ? { activity: snapshot.activity } + : {}), artifacts: result?.artifacts ?? [], } return build(restored) diff --git a/packages/ai-client/src/generation-reconstruct.ts b/packages/ai-client/src/generation-reconstruct.ts index 650204e06..dcac9ab9e 100644 --- a/packages/ai-client/src/generation-reconstruct.ts +++ b/packages/ai-client/src/generation-reconstruct.ts @@ -27,7 +27,9 @@ function mediaUrls( return restored.artifacts .filter( (a) => - a.role === 'output' && a.source.mediaType === mediaType && a.url != null, + a.role === 'output' && + a.source.mediaType === mediaType && + a.url != null, ) .map((a) => a.url as string) } diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index 470a2947c..1d37e1e94 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -154,9 +154,10 @@ export function useGeneration< const [isLoading, setIsLoading] = createSignal(false) const [error, setError] = createSignal(undefined) const [status, setStatus] = createSignal('idle') - const [resumeState, setResumeState] = createSignal( - options.initialResumeSnapshot?.resumeState ?? null, - ) + const [resumeState, setResumeState] = + createSignal( + options.initialResumeSnapshot?.resumeState ?? null, + ) let disposed = false // Built once. `untrack` keeps the option reads below from subscribing diff --git a/packages/ai-solid/tests/use-generation.test.ts b/packages/ai-solid/tests/use-generation.test.ts index 77cd9896e..7b95ad439 100644 --- a/packages/ai-solid/tests/use-generation.test.ts +++ b/packages/ai-solid/tests/use-generation.test.ts @@ -1362,9 +1362,7 @@ describe('resume snapshot persistence', () => { chunks: createReplayVideoChunks(), }) - const { result } = renderHook(() => - useGeneration({ connection: adapter }), - ) + const { result } = renderHook(() => useGeneration({ connection: adapter })) await result.generate({ prompt: 'replay' }) diff --git a/testing/e2e/tests/generation-persistence-server.spec.ts b/testing/e2e/tests/generation-persistence-server.spec.ts index 3858dfbed..958b40682 100644 --- a/testing/e2e/tests/generation-persistence-server.spec.ts +++ b/testing/e2e/tests/generation-persistence-server.spec.ts @@ -32,7 +32,9 @@ test.describe('generation persistence (server-driven)', () => { // Server-driven mode writes NOTHING to browser storage — the record lives // on the server. No localStorage key mentions the generation id. - const localKeys = await page.evaluate(() => Object.keys(window.localStorage)) + const localKeys = await page.evaluate(() => + Object.keys(window.localStorage), + ) expect(localKeys.some((k) => k.includes('generation-server'))).toBe(false) // Reload with empty client storage: the run is restored purely from the From 9c7f3dc834065dd955a574b5d5c4494f8910f458 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 28 Jul 2026 17:48:56 +0200 Subject: [PATCH 24/66] 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. --- docs/config.json | 5 + .../generation-persistence-advanced.md | 118 ++++++++ docs/persistence/generation-persistence.md | 286 +++++------------- 3 files changed, 199 insertions(+), 210 deletions(-) create mode 100644 docs/persistence/generation-persistence-advanced.md diff --git a/docs/config.json b/docs/config.json index 516e70215..fd1d55d3b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -262,6 +262,11 @@ "addedAt": "2026-07-28", "updatedAt": "2026-07-28" }, + { + "label": "Generation Persistence: Advanced", + "to": "persistence/generation-persistence-advanced", + "addedAt": "2026-07-28" + }, { "label": "Keep Generated Files", "to": "persistence/keep-generated-files", diff --git a/docs/persistence/generation-persistence-advanced.md b/docs/persistence/generation-persistence-advanced.md new file mode 100644 index 000000000..7dc79dd01 --- /dev/null +++ b/docs/persistence/generation-persistence-advanced.md @@ -0,0 +1,118 @@ +--- +title: 'Generation Persistence: Advanced' +id: generation-persistence-advanced +--- + +# Generation Persistence: Advanced + +The deeper parts of [Generation persistence](./generation-persistence): +reconnecting a stream that is still running, the in-flight `resumeState`, seeding +state yourself, securing the hydration endpoint, and exactly what the record +holds. Start with the main guide; come here when you need one of these. + +## Reconnect to a run that is still streaming + +The server-driven endpoint in the main guide already wires this: a `durability` +adapter on `toServerSentEventsResponse`, plus a `GET` that replays the run from +the log when the request carries a resume cursor. On the client there is nothing +to add. A connection dropped mid-generation re-attaches on its own through +`fetchServerSentEvents` or `fetchHttpStream`, the same adapters `useChat` uses. +In production, swap `memoryStream` for `durableStream` from +`@tanstack/ai-durable-stream`, where requests span processes. + +A full page reload is different: the hooks never start or resume a run on mount, +and the record alone cannot re-attach to the stream. It records that a run was +in flight, not a stream position. After a reload, a non-null `resumeState` is +informational: "a run was still going when the page closed." See +[Resumable Streams](../resumable-streams/overview) for the durability contract, +production adapters, and the one-time-side-effects note. + +## The in-flight `resumeState` + +`resumeState` is non-null only while a run is streaming. It is the identity of +the live run (`threadId` / `runId`, plus `pendingArtifacts` while media streams), +and it clears when the run ends. Use `status` / `result` to display a finished +run; use `resumeState` to tell that a run was still going. + +For a client-driven store: + +- **The `id` is the storage key.** The snapshot is written under + `generation:` (plus the adapter's `keyPrefix`), so a stable `id` is what + makes the record findable after a reload. Omitting `id` generates a fresh key + per mount. The `generation:` segment also keeps a chat client with the same id + from colliding. +- `stop()` marks the record no longer resumable; `reset()` deletes it. +- The snapshot never triggers work. A generation starts only on `generate(...)`. + +## Seed the state yourself + +If your app manages storage itself (custom backend, SSR-provided state), read +the value and pass it as `initialResumeSnapshot`, which skips the automatic read. +Validate untrusted values with `parseGenerationResumeSnapshot`: + +```tsx +import { parseGenerationResumeSnapshot } from '@tanstack/ai-client' +import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' + +function HeroImageGenerator({ stored }: { stored: unknown }) { + const seed = parseGenerationResumeSnapshot(stored) + const image = useGenerateImage({ + id: 'hero-image', + connection: fetchServerSentEvents('/api/generate/image'), + ...(seed ? { initialResumeSnapshot: seed } : {}), + }) + return

{image.status}

+} +``` + +## Secure the hydration endpoint + +`reconstructGeneration(persistence, request)` reads a `?jobId` (preferred) or +`?threadId` from the query and returns the last job's record plus a cursor to any +still-running run. It resolves the latest job for a thread through the store's +`findLatestForThread`. It does **not** enforce tenancy on its own, so on a public +route pass its `authorize` option (session to owned thread/job) before returning +anything: + +```ts group=generation-authorize +import { + memoryPersistence, + reconstructGeneration, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() + +// Replace these with your real session + ownership checks. +async function getSessionUserId(_request: Request): Promise { + return null +} +async function userOwnsThread( + _userId: string, + _threadId: string, +): Promise { + return true +} + +export function GET(request: Request): Response | Promise { + return reconstructGeneration(persistence, request, { + authorize: async (id, req) => { + const userId = await getSessionUserId(req) + return userId !== null && (await userOwnsThread(userId, id)) + }, + }) +} +``` + +## What the record holds + +The record, hydrated from the server or read from the browser snapshot, holds +run identity and result metadata (ids, model, status, a video `jobId`, an expiry +timestamp), never the generated bytes. On its own it does not carry a usable +media URL, so a reload restores `status` and `error` while `result` stays `null`. + +To bring the media back too, persist the bytes on the server with an `artifacts` ++ `blobs` backend and stamp a durable serve URL onto each ref with +`withGenerationPersistence`'s `artifactUrl` (see +[Keep generated files](./keep-generated-files)). Those durable refs travel on the +record, so the restored `result` is rebuilt with its media resolved to your own +origin and its refs on `result.artifacts`. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index e83fbb342..aa23a7fac 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -6,72 +6,42 @@ id: generation-persistence # Generation Persistence Media generation takes time, and video can take minutes. If the user reloads the -page or their connection drops mid-run, that run is easy to lose track of. -Generation persistence keeps a small record of each run so your app can pick -things back up. - -It helps with three things: - -- **After a reload**, show what the last run was: whether it finished, what - failed, and metadata like the result id or video job id. Depending on the mode - you choose, that record is either hydrated from the server or kept as a small - snapshot in browser storage — either way it is read back automatically on - mount and repainted into the hook's normal `status` / `result` / `error` - fields, so a restored run reads exactly like a fresh one. -- **Keep the generated files.** On the server, save the generated bytes to your - own storage so they outlive the provider's expiring URLs — see - [Keep generated files](./keep-generated-files). -- **While a run is still streaming**, let a dropped connection re-attach to it - instead of starting over. This reuses the same resumable streams the chat - client uses. - -## When to use it - -Use it when a run is long enough that a reload or a dropped connection actually -matters, or when you need to keep the output: video, batch images, long audio, -transcription of a big file. For a quick one-shot image you show and forget, you -can skip it. - -The record never holds the generated bytes, only run identity, status, errors, -and result metadata (like the result id, model, or video job id). Storing the -bytes themselves is a server-side opt-in, shown in -[Keep generated files](./keep-generated-files). +page or their connection drops mid-run, that run is easy to lose. Generation +persistence keeps a small record of each run and restores it into the hook's +normal `status` / `result` / `error` fields, so a reload reads exactly like a +fresh run. + +Reach for it when a run is long enough that a reload matters: video, batch +images, long audio, a big transcription. For a quick one-shot image you show and +forget, skip it. ## Choose a mode -Generation persistence mirrors chat's `persistence` option: it takes a boolean -or a storage adapter, and each side of that choice decides who owns the record. - -- **`true`** is server-driven. The client caches nothing; on mount it hydrates - the last generation for its `threadId` from the server and repaints it. The - server owns the job record. -- **a storage adapter** (`persistence: localStoragePersistence()`) is - client-driven. The client writes a small snapshot to browser storage as a run - streams and reads it back on the next mount. -- **`false`** (or omitted) is off: the run lives in memory only and a reload - starts empty. - -Both modes rely on the server keeping a **generation job** record. -`withGenerationPersistence(persistence)` requires a dedicated `jobs` store — a -`GenerationJobStore` keyed by `jobId`, the request id the run mints. A -generation has no conversation of its own, so `threadId` is only an optional -*link* on the job record; the server-driven client uses that link to find the -last generation for a thread. `memoryPersistence()` ships a `jobs` store (plus -`artifacts` + `blobs`), so it works out of the box; any backend that implements -`GenerationJobStore` works the same way (see -[Build your own adapter](./build-your-own-adapter#generation--media-stores)). - -### Server-driven: `persistence: true` - -Pass `persistence: true` and give the hook a stable `threadId`. The client -stores nothing. On mount it hydrates the last generation for that thread from -the server through the connection's read-only JSON GET (`?threadId`), the same -`fetchServerSentEvents` / `fetchHttpStream` adapters `useChat` uses. It never -auto-starts a run — a generation begins only when you call `generate(...)`. - -**Server** — a `POST` that runs the generation with -`withGenerationPersistence`, and a `GET` on the same endpoint that answers the -hydration request with `reconstructGeneration`: +Generation persistence mirrors chat's `persistence` option: + +- **`persistence: true`** — server-driven. The client caches nothing; on mount + it hydrates the last run for its `threadId` from the server. +- **`persistence: `** (e.g. `localStoragePersistence()`) — client-driven. + The client keeps a small snapshot in browser storage. +- **omitted / `false`** — off. The run lives in memory only; a reload starts + empty. + +Either way the server keeps a **generation job** record: +`withGenerationPersistence(persistence)` needs a `jobs` store (a +`GenerationJobStore` keyed by the run's `jobId`; a generation has no conversation +of its own, so `threadId` is only an optional link). `memoryPersistence()` ships +one out of the box; see +[Build your own adapter](./build-your-own-adapter#generation--media-stores) for +your own backend. + +The record never holds the generated bytes, so on its own a reload restores +`status` and `error` while `result` stays `null`. To bring the media back too, +add server byte storage: see [Keep generated files](./keep-generated-files). + +## Server-driven: `persistence: true` + +One `POST` that runs the generation, and a `GET` on the same route that answers +the mount-time hydration with `reconstructGeneration`: ```ts group=generation-server-driven import { @@ -88,16 +58,11 @@ import { withGenerationPersistence, } from '@tanstack/ai-persistence' -// memoryPersistence() ships the `jobs` store generation persistence requires -// (plus `artifacts` + `blobs`). Point it at a durable backend for production. const persistence = memoryPersistence() export async function POST(request: Request) { const durability = memoryStream(request) - const { input, threadId } = await generationParamsFromRequest( - 'image', - request, - ) + const { input, threadId } = await generationParamsFromRequest('image', request) if (typeof input.prompt !== 'string') { throw new Error('This endpoint accepts text image prompts only.') @@ -106,29 +71,18 @@ export async function POST(request: Request) { const stream = generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt, - // Link the job to the thread so the GET below can hydrate the last - // generation for it by thread id. Optional — omit it and the job is - // findable only by its own jobId. + // Link the job to the thread so the GET can find the last run for it. ...(threadId !== undefined ? { threadId } : {}), stream: true, + // `artifactUrl` makes the restored media render from your own origin. It is + // optional — see Keep generated files for the serve route it points at. middleware: [ withGenerationPersistence(persistence, { - // memoryPersistence() ships the `artifacts` + `blobs` byte stores, so - // each generated file's bytes are saved. `artifactUrl` stamps a - // durable, app-origin serve URL onto every persisted artifact ref and - // rewrites the live result's media to it, so the live result already - // points at your own origin — and a reload restores that same media. - // The serve URL is the GET artifact route (retrieveArtifact / - // retrieveBlob) shown in Keep generated files. - artifactUrl: (ref) => - `/api/generate/image/artifact?id=${ref.artifactId}`, + artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, }), ], }) - // withGenerationPersistence records the job's status, result metadata, and - // durable artifact refs in the jobs store; durability records the stream so a - // reload can re-attach to it. return toServerSentEventsResponse(stream, { durability: { adapter: durability }, }) @@ -136,31 +90,19 @@ export async function POST(request: Request) { export function GET(request: Request): Response | Promise { const durability = memoryStream(request) - // A reconnecting client carries a resume cursor (Last-Event-ID / ?offset and - // X-Run-Id / ?runId). Replay the durability log so a run still streaming - // finishes in place. No provider call here. + // A reconnecting client carries a resume cursor; replay the live stream. if (durability.resumeFrom() !== null) { return resumeServerSentEventsResponse({ adapter: durability }) } - // Otherwise this is the mount-time hydration GET (?threadId): return the last - // generation job for the thread. Guard access in multi-user apps with the - // `authorize` option (session → owned thread/job). + // Otherwise this is the mount-time hydration (?threadId). Guard it with the + // `authorize` option in multi-user apps (see the advanced guide). return reconstructGeneration(persistence, request) } ``` -`reconstructGeneration(persistence, request)` reads a `?jobId` (preferred) or -`?threadId` from the query and returns the last job's persisted record plus a -cursor to any still-running run. The client consumes that on mount and repaints -it into the hook's **normal** fields — you never touch the wire shape. It -requires a `jobs` store and resolves the latest job for a thread through the -store's `findLatestForThread`. It does **not** enforce tenancy on its own — pass -its `authorize` option before exposing it on a public route. - -**Client** — a connection, a stable `threadId`, and `persistence: true`. The -hook is transparent: it exposes the same `result` / `status` / `error` fields as -a fresh run, so a reload repaints the last generation as if it had just -finished — exactly the way `useChat` restores into `messages`: +On the client, pass a connection, a stable `threadId`, and `persistence: true`. +The hook is transparent: a reload repaints the last run into the same fields a +fresh run uses, the way `useChat` restores into `messages`: ```tsx import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' @@ -187,9 +129,6 @@ export function HeroImageGenerator({ threadId }: { threadId: string }) { Generate - {image.resumeState ? ( -

A run was still generating when the page last closed…

- ) : null} {image.status === 'success' ? (

Last run finished{image.result?.id ? ` (${image.result.id})` : ''}.

) : null} @@ -202,125 +141,52 @@ export function HeroImageGenerator({ threadId }: { threadId: string }) { } ``` -Because the server example stamps a durable `artifactUrl` onto each ref, the -restored `image.result.images[i].url` points at your own serve route, so the -media renders after a reload just as it did live. Without byte storage the -reload still restores `image.status` and `image.error`, but `image.result` -stays `null` — the metadata survives, the bytes do not. - -You never fetch or seed the snapshot yourself. A reload and the same thread -opened on another device follow the identical path, because the thread id is -the stable key and the server resolves everything from it — no loader, no extra -props. Reach for this mode when you do not want run records in the browser, or -when the same generation must show up on another device. +Because the server stamps a durable `artifactUrl`, the restored +`image.result.images[i].url` serves from your own origin, so the image renders +after a reload just as it did live. You never fetch or seed anything: the thread +id is the stable key, and a reload or the same thread on another device follow +the identical path. -### Client-driven: a storage adapter +## Client-driven: a storage adapter -Pass a storage adapter as `persistence`, and give the hook a stable `id`. The -client writes a snapshot to browser storage as the run streams and reads it back -(validated) when the component mounts. No server `GET` endpoint is needed for -hydration — the record lives entirely in the browser: +Pass a storage adapter instead, and give the hook a stable `id`. The client +writes the snapshot to browser storage as the run streams and reads it back on +mount, with no server `GET` for hydration. Everything else about the hook is the +same as above: ```tsx import { localStoragePersistence } from '@tanstack/ai-client' import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' -// A bare call is all it takes — no type argument. The adapter defaults to the -// generation snapshot shape, exactly as it defaults to the chat record on a -// chat client. +// A bare call is all it takes, no type argument. const snapshots = localStoragePersistence({ keyPrefix: 'my-app:' }) -export function HeroImageGenerator() { +function HeroImageGenerator() { const image = useGenerateImage({ id: 'hero-image', connection: fetchServerSentEvents('/api/generate/image'), persistence: snapshots, }) - - return ( -
- - - {image.resumeState ? ( -

Run {image.resumeState.runId} is streaming…

- ) : null} - {image.status === 'success' ? ( -

Last run finished{image.result?.id ? ` (${image.result.id})` : ''}.

- ) : null} - {image.error ?

Last run failed: {image.error.message}

: null} -
- ) + // The render is identical to the server-driven example above. + return

{image.status}

} ``` -A few things to know: - -- **The hook is transparent.** After a reload it repaints the same fields a - fresh run does — `status` (`'idle'` / `'generating'` / `'success'` / - `'error'`), `error` if the run failed, and `result` for a finished run. You - read a restored generation exactly like a live one, the way `useChat` restores - into `messages`. -- **`result` needs byte storage to come back.** A client-side snapshot never - holds the generated bytes, so on its own a reload restores `status` and - `error` while `result` stays `null`. Pair it with the server-side byte storage - and `artifactUrl` from the server-driven example (or - [Keep generated files](./keep-generated-files)) and the restored `result` is - rebuilt from the durable refs — `result.images[i].url` (or a video's - `result.url`) serves from your own origin, and `result.artifacts` carries the - refs. -- **`resumeState`** is non-null only while a run is in flight — it is the - identity (`threadId` / `runId`, plus `pendingArtifacts` while media streams) of - the streaming run, and it is cleared when the run ends. Use `status` / - `result`, not `resumeState`, to display a finished run; use `resumeState` to - tell that a run was still going. -- **The `id` is the storage key.** The snapshot is written under - `generation:` (plus the adapter's `keyPrefix`), so a stable `id` is what - makes the record findable after a reload. Omitting `id` generates a fresh - key per mount. The `generation:` segment also keeps a chat client with the - same id from colliding with the generation record. -- `stop()` marks the persisted record no longer resumable, and `reset()` - deletes it. -- The snapshot never triggers work. A generation starts only when you call - `generate(...)`. - -If your app manages storage itself (custom backends, SSR-provided state), read -the value yourself and pass it as `initialResumeSnapshot` — that skips the -automatic read. Validate untrusted values with `parseGenerationResumeSnapshot` -from `@tanstack/ai-client`. - -## Reconnect to a run that is still streaming - -The server-driven endpoint above already wires this: a `durability` adapter on -`toServerSentEventsResponse`, plus a `GET` handler that replays the run from the -log when the request carries a resume cursor. On the client there is nothing to -add. A connection dropped mid-generation re-attaches on its own through -`fetchServerSentEvents` or `fetchHttpStream`, the same adapters `useChat` uses. -In production, swap `memoryStream` for `durableStream` from -`@tanstack/ai-durable-stream`, where requests span processes. - -A full page reload is different: the hooks never start or resume a run on -mount, and the record alone cannot re-attach to the stream — it records that a -run was in flight, not a stream position. After a reload, a non-null -`resumeState` is informational ("a run was still going when the page closed"). -See [Resumable Streams](../resumable-streams/overview) for the durability -contract, production adapters, and the one-time-side-effects note. - -## What the record holds - -The generation record — whether hydrated from the server or read from the -browser snapshot — holds run identity and result metadata (ids, model, status, -a video `jobId`, an expiry timestamp), never the generated bytes. On its own it -does not carry a usable media URL, so a reload restores `status` and `error` -while `result` stays `null`. To bring the media back too, persist the bytes on -the server with an `artifacts` + `blobs` backend and stamp a durable serve URL -onto each ref with `withGenerationPersistence`'s `artifactUrl` (see -[Keep generated files](./keep-generated-files)). Those durable refs travel on -the record, so the restored `result` is rebuilt with its media resolved to your -own origin. +Two things to keep in mind: + +- **The hook is transparent.** After a reload it repaints `status` + (`'idle'` / `'generating'` / `'success'` / `'error'`), `error`, and `result` + just like a live run. +- **`result` needs byte storage to come back.** A browser snapshot never holds + the bytes, so on its own a reload restores `status` and `error` while `result` + stays `null`. Add server byte storage and `artifactUrl` + ([Keep generated files](./keep-generated-files)) and the restored `result` is + rebuilt, its media served from your own origin. + +## Going further + +- [Keep generated files](./keep-generated-files): store the generated bytes so + the media itself survives, not just the record. +- [Generation persistence: advanced](./generation-persistence-advanced): + reconnecting a dropped stream, the in-flight `resumeState`, seeding state + yourself, and securing the hydration endpoint. From f976c900cb463fa8c067e8a2446d3493630e4fe8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 28 Jul 2026 18:36:42 +0200 Subject: [PATCH 25/66] 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. --- .changeset/generation-persistence.md | 2 +- .../generation-persistence-advanced.md | 25 +---- docs/persistence/generation-persistence.md | 8 ++ packages/ai-client/src/connection-adapters.ts | 12 +++ packages/ai-client/src/generation-client.ts | 48 +++++++++ .../ai-client/src/video-generation-client.ts | 44 ++++++++ .../ai-client/tests/generation-client.test.ts | 101 ++++++++++++++++++ 7 files changed, 218 insertions(+), 22 deletions(-) diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index 774c0fdbc..e7c34c1eb 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -19,6 +19,6 @@ Add generation persistence, mirroring chat: media generation runs survive a relo **Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the job record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. -**Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take the same `persistence` option chat does: `true` (server-driven — cache nothing, hydrate the last run for a stable `threadId` on mount) or a storage adapter (client-driven — write a lightweight snapshot under `generation:` as the run streams and read it back on mount). Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and keeps `resumeState` for the in-flight run identity — there is no `resumeSnapshot` / `pendingArtifacts` / `resultArtifacts` hook field. 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. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. +**Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take the same `persistence` option chat does: `true` (server-driven — cache nothing, hydrate the last run for a stable `threadId` on mount) or a storage adapter (client-driven — write a lightweight snapshot under `generation:` as the run streams and read it back on mount). Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and keeps `resumeState` for the in-flight run identity — there is no `resumeSnapshot` / `pendingArtifacts` / `resultArtifacts` hook field. If a run is still generating when the connection drops or the page reloads, the client re-attaches to it and finishes it in place (via the connection's `joinRun` durability replay), exactly like `useChat`. 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. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too with no type argument (a bare call is correct on either side). Untrusted snapshots are validated with the new `parseGenerationResumeSnapshot`. diff --git a/docs/persistence/generation-persistence-advanced.md b/docs/persistence/generation-persistence-advanced.md index 7dc79dd01..36483577c 100644 --- a/docs/persistence/generation-persistence-advanced.md +++ b/docs/persistence/generation-persistence-advanced.md @@ -5,27 +5,10 @@ id: generation-persistence-advanced # Generation Persistence: Advanced -The deeper parts of [Generation persistence](./generation-persistence): -reconnecting a stream that is still running, the in-flight `resumeState`, seeding -state yourself, securing the hydration endpoint, and exactly what the record -holds. Start with the main guide; come here when you need one of these. - -## Reconnect to a run that is still streaming - -The server-driven endpoint in the main guide already wires this: a `durability` -adapter on `toServerSentEventsResponse`, plus a `GET` that replays the run from -the log when the request carries a resume cursor. On the client there is nothing -to add. A connection dropped mid-generation re-attaches on its own through -`fetchServerSentEvents` or `fetchHttpStream`, the same adapters `useChat` uses. -In production, swap `memoryStream` for `durableStream` from -`@tanstack/ai-durable-stream`, where requests span processes. - -A full page reload is different: the hooks never start or resume a run on mount, -and the record alone cannot re-attach to the stream. It records that a run was -in flight, not a stream position. After a reload, a non-null `resumeState` is -informational: "a run was still going when the page closed." See -[Resumable Streams](../resumable-streams/overview) for the durability contract, -production adapters, and the one-time-side-effects note. +The deeper parts of [Generation persistence](./generation-persistence): the +in-flight `resumeState`, seeding state yourself, securing the hydration endpoint, +and exactly what the record holds. Start with the main guide; come here when you +need one of these. ## The in-flight `resumeState` diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index aa23a7fac..4e111c166 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -147,6 +147,14 @@ after a reload just as it did live. You never fetch or seed anything: the thread id is the stable key, and a reload or the same thread on another device follow the identical path. +If a run is **still generating** when the connection drops or the page reloads, +the client re-attaches to it and finishes it in place, exactly like `useChat`. +The `durability` adapter on `toServerSentEventsResponse` plus the `GET` resume +branch above are all it needs: on mount `reconstructGeneration` reports the live +run and the client tails it through the durability log. In production, swap +`memoryStream` for `durableStream` from `@tanstack/ai-durable-stream`, where +requests span processes. See [Resumable Streams](../resumable-streams/overview). + ## Client-driven: a storage adapter Pass a storage adapter instead, and give the hook a stable `id`. The client diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 20979d3a1..1dc86dacc 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -751,6 +751,18 @@ export interface ConnectConnectionAdapter { * `hydrate` handler. */ hydrateGeneration?: (threadId: string) => Promise + /** + * Re-attach to a run that is still generating and replay it from the start + * (read-only `?offset=-1&runId` against the delivery-durability log). The + * generation client tails this on mount when hydration reports a run still in + * flight, so a dropped connection or a full reload finishes the generation in + * place — the same durability replay the chat client uses. Optional and + * feature-detected; present on `fetchServerSentEvents` / `fetchHttpStream`. + */ + joinRun?: ( + runId: string, + abortSignal?: AbortSignal, + ) => AsyncIterable } /** diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 740b3f426..d85a3eda6 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -118,6 +118,7 @@ export class GenerationClient< private queuedSnapshotSignature: string | undefined private resumePersistenceError: Error | undefined = undefined private abortController: AbortController | null = null + private rejoinedRunId: string | undefined private readonly callbacksRef: GenerationCallbacks private devtoolsMounted = false private disposed = false @@ -785,6 +786,10 @@ export class GenerationClient< // observed since construction. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return this.repaintFromSnapshot(snapshot) + // If the stored run was still generating, re-attach and finish it in place. + if (snapshot.status === 'running' && snapshot.resumeState?.runId) { + this.rejoinInFlight(snapshot.resumeState.runId) + } } /** @@ -814,6 +819,49 @@ export class GenerationClient< if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return this.repaintFromSnapshot(snapshot) + // A run still generating on the server: re-attach and finish it in place. + if (res.activeRun?.runId) { + this.rejoinInFlight(res.activeRun.runId) + } + })() + } + + /** + * Re-attach to a run that is still generating and stream it to completion, + * mirroring the chat client's mount-time rejoin. Reuses `processStream`, so + * `result` / `progress` / `status` repaint from the replayed chunks exactly as + * a live run does. Best-effort: a live `generate()` owns the client and is + * never stomped, and the same run is only rejoined once. + */ + private rejoinInFlight(runId: string): void { + const joinRun = this.connection?.joinRun + if (!joinRun) return + if (this.rejoinedRunId === runId) return + // A fresh send (or an in-progress rejoin) owns the client. + if (this.isLoading || this.abortController) return + this.rejoinedRunId = runId + const controller = new AbortController() + this.abortController = controller + this.setIsLoading(true) + this.setStatus('generating') + void (async () => { + try { + await this.processStream(joinRun(runId, controller.signal), runId, controller.signal) + } catch (error) { + if (!controller.signal.aborted) { + this.recordResumeSnapshotError( + error instanceof Error ? error : new Error(String(error)), + ) + } + } finally { + // Only reset if this rejoin still owns the client: a `stop()` + + // fresh `generate()` may have replaced the controller while the tail + // was settling, and that live run owns `isLoading` now. + if (this.abortController === controller) { + this.abortController = null + this.setIsLoading(false) + } + } })() } diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index c5e808add..ab03e1a4b 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -124,6 +124,7 @@ export class VideoGenerationClient { private queuedSnapshotSignature: string | undefined private resumePersistenceError: Error | undefined = undefined private abortController: AbortController | null = null + private rejoinedRunId: string | undefined private readonly callbacksRef: VideoCallbacks private devtoolsMounted = false private disposed = false @@ -852,6 +853,9 @@ export class VideoGenerationClient { // observed since construction. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return this.repaintFromSnapshot(snapshot) + if (snapshot.status === 'running' && snapshot.resumeState?.runId) { + this.rejoinInFlight(snapshot.resumeState.runId) + } } /** @@ -881,6 +885,46 @@ export class VideoGenerationClient { if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return this.repaintFromSnapshot(snapshot) + if (res.activeRun?.runId) { + this.rejoinInFlight(res.activeRun.runId) + } + })() + } + + /** + * Re-attach to a video run still generating and stream it to completion, + * mirroring the chat client's mount-time rejoin. Reuses `processStream`, so + * the job status and result repaint from the replayed chunks. A live + * `generate()` owns the client and is never stomped; a run is rejoined once. + */ + private rejoinInFlight(runId: string): void { + const joinRun = this.connection?.joinRun + if (!joinRun) return + if (this.rejoinedRunId === runId) return + if (this.isLoading || this.abortController) return + this.rejoinedRunId = runId + const controller = new AbortController() + this.abortController = controller + this.setIsLoading(true) + this.setStatus('generating') + void (async () => { + try { + await this.processStream(joinRun(runId, controller.signal), runId, controller.signal) + } catch (error) { + if (!controller.signal.aborted) { + this.recordResumeSnapshotError( + error instanceof Error ? error : new Error(String(error)), + ) + } + } finally { + // Only reset if this rejoin still owns the client: a `stop()` + + // fresh `generate()` may have replaced the controller while the tail + // was settling, and that live run owns `isLoading` now. + if (this.abortController === controller) { + this.abortController = null + this.setIsLoading(false) + } + } })() } diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index af5ac443a..3a986685a 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -1529,6 +1529,56 @@ describe('GenerationClient', () => { ) }) + it('rejoins an in-flight run from a stored running snapshot', async () => { + const runId = 'run-stored-live' + const { persistence } = createMapPersistence({ + 'generation:hero': { + schemaVersion: 1, + resumeState: { threadId: 'hero', runId }, + status: 'running', + }, + }) + const chunks: Array = [ + { + type: EventType.RUN_STARTED, + runId, + threadId: 'hero', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:result', + value: { + id: 'stored-img', + model: 'test-image', + images: [{ url: '/stored.png' }], + }, + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId, + threadId: 'hero', + timestamp: Date.now(), + }, + ] + const joinRun = vi.fn(async function* () { + for (const chunk of chunks) yield chunk + }) + const client = new GenerationClient({ + id: 'hero', + connection: { async *connect() {}, joinRun }, + persistence, + }) + + await waitForCondition(() => { + expect(client.getStatus()).toBe('success') + expect(client.getResult()).toMatchObject({ id: 'stored-img' }) + }) + expect(joinRun).toHaveBeenCalledWith(runId, expect.anything()) + expect(client.getIsLoading()).toBe(false) + }) + it('repaints status/result from a stored complete image snapshot via reconstructResult', async () => { const snapshot: GenerationResumeSnapshot = { schemaVersion: 1, @@ -1948,6 +1998,57 @@ describe('GenerationClient', () => { expect(onResumeStateChange).toHaveBeenLastCalledWith(null) }) + it('rejoins an in-flight run reported by the server and finishes it in place', async () => { + const runId = 'run-live-1' + const chunks: Array = [ + { + type: EventType.RUN_STARTED, + runId, + threadId: 'thread-live', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:result', + value: { + id: 'live-img', + model: 'test-image', + images: [{ url: '/live.png' }], + }, + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId, + threadId: 'thread-live', + timestamp: Date.now(), + }, + ] + const joinRun = vi.fn(async function* () { + for (const chunk of chunks) yield chunk + }) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-live', runId }, + status: 'running' as const, + }, + activeRun: { runId }, + })) + const client = new GenerationClient({ + threadId: 'thread-live', + connection: { async *connect() {}, hydrateGeneration, joinRun }, + persistence: true, + }) + + await waitForCondition(() => { + expect(client.getStatus()).toBe('success') + expect(client.getResult()).toMatchObject({ id: 'live-img' }) + }) + expect(joinRun).toHaveBeenCalledWith(runId, expect.anything()) + expect(client.getIsLoading()).toBe(false) + }) + it('does nothing when the connection exposes no hydrateGeneration', async () => { const client = new GenerationClient({ threadId: 'thread-server', From 709032eb32d21fa05e05609e062f4d52823c9f77 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:38:07 +0000 Subject: [PATCH 26/66] ci: apply automated fixes --- packages/ai-client/src/generation-client.ts | 6 +++++- packages/ai-client/src/video-generation-client.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index d85a3eda6..b45a9db34 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -846,7 +846,11 @@ export class GenerationClient< this.setStatus('generating') void (async () => { try { - await this.processStream(joinRun(runId, controller.signal), runId, controller.signal) + await this.processStream( + joinRun(runId, controller.signal), + runId, + controller.signal, + ) } catch (error) { if (!controller.signal.aborted) { this.recordResumeSnapshotError( diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index ab03e1a4b..af7aa08e4 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -909,7 +909,11 @@ export class VideoGenerationClient { this.setStatus('generating') void (async () => { try { - await this.processStream(joinRun(runId, controller.signal), runId, controller.signal) + await this.processStream( + joinRun(runId, controller.signal), + runId, + controller.signal, + ) } catch (error) { if (!controller.signal.aborted) { this.recordResumeSnapshotError( From 61b8d07847181eb348ee2a0a9e34af54be4f7ebb Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 28 Jul 2026 18:45:16 +0200 Subject: [PATCH 27/66] docs(persistence): remove the generation-persistence advanced page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/config.json | 5 - .../generation-persistence-advanced.md | 101 ------------------ docs/persistence/generation-persistence.md | 8 +- 3 files changed, 3 insertions(+), 111 deletions(-) delete mode 100644 docs/persistence/generation-persistence-advanced.md diff --git a/docs/config.json b/docs/config.json index fd1d55d3b..516e70215 100644 --- a/docs/config.json +++ b/docs/config.json @@ -262,11 +262,6 @@ "addedAt": "2026-07-28", "updatedAt": "2026-07-28" }, - { - "label": "Generation Persistence: Advanced", - "to": "persistence/generation-persistence-advanced", - "addedAt": "2026-07-28" - }, { "label": "Keep Generated Files", "to": "persistence/keep-generated-files", diff --git a/docs/persistence/generation-persistence-advanced.md b/docs/persistence/generation-persistence-advanced.md deleted file mode 100644 index 36483577c..000000000 --- a/docs/persistence/generation-persistence-advanced.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -title: 'Generation Persistence: Advanced' -id: generation-persistence-advanced ---- - -# Generation Persistence: Advanced - -The deeper parts of [Generation persistence](./generation-persistence): the -in-flight `resumeState`, seeding state yourself, securing the hydration endpoint, -and exactly what the record holds. Start with the main guide; come here when you -need one of these. - -## The in-flight `resumeState` - -`resumeState` is non-null only while a run is streaming. It is the identity of -the live run (`threadId` / `runId`, plus `pendingArtifacts` while media streams), -and it clears when the run ends. Use `status` / `result` to display a finished -run; use `resumeState` to tell that a run was still going. - -For a client-driven store: - -- **The `id` is the storage key.** The snapshot is written under - `generation:` (plus the adapter's `keyPrefix`), so a stable `id` is what - makes the record findable after a reload. Omitting `id` generates a fresh key - per mount. The `generation:` segment also keeps a chat client with the same id - from colliding. -- `stop()` marks the record no longer resumable; `reset()` deletes it. -- The snapshot never triggers work. A generation starts only on `generate(...)`. - -## Seed the state yourself - -If your app manages storage itself (custom backend, SSR-provided state), read -the value and pass it as `initialResumeSnapshot`, which skips the automatic read. -Validate untrusted values with `parseGenerationResumeSnapshot`: - -```tsx -import { parseGenerationResumeSnapshot } from '@tanstack/ai-client' -import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react' - -function HeroImageGenerator({ stored }: { stored: unknown }) { - const seed = parseGenerationResumeSnapshot(stored) - const image = useGenerateImage({ - id: 'hero-image', - connection: fetchServerSentEvents('/api/generate/image'), - ...(seed ? { initialResumeSnapshot: seed } : {}), - }) - return

{image.status}

-} -``` - -## Secure the hydration endpoint - -`reconstructGeneration(persistence, request)` reads a `?jobId` (preferred) or -`?threadId` from the query and returns the last job's record plus a cursor to any -still-running run. It resolves the latest job for a thread through the store's -`findLatestForThread`. It does **not** enforce tenancy on its own, so on a public -route pass its `authorize` option (session to owned thread/job) before returning -anything: - -```ts group=generation-authorize -import { - memoryPersistence, - reconstructGeneration, -} from '@tanstack/ai-persistence' - -const persistence = memoryPersistence() - -// Replace these with your real session + ownership checks. -async function getSessionUserId(_request: Request): Promise { - return null -} -async function userOwnsThread( - _userId: string, - _threadId: string, -): Promise { - return true -} - -export function GET(request: Request): Response | Promise { - return reconstructGeneration(persistence, request, { - authorize: async (id, req) => { - const userId = await getSessionUserId(req) - return userId !== null && (await userOwnsThread(userId, id)) - }, - }) -} -``` - -## What the record holds - -The record, hydrated from the server or read from the browser snapshot, holds -run identity and result metadata (ids, model, status, a video `jobId`, an expiry -timestamp), never the generated bytes. On its own it does not carry a usable -media URL, so a reload restores `status` and `error` while `result` stays `null`. - -To bring the media back too, persist the bytes on the server with an `artifacts` -+ `blobs` backend and stamp a durable serve URL onto each ref with -`withGenerationPersistence`'s `artifactUrl` (see -[Keep generated files](./keep-generated-files)). Those durable refs travel on the -record, so the restored `result` is rebuilt with its media resolved to your own -origin and its refs on `result.artifacts`. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 4e111c166..694cbdc53 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -94,8 +94,9 @@ export function GET(request: Request): Response | Promise { if (durability.resumeFrom() !== null) { return resumeServerSentEventsResponse({ adapter: durability }) } - // Otherwise this is the mount-time hydration (?threadId). Guard it with the - // `authorize` option in multi-user apps (see the advanced guide). + // Otherwise this is the mount-time hydration (?threadId). In multi-user apps + // pass reconstructGeneration's `authorize` option so a guessed threadId can't + // read another user's generation. return reconstructGeneration(persistence, request) } ``` @@ -195,6 +196,3 @@ Two things to keep in mind: - [Keep generated files](./keep-generated-files): store the generated bytes so the media itself survives, not just the record. -- [Generation persistence: advanced](./generation-persistence-advanced): - reconnecting a dropped stream, the in-flight `resumeState`, seeding state - yourself, and securing the hydration endpoint. From 7fe97960bb7b443dda433e16925a38f39ffe9717 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:56:53 +1000 Subject: [PATCH 28/66] feat(examples): shared generation run history + fix stale generation-persistence docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/persistence/controls.md | 2 +- docs/persistence/internals.md | 28 +- .../src/components/GenerationRunHistory.tsx | 236 ++++++++++++ .../ts-react-chat/src/lib/generation-runs.ts | 339 ++++++++++++++++++ .../src/routes/generation-hooks.tsx | 82 ++++- .../src/routes/generations.audio.tsx | 40 ++- .../src/routes/generations.image.tsx | 120 +++---- .../src/routes/generations.speech.tsx | 43 ++- .../src/routes/generations.summarize.tsx | 17 + .../src/routes/generations.transcription.tsx | 17 + .../src/routes/generations.video.tsx | 28 ++ packages/ai-persistence/src/types.ts | 15 +- 12 files changed, 860 insertions(+), 107 deletions(-) create mode 100644 examples/ts-react-chat/src/components/GenerationRunHistory.tsx create mode 100644 examples/ts-react-chat/src/lib/generation-runs.ts diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md index cbb49777b..0e2cddeb0 100644 --- a/docs/persistence/controls.md +++ b/docs/persistence/controls.md @@ -89,7 +89,7 @@ values arrive from untyped JavaScript. - `withPersistence` requires `messages`. - `interrupts` requires `runs`: an interrupt record is scoped to a run. -- `withGenerationPersistence` requires `runs`. +- `withGenerationPersistence` requires `jobs`. To define a partial backend directly rather than by composing, use `defineAIPersistence({ stores: { ... } })` and pass only the stores you have. diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md index 4faf73cc4..17b0155ce 100644 --- a/docs/persistence/internals.md +++ b/docs/persistence/internals.md @@ -50,18 +50,19 @@ stored transcript is loaded and used. ## Generation middleware lifecycle -`withGenerationPersistence(persistence)` records the run: `onStart` creates or -resumes the run record, and `onFinish`, `onError`, and `onAbort` terminalize -it. Durable media storage (artifact metadata plus blob bytes) is a follow-up -feature. - -**Do not treat this as the long-term generation model.** Today it reuses chat -`RunStore` and dual-keys `(runId, threadId)` both to `requestId`. That is a -stopgap: **generation jobs must not fake `threadId = requestId`.** `threadId` -is the shared conversation key (`Scope.threadId`); a generation job's primary -id is `requestId` / `jobId`. The follow-up should introduce a dedicated -generation job store (and later artifact store), not chat `RunStore` / -`MessageStore`. +`withGenerationPersistence(persistence)` records the job: `onStart` creates or +resumes the job record, `onFinish`, `onError`, and `onAbort` terminalize it, +and a result transform captures the terminal result metadata (ids, urls — never +media bytes) onto the record. When `artifacts` and `blobs` are both provided it +also persists the generated media and merges the durable refs onto both the +result and the job record. + +Generation uses its own `jobs` store (`GenerationJobStore`), never chat's +`runs` / `messages`. A generation has no conversation, so the job is keyed on +its own `jobId` (`ctx.runId ?? ctx.requestId`) and `threadId` is **optional** — +carried through only when the caller supplies one, as a link to a chat or as +the stable hydration key a server-driven client reloads by. It is never faked +from the request id, and `threadId` never becomes the job's primary identity. ## Composition semantics @@ -95,8 +96,9 @@ removed. Unknown store keys are rejected statically and by runtime validation. Middleware adds entrypoint validation: - chat requires `messages`; rejects `interrupts` without `runs`. -- generation requires `runs`. +- generation requires `jobs`. - `reconstructChat` requires `messages`. +- `reconstructGeneration` requires `jobs`. The runtime checks are required because JavaScript, configuration loading, and explicitly widened types can bypass static guarantees. diff --git a/examples/ts-react-chat/src/components/GenerationRunHistory.tsx b/examples/ts-react-chat/src/components/GenerationRunHistory.tsx new file mode 100644 index 000000000..9dd92bb60 --- /dev/null +++ b/examples/ts-react-chat/src/components/GenerationRunHistory.tsx @@ -0,0 +1,236 @@ +import { useState } from 'react' +import { ChevronRight } from 'lucide-react' +import { clearGenerationRuns, useGenerationRuns } from '../lib/generation-runs' +import type { GenerationKind, GenerationRunEntry } from '../lib/generation-runs' + +const KIND_LABELS: Record = { + image: 'Image', + audio: 'Audio', + speech: 'Speech', + transcription: 'Transcription', + summarize: 'Summarize', + video: 'Video', +} + +function formatWhen(at: number): string { + const elapsed = Date.now() - at + if (elapsed < 60_000) return 'just now' + if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago` + if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago` + return new Date(at).toLocaleString() +} + +function RunOutput({ entry }: { entry: GenerationRunEntry }) { + const preview = entry.preview ?? [] + const hasMedia = preview.length > 0 || entry.artifacts.length > 0 + + return ( +
+ {entry.label && ( +
+

+ Input +

+

+ {entry.label} +

+
+ )} + + {entry.error ? ( +
+

+ Error +

+

+ {entry.error} +

+
+ ) : ( +
+

+ Output +

+ {entry.text && ( +

+ {entry.text} +

+ )} + {preview.length > 0 && ( +
+ {preview.map((item, index) => + item.type === 'image' ? ( + {entry.label + ) : item.type === 'audio' ? ( +
+ )} + {entry.artifacts.length > 0 && ( +
+ {entry.artifacts.map((artifact) => + artifact.mimeType.startsWith('image/') ? ( + {artifact.name} + ) : artifact.mimeType.startsWith('audio/') ? ( +
+ )} + {!entry.text && !hasMedia && ( +

+ The output wasn't stored — media bytes are never persisted, and + this run's preview was missing or too large for localStorage. Only + the run's metadata survives. +

+ )} +
+ )} + + {entry.model && ( +

model: {entry.model}

+ )} +
+ ) +} + +/** + * The previous-runs list every generation page shows. Entries are recorded by + * `generationRunPersistence()` (see `lib/generation-runs.ts`) whenever a run + * finishes or fails, and survive reloads in localStorage. Click an entry to + * see what was generated: the input, text output, and any media preview the + * route captured at result time. Pass `kind` to show one activity's runs, or + * omit it to show everything. + */ +export function GenerationRunHistory({ kind }: { kind?: GenerationKind }) { + const runs = useGenerationRuns(kind) + const [expandedId, setExpandedId] = useState(null) + + return ( +
+
+

+ Previous runs + {runs.length > 0 && ( + + {runs.length} + + )} +

+ {runs.length > 0 && ( + + )} +
+ + {runs.length === 0 ? ( +

+ No previous runs yet. Every generation on this page is persisted to + localStorage; finished runs are listed here and survive a reload. +

+ ) : ( +
    + {runs.map((entry) => { + const expanded = expandedId === entry.entryId + return ( +
  • + + {expanded && } +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/examples/ts-react-chat/src/lib/generation-runs.ts b/examples/ts-react-chat/src/lib/generation-runs.ts new file mode 100644 index 000000000..2f0fb4336 --- /dev/null +++ b/examples/ts-react-chat/src/lib/generation-runs.ts @@ -0,0 +1,339 @@ +import { useSyncExternalStore } from 'react' +import { localStoragePersistence } from '@tanstack/ai-client' +import type { + GenerationPersistence, + GenerationResumeSnapshot, +} from '@tanstack/ai-client' + +/** + * Shared generation persistence + run history for the example app. + * + * Every generation route wires its hooks through `generationRunPersistence()`, + * which does two things: + * + * 1. Delegates to the standard `localStoragePersistence()` adapter, so each + * hook's last run (status, result metadata, error — never media bytes) + * survives a full page reload exactly as the library intends. + * 2. Watches the snapshots the client writes and, whenever one reaches a + * terminal status (`complete` / `error`), appends a compact entry to a + * single shared run-history list in localStorage. The `GenerationRunHistory` + * component renders that list, so every page shows its previous runs. + * + * The library itself only keeps the *last* snapshot per hook id — the history + * list is plain example-app code layered on top of the storage adapter + * contract, which is the point: run history is an app concern, and the adapter + * seam is where you build it. + */ + +export type GenerationKind = + | 'image' + | 'audio' + | 'speech' + | 'transcription' + | 'summarize' + | 'video' + +export interface GenerationRunArtifact { + url: string + name: string + mimeType: string +} + +export interface GenerationRunPreviewItem { + type: 'image' | 'audio' | 'video' + src: string +} + +export interface GenerationRunEntry { + entryId: string + kind: GenerationKind + /** Epoch millis when the run reached a terminal state. */ + at: number + status: 'complete' | 'error' + /** The prompt / input summary captured at generate time, when available. */ + label?: string + model?: string + /** Text output (a transcript or summary), when the activity produces text. */ + text?: string + error?: string + /** Durable artifact serve URLs, when the server persisted media. */ + artifacts: Array + /** + * Lightweight media previews captured at result time (remote URLs or small + * data: URLs), so clicking a history entry can show what was generated even + * though snapshots never persist media bytes. Oversized outputs are skipped. + */ + preview?: Array +} + +const HISTORY_STORAGE_KEY = 'example:generation-runs' +const MAX_ENTRIES = 30 +// Per-item ceiling for stored preview sources. A remote URL is tiny; a data: +// URL for a short audio clip or SVG fits comfortably; a full-size PNG's base64 +// does not and is skipped rather than blowing the localStorage quota. +const PREVIEW_SRC_MAX_CHARS = 300_000 +const PREVIEW_MAX_ITEMS = 4 + +// The base adapter every generation hook in this app shares. The client +// namespaces its record under `generation:`, so one adapter serves +// every hook as long as each hook passes a stable `id`. +const snapshotStore = localStoragePersistence({ + keyPrefix: 'example:', +}) + +// The most recent input per kind, captured by `rememberRunLabel()` just before +// `generate()` is called. Snapshots never contain the input (only run identity +// and result metadata), so the label rides along out-of-band. +const pendingLabels = new Map() + +// Media previews captured by `rememberRunPreview()` from `onResult` while the +// run is finishing; consumed when the terminal snapshot records the entry. +const pendingPreviews = new Map< + GenerationKind, + Array +>() + +// Last terminal snapshot recorded per hook id, to avoid double-recording when +// the client re-persists an unchanged terminal snapshot. +const recordedSnapshots = new Map() + +const listeners = new Set<() => void>() +let crossTabListenerAttached = false + +const EMPTY_ENTRIES: Array = [] +let cachedRaw: string | null = null +let cachedEntries: Array = EMPTY_ENTRIES +const cachedByKind = new Map>() + +function getLocalStorage(): Storage | null { + const browserGlobals: { localStorage?: Storage } = globalThis + return browserGlobals.localStorage ?? null +} + +function notify(): void { + for (const listener of listeners) listener() +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + if (!crossTabListenerAttached && typeof window !== 'undefined') { + crossTabListenerAttached = true + window.addEventListener('storage', (event) => { + if (event.key === HISTORY_STORAGE_KEY) notify() + }) + } + return () => { + listeners.delete(listener) + } +} + +function isRunEntry(value: unknown): value is GenerationRunEntry { + return ( + typeof value === 'object' && + value !== null && + 'entryId' in value && + typeof value.entryId === 'string' && + 'kind' in value && + typeof value.kind === 'string' && + 'at' in value && + typeof value.at === 'number' && + 'status' in value && + (value.status === 'complete' || value.status === 'error') && + 'artifacts' in value && + Array.isArray(value.artifacts) + ) +} + +function readEntries(): Array { + const storage = getLocalStorage() + if (!storage) return EMPTY_ENTRIES + let raw: string | null = null + try { + raw = storage.getItem(HISTORY_STORAGE_KEY) + } catch { + return EMPTY_ENTRIES + } + if (raw === null) return EMPTY_ENTRIES + if (raw === cachedRaw) return cachedEntries + try { + const parsed: unknown = JSON.parse(raw) + cachedEntries = Array.isArray(parsed) ? parsed.filter(isRunEntry) : [] + } catch { + cachedEntries = [] + } + cachedRaw = raw + cachedByKind.clear() + return cachedEntries +} + +function writeEntries(entries: Array): void { + const storage = getLocalStorage() + if (!storage) return + // Best-effort with graceful degradation: if the write exceeds the quota, + // drop previews from all but the newest few entries and retry, then fall + // back to a shorter, preview-free list before giving up. + const attempts: Array> = [ + entries, + entries.map((entry, index) => + index < 3 || !entry.preview ? entry : { ...entry, preview: undefined }, + ), + entries.slice(0, 10).map((entry) => ({ ...entry, preview: undefined })), + ] + for (const attempt of attempts) { + try { + storage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(attempt)) + notify() + return + } catch { + continue + } + } +} + +function entriesForKind(kind: GenerationKind): Array { + const all = readEntries() + const cached = cachedByKind.get(kind) + if (cached) return cached + const filtered = all.filter((entry) => entry.kind === kind) + cachedByKind.set(kind, filtered) + return filtered +} + +function toRunEntry( + kind: GenerationKind, + snapshot: GenerationResumeSnapshot, +): GenerationRunEntry | null { + if (snapshot.status !== 'complete' && snapshot.status !== 'error') return null + + const artifacts: Array = [] + for (const ref of snapshot.result?.artifacts ?? + snapshot.pendingArtifacts ?? + []) { + if (ref.role === 'output' && ref.url) { + artifacts.push({ url: ref.url, name: ref.name, mimeType: ref.mimeType }) + } + } + + const label = pendingLabels.get(kind) + // Consume the pending preview: it belongs to this run only. A failed run + // gets no preview (its onResult never fired for this run's output). + const preview = pendingPreviews.get(kind) + pendingPreviews.delete(kind) + return { + entryId: `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind, + at: Date.now(), + status: snapshot.status, + ...(label ? { label } : {}), + ...(snapshot.result?.model ? { model: snapshot.result.model } : {}), + ...(snapshot.result?.text ? { text: snapshot.result.text } : {}), + ...(snapshot.error ? { error: snapshot.error.message } : {}), + artifacts, + ...(snapshot.status === 'complete' && preview && preview.length > 0 + ? { preview } + : {}), + } +} + +function recordTerminalSnapshot( + kind: GenerationKind, + hookId: string, + snapshot: GenerationResumeSnapshot, +): void { + const entry = toRunEntry(kind, snapshot) + if (!entry) return + // The client persists on every observed chunk; a terminal snapshot is + // written once per run, but guard against identical re-writes anyway. + const serialized = JSON.stringify({ + status: snapshot.status, + result: snapshot.result, + error: snapshot.error, + }) + if (recordedSnapshots.get(hookId) === serialized) return + recordedSnapshots.set(hookId, serialized) + writeEntries([entry, ...readEntries()].slice(0, MAX_ENTRIES)) +} + +/** + * A `GenerationPersistence` adapter for one generation kind: standard + * localStorage snapshot persistence, plus run-history recording. Pass a stable + * `id` to the hook alongside this adapter so a reload finds the same record. + */ +export function generationRunPersistence( + kind: GenerationKind, +): GenerationPersistence { + return { + getItem: (id) => snapshotStore.getItem(id), + setItem: (id, snapshot) => { + recordTerminalSnapshot(kind, id, snapshot) + return snapshotStore.setItem(id, snapshot) + }, + removeItem: (id) => snapshotStore.removeItem(id), + } +} + +/** + * Capture the user's input just before calling `generate()`, so the history + * entry for the finishing run can show what was asked for. Persisted snapshots + * deliberately carry no input, so this is the app's job. + */ +export function rememberRunLabel(kind: GenerationKind, label: string): void { + const trimmed = label.trim() + if (trimmed) pendingLabels.set(kind, trimmed.slice(0, 200)) + // A new run is starting — a preview left over from an earlier run must not + // attach to this one. + pendingPreviews.delete(kind) +} + +/** + * Capture what a finishing run generated, from the hook's `onResult`, so the + * history entry can show the output when clicked. Only remote http(s) URLs and + * small data: URLs are kept — a full-size image's base64 would blow the + * localStorage quota, so oversized items are dropped (the entry then shows + * metadata only). + */ +export function rememberRunPreview( + kind: GenerationKind, + items: Array, +): void { + const usable = items + .filter( + (item) => + (item.src.startsWith('https://') || + item.src.startsWith('http://') || + item.src.startsWith('data:')) && + item.src.length <= PREVIEW_SRC_MAX_CHARS, + ) + .slice(0, PREVIEW_MAX_ITEMS) + if (usable.length > 0) pendingPreviews.set(kind, usable) +} + +/** Previous runs, newest first — all kinds, or one kind when given. */ +export function useGenerationRuns( + kind?: GenerationKind, +): Array { + return useSyncExternalStore( + subscribe, + () => (kind ? entriesForKind(kind) : readEntries()), + () => EMPTY_ENTRIES, + ) +} + +/** Remove history entries — one kind's, or everything when omitted. */ +export function clearGenerationRuns(kind?: GenerationKind): void { + if (!kind) { + const storage = getLocalStorage() + if (!storage) return + try { + storage.removeItem(HISTORY_STORAGE_KEY) + } catch { + return + } + cachedRaw = null + cachedEntries = EMPTY_ENTRIES + cachedByKind.clear() + notify() + return + } + writeEntries(readEntries().filter((entry) => entry.kind !== kind)) +} diff --git a/examples/ts-react-chat/src/routes/generation-hooks.tsx b/examples/ts-react-chat/src/routes/generation-hooks.tsx index a4913116b..8d6f2feca 100644 --- a/examples/ts-react-chat/src/routes/generation-hooks.tsx +++ b/examples/ts-react-chat/src/routes/generation-hooks.tsx @@ -34,6 +34,12 @@ import type { TTSResult, } from '@tanstack/ai' import type { LucideIcon } from 'lucide-react' +import { + generationRunPersistence, + rememberRunLabel, + rememberRunPreview, +} from '../lib/generation-runs' +import { GenerationRunHistory } from '../components/GenerationRunHistory' const SAMPLE_WAV_BASE64 = createToneWavBase64() const SAMPLE_AUDIO_DATA_URL = `data:audio/wav;base64,${SAMPLE_WAV_BASE64}` @@ -134,6 +140,17 @@ const summarizeConnection = createGenerationConnection( const videoConnection = createVideoConnection() +// Every hook persists its lightweight resume snapshot and records finished +// runs into the shared history list rendered at the bottom of the page. +const fixturePersistence = { + image: generationRunPersistence('image'), + audio: generationRunPersistence('audio'), + speech: generationRunPersistence('speech'), + transcription: generationRunPersistence('transcription'), + summarize: generationRunPersistence('summarize'), + video: generationRunPersistence('video'), +} + const SAMPLE_SUMMARY_TEXT = 'Generation hooks emit core devtools snapshots with input, progress, result, and renderable previews for media and text outputs.' @@ -155,31 +172,62 @@ function GenerationHooksPage() { const image = useGenerateImage({ id: 'generation-hooks:useGenerateImage', connection: imageConnection, + persistence: fixturePersistence.image, + onResult: (result) => { + rememberRunPreview( + 'image', + result.images.map((img) => ({ + type: 'image' as const, + src: img.url ?? '', + })), + ) + }, }) const audio = useGenerateAudio({ id: 'generation-hooks:useGenerateAudio', connection: audioConnection, + persistence: fixturePersistence.audio, + onResult: (result) => { + rememberRunPreview('audio', [ + { type: 'audio', src: result.audio.url ?? '' }, + ]) + }, }) const speech = useGenerateSpeech({ id: 'generation-hooks:useGenerateSpeech', connection: speechConnection, + persistence: fixturePersistence.speech, + onResult: (result) => { + rememberRunPreview('speech', [ + { + type: 'audio', + src: `data:${result.contentType ?? 'audio/wav'};base64,${result.audio}`, + }, + ]) + }, }) const transcription = useTranscription({ id: 'generation-hooks:useTranscription', connection: transcriptionConnection, + persistence: fixturePersistence.transcription, }) const summarize = useSummarize({ id: 'generation-hooks:useSummarize', connection: summarizeConnection, + persistence: fixturePersistence.summarize, }) const video = useGenerateVideo({ id: 'generation-hooks:useGenerateVideo', connection: videoConnection, + persistence: fixturePersistence.video, + onResult: (result) => { + rememberRunPreview('video', [{ type: 'video', src: result.url }]) + }, }) const loadingCount = [ @@ -191,20 +239,36 @@ function GenerationHooksPage() { video.isLoading, ].filter(Boolean).length - const runImage = () => image.generate({ prompt, numberOfImages: imageCount }) - const runAudio = () => audio.generate({ prompt, duration: audioDuration }) - const runSpeech = () => speech.generate({ text: speechText, voice: 'local' }) - const runTranscription = () => - transcription.generate({ + const runImage = () => { + rememberRunLabel('image', prompt) + return image.generate({ prompt, numberOfImages: imageCount }) + } + const runAudio = () => { + rememberRunLabel('audio', prompt) + return audio.generate({ prompt, duration: audioDuration }) + } + const runSpeech = () => { + rememberRunLabel('speech', speechText) + return speech.generate({ text: speechText, voice: 'local' }) + } + const runTranscription = () => { + rememberRunLabel('transcription', 'Local fixture audio') + return transcription.generate({ audio: SAMPLE_TRANSCRIPTION_AUDIO, language: 'en', }) - const runSummarize = () => - summarize.generate({ + } + const runSummarize = () => { + rememberRunLabel('summarize', summaryText) + return summarize.generate({ text: summaryText, style: 'bullet-points', }) - const runVideo = () => video.generate({ prompt }) + } + const runVideo = () => { + rememberRunLabel('video', prompt) + return video.generate({ prompt }) + } const runAll = async () => { await Promise.all([ @@ -473,6 +537,8 @@ function GenerationHooksPage() { )} + +
) diff --git a/examples/ts-react-chat/src/routes/generations.audio.tsx b/examples/ts-react-chat/src/routes/generations.audio.tsx index 757278e73..5671cd96c 100644 --- a/examples/ts-react-chat/src/routes/generations.audio.tsx +++ b/examples/ts-react-chat/src/routes/generations.audio.tsx @@ -10,9 +10,19 @@ import { type AudioProviderConfig, type AudioProviderId, } from '../lib/audio-providers' +import { + generationRunPersistence, + rememberRunLabel, + rememberRunPreview, +} from '../lib/generation-runs' +import { GenerationRunHistory } from '../components/GenerationRunHistory' type Mode = 'hooks' | 'server-fn' +// Persist each variant's lightweight resume snapshot across reloads and record +// finished runs into the shared history list rendered below the form. +const audioPersistence = generationRunPersistence('audio') + interface AudioOutput { url: string contentType?: string @@ -58,6 +68,26 @@ function toAudioOutput(raw: AudioGenerationResult): AudioOutput | null { return null } +// Capture what was generated (a remote URL, or a small data: URL — never a +// session-only blob: URL) so clicking the history entry can replay it, then +// hand off to the normal UI transform. +function toAudioOutputWithPreview( + raw: AudioGenerationResult, +): AudioOutput | null { + const { audio } = raw + rememberRunPreview('audio', [ + { + type: 'audio', + src: + audio.url ?? + (audio.b64Json + ? `data:${audio.contentType ?? 'audio/mpeg'};base64,${audio.b64Json}` + : ''), + }, + ]) + return toAudioOutput(raw) +} + function AudioGenerationForm({ mode, config, @@ -74,17 +104,21 @@ function AudioGenerationForm({ const hookOptions = useMemo(() => { if (mode === 'hooks') { return { + id: `audio:${mode}:${config.id}`, connection: fetchServerSentEvents('/api/generate/audio'), body: { provider: config.id, model: selectedModel }, - onResult: toAudioOutput, + persistence: audioPersistence, + onResult: toAudioOutputWithPreview, } } return { + id: `audio:${mode}:${config.id}`, fetcher: (input: { prompt: string; duration?: number }) => generateAudioFn({ data: { ...input, provider: config.id, model: selectedModel }, }), - onResult: toAudioOutput, + persistence: audioPersistence, + onResult: toAudioOutputWithPreview, } }, [mode, config.id, selectedModel]) @@ -128,6 +162,7 @@ function AudioGenerationUI({ }) { const handleGenerate = () => { if (!prompt.trim()) return + rememberRunLabel('audio', prompt) generate({ prompt: prompt.trim(), duration }) } @@ -348,6 +383,7 @@ function AudioGenerationPage() { mode={mode} config={config} /> +
diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 8d942129f..4ac8ad1e0 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -2,28 +2,48 @@ import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useGenerateImage } from '@tanstack/ai-react' import type { UseGenerateImageReturn } from '@tanstack/ai-react' -import { - fetchServerSentEvents, - localStoragePersistence, -} from '@tanstack/ai-client' +import { fetchServerSentEvents } from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' +import { + generationRunPersistence, + rememberRunLabel, + rememberRunPreview, +} from '../lib/generation-runs' +import { GenerationRunHistory } from '../components/GenerationRunHistory' +import type { ImageGenerationResult } from '@tanstack/ai' -// Reuse the shared web-storage adapter for the lightweight generation resume -// snapshot. Only run identity, status, errors, and result metadata are stored -// — never the generated image bytes. A bare call needs no type argument: the -// adapter defaults to the generation snapshot shape. The client namespaces its -// record under `generation:` and reads it back on mount, repainting the -// hook's normal `status` / `result` / `error` fields so the last run's outcome -// survives a full page reload. -const imageSnapshots = localStoragePersistence({ keyPrefix: 'example:' }) +// Every variant persists its lightweight resume snapshot (run identity, +// status, errors, result metadata — never image bytes) and records finished +// runs into the shared history list. The client namespaces its record under +// `generation:` and reads it back on mount, repainting the hook's normal +// `status` / `result` / `error` fields so the last run's outcome survives a +// full page reload. +const imagePersistence = generationRunPersistence('image') + +// Capture what was generated so clicking the history entry can show it. +// Remote URLs store as-is; base64 payloads become data: URLs but oversized +// ones are dropped by the size guard in `rememberRunPreview`. +function recordImagePreview(result: ImageGenerationResult) { + rememberRunPreview( + 'image', + result.images.map((img) => ({ + type: 'image' as const, + src: + img.url ?? (img.b64Json ? `data:image/png;base64,${img.b64Json}` : ''), + })), + ) +} function StreamingImageGeneration() { const [prompt, setPrompt] = useState('') const [numberOfImages, setNumberOfImages] = useState(1) const hookReturn = useGenerateImage({ + id: 'image:streaming', connection: fetchServerSentEvents('/api/generate/image'), + persistence: imagePersistence, + onResult: recordImagePreview, }) return ( @@ -42,10 +62,13 @@ function DirectImageGeneration() { const [numberOfImages, setNumberOfImages] = useState(1) const hookReturn = useGenerateImage({ + id: 'image:direct', fetcher: (input) => generateImageFn({ data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, }), + persistence: imagePersistence, + onResult: recordImagePreview, }) return ( @@ -64,10 +87,13 @@ function ServerFnImageGeneration() { const [numberOfImages, setNumberOfImages] = useState(1) const hookReturn = useGenerateImage({ + id: 'image:server-fn', fetcher: (input) => generateImageStreamFn({ data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, }), + persistence: imagePersistence, + onResult: recordImagePreview, }) return ( @@ -81,54 +107,6 @@ function ServerFnImageGeneration() { ) } -function PersistedImageGeneration() { - const [prompt, setPrompt] = useState('') - const [numberOfImages, setNumberOfImages] = useState(1) - - const hookReturn = useGenerateImage({ - id: 'persisted-image', - connection: fetchServerSentEvents('/api/generate/image'), - persistence: imageSnapshots, - }) - - return ( -
-
- {hookReturn.resumeState ? ( -

- Run in flight:{' '} - {hookReturn.resumeState.runId} -

- ) : hookReturn.status === 'success' ? ( -

- Last run:{' '} - - success - {hookReturn.result?.model ? ` (${hookReturn.result.model})` : ''} - -

- ) : hookReturn.status === 'error' ? ( -

- Last run:{' '} - - error{hookReturn.error ? ` — ${hookReturn.error.message}` : ''} - -

- ) : ( -

No persisted run yet.

- )} -
- -
- ) -} - function ImageGenerationUI({ prompt, setPrompt, @@ -147,6 +125,7 @@ function ImageGenerationUI({ }) { const handleGenerate = () => { if (!prompt.trim()) return + rememberRunLabel('image', prompt) generate({ prompt: prompt.trim(), numberOfImages }) } @@ -230,9 +209,9 @@ function ImageGenerationUI({ } function ImageGenerationPage() { - const [mode, setMode] = useState< - 'streaming' | 'direct' | 'server-fn' | 'persisted' - >('streaming') + const [mode, setMode] = useState<'streaming' | 'direct' | 'server-fn'>( + 'streaming', + ) return (
@@ -275,16 +254,6 @@ function ImageGenerationPage() { > Server Fn -
@@ -295,11 +264,10 @@ function ImageGenerationPage() { ) : mode === 'direct' ? ( - ) : mode === 'server-fn' ? ( - ) : ( - + )} +
diff --git a/examples/ts-react-chat/src/routes/generations.speech.tsx b/examples/ts-react-chat/src/routes/generations.speech.tsx index dd78bcb96..4638e27a4 100644 --- a/examples/ts-react-chat/src/routes/generations.speech.tsx +++ b/examples/ts-react-chat/src/routes/generations.speech.tsx @@ -9,11 +9,21 @@ import { type SpeechProviderConfig, type SpeechProviderId, } from '../lib/audio-providers' +import { + generationRunPersistence, + rememberRunLabel, + rememberRunPreview, +} from '../lib/generation-runs' +import { GenerationRunHistory } from '../components/GenerationRunHistory' type SpeechOutput = { audioUrl: string; format?: string; duration?: number } type Mode = 'streaming' | 'direct' | 'server-fn' +// Persist each variant's lightweight resume snapshot across reloads and record +// finished runs into the shared history list rendered below the form. +const speechPersistence = generationRunPersistence('speech') + function toSpeechOutput(raw: { audio: string contentType?: string @@ -35,6 +45,25 @@ function toSpeechOutput(raw: { } } +// Capture what was generated as a small data: URL (never the session-only +// blob: URL the UI plays) so clicking the history entry can replay it, then +// hand off to the normal UI transform. Oversized clips are dropped by the +// size guard in `rememberRunPreview`. +function toSpeechOutputWithPreview(raw: { + audio: string + contentType?: string + format?: string + duration?: number +}): SpeechOutput { + rememberRunPreview('speech', [ + { + type: 'audio', + src: `data:${raw.contentType ?? 'audio/mpeg'};base64,${raw.audio}`, + }, + ]) + return toSpeechOutput(raw) +} + function SpeechGenerationForm({ mode, config, @@ -48,26 +77,32 @@ function SpeechGenerationForm({ const hookOptions = useMemo(() => { if (mode === 'streaming') { return { + id: `speech:${mode}:${config.id}`, connection: fetchServerSentEvents('/api/generate/speech'), body: { provider: config.id }, - onResult: toSpeechOutput, + persistence: speechPersistence, + onResult: toSpeechOutputWithPreview, } } if (mode === 'direct') { return { + id: `speech:${mode}:${config.id}`, fetcher: (input: { text: string; voice?: string }) => generateSpeechFn({ data: { ...input, provider: config.id }, }), - onResult: toSpeechOutput, + persistence: speechPersistence, + onResult: toSpeechOutputWithPreview, } } return { + id: `speech:${mode}:${config.id}`, fetcher: (input: { text: string; voice?: string }) => generateSpeechStreamFn({ data: { ...input, provider: config.id }, }), - onResult: toSpeechOutput, + persistence: speechPersistence, + onResult: toSpeechOutputWithPreview, } }, [mode, config.id]) @@ -105,6 +140,7 @@ function SpeechGenerationUI({ }) { const handleGenerate = () => { if (!text.trim()) return + rememberRunLabel('speech', text) generate({ text: text.trim(), voice }) } @@ -277,6 +313,7 @@ function SpeechGenerationPage() { mode={mode} config={config} /> + diff --git a/examples/ts-react-chat/src/routes/generations.summarize.tsx b/examples/ts-react-chat/src/routes/generations.summarize.tsx index bcc12898b..3353990d4 100644 --- a/examples/ts-react-chat/src/routes/generations.summarize.tsx +++ b/examples/ts-react-chat/src/routes/generations.summarize.tsx @@ -5,6 +5,15 @@ import type { UseSummarizeReturn } from '@tanstack/ai-react' import { fetchServerSentEvents } from '@tanstack/ai-client' import { summarizeFn, summarizeStreamFn } from '../lib/server-fns' import type { StreamChunk } from '@tanstack/ai' +import { + generationRunPersistence, + rememberRunLabel, +} from '../lib/generation-runs' +import { GenerationRunHistory } from '../components/GenerationRunHistory' + +// Persist each variant's lightweight resume snapshot across reloads and record +// finished runs into the shared history list rendered below the form. +const summarizePersistence = generationRunPersistence('summarize') const SAMPLE_TEXT = `Artificial intelligence (AI) has rapidly transformed from a niche academic pursuit into one of the most influential technologies of the 21st century. The development of large language models, in particular, has demonstrated capabilities that were previously thought to be decades away. These models can generate human-like text, translate languages, write code, and even engage in complex reasoning tasks. @@ -46,8 +55,10 @@ function StreamingSummarize() { // The connect adapter merges `body` into every request payload; the API // route reads `model` from `body.data` to pick the openaiSummarize variant. const hookReturn = useSummarize({ + id: 'summarize:streaming', connection: fetchServerSentEvents('/api/summarize'), body: { model }, + persistence: summarizePersistence, onChunk: (chunk) => setStreamingText((prev) => consumeStreamingChunk(chunk, prev) ?? prev), }) @@ -79,7 +90,9 @@ function DirectSummarize() { // mode is non-streaming — the result appears all at once when the // server-fn resolves. const hookReturn = useSummarize({ + id: 'summarize:direct', fetcher: (input) => summarizeFn({ data: { ...input, model } }), + persistence: summarizePersistence, }) return ( @@ -106,7 +119,9 @@ function ServerFnSummarize() { // The server-fn returns an SSE Response; GenerationClient parses it and // routes chunks through `onChunk` exactly like the connect-adapter path. const hookReturn = useSummarize({ + id: 'summarize:server-fn', fetcher: (input) => summarizeStreamFn({ data: { ...input, model } }), + persistence: summarizePersistence, onChunk: (chunk) => setStreamingText((prev) => consumeStreamingChunk(chunk, prev) ?? prev), }) @@ -156,6 +171,7 @@ function SummarizeUI({ }) { const handleSummarize = () => { if (!text.trim()) return + rememberRunLabel('summarize', text) onBeforeGenerate?.() // Intentionally no `maxLength` — for the OpenAI Responses API, // `maxLength` is mapped to `max_output_tokens`, which on GPT-5.x @@ -328,6 +344,7 @@ function SummarizePage() { ) : ( )} + diff --git a/examples/ts-react-chat/src/routes/generations.transcription.tsx b/examples/ts-react-chat/src/routes/generations.transcription.tsx index d889cd452..583067ed5 100644 --- a/examples/ts-react-chat/src/routes/generations.transcription.tsx +++ b/examples/ts-react-chat/src/routes/generations.transcription.tsx @@ -10,9 +10,18 @@ import type { } from '../lib/audio-providers' import type { UseTranscriptionReturn } from '@tanstack/ai-react' import type { TranscriptionGenerateInput } from '@tanstack/ai-client' +import { + generationRunPersistence, + rememberRunLabel, +} from '../lib/generation-runs' +import { GenerationRunHistory } from '../components/GenerationRunHistory' type Mode = 'streaming' | 'direct' | 'server-fn' +// Persist each variant's lightweight resume snapshot across reloads and record +// finished runs into the shared history list rendered below the form. +const transcriptionPersistence = generationRunPersistence('transcription') + function TranscriptionForm({ mode, config, @@ -23,12 +32,15 @@ function TranscriptionForm({ const hookOptions = useMemo(() => { if (mode === 'streaming') { return { + id: `transcription:${mode}:${config.id}`, connection: fetchServerSentEvents('/api/transcribe'), body: { provider: config.id }, + persistence: transcriptionPersistence, } } if (mode === 'direct') { return { + id: `transcription:${mode}:${config.id}`, fetcher: (input: TranscriptionGenerateInput) => transcribeFn({ data: { @@ -39,9 +51,11 @@ function TranscriptionForm({ provider: config.id, }, }), + persistence: transcriptionPersistence, } } return { + id: `transcription:${mode}:${config.id}`, fetcher: (input: TranscriptionGenerateInput) => transcribeStreamFn({ data: { @@ -52,6 +66,7 @@ function TranscriptionForm({ provider: config.id, }, }), + persistence: transcriptionPersistence, } }, [mode, config.id]) @@ -79,6 +94,7 @@ function TranscriptionUI({ ) const dataUrl = `data:${file.type};base64,${base64}` + rememberRunLabel('transcription', file.name) await generate({ audio: dataUrl, language: 'en', @@ -246,6 +262,7 @@ function TranscriptionPage() { mode={mode} config={config} /> + diff --git a/examples/ts-react-chat/src/routes/generations.video.tsx b/examples/ts-react-chat/src/routes/generations.video.tsx index d9e2683f0..ffd9c7e36 100644 --- a/examples/ts-react-chat/src/routes/generations.video.tsx +++ b/examples/ts-react-chat/src/routes/generations.video.tsx @@ -5,12 +5,32 @@ import type { UseGenerateVideoReturn } from '@tanstack/ai-react' import { fetchServerSentEvents } from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' import { generateVideoFn, generateVideoStreamFn } from '../lib/server-fns' +import { + generationRunPersistence, + rememberRunLabel, + rememberRunPreview, +} from '../lib/generation-runs' +import { GenerationRunHistory } from '../components/GenerationRunHistory' +import type { VideoGenerateResult } from '@tanstack/ai-client' + +// Persist each variant's lightweight resume snapshot across reloads and record +// finished runs into the shared history list rendered below the form. For a +// long video run this keeps the job id around after a reload. +const videoPersistence = generationRunPersistence('video') + +// Capture the generated video's URL so clicking the history entry can play it. +function recordVideoPreview(result: VideoGenerateResult) { + rememberRunPreview('video', [{ type: 'video', src: result.url }]) +} function StreamingVideoGeneration() { const [prompt, setPrompt] = useState('') const hookReturn = useGenerateVideo({ + id: 'video:streaming', connection: fetchServerSentEvents('/api/generate/video'), + persistence: videoPersistence, + onResult: recordVideoPreview, }) return ( @@ -22,10 +42,13 @@ function DirectVideoGeneration() { const [prompt, setPrompt] = useState('') const hookReturn = useGenerateVideo({ + id: 'video:direct', fetcher: (input) => generateVideoFn({ data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, }), + persistence: videoPersistence, + onResult: recordVideoPreview, }) return ( @@ -37,10 +60,13 @@ function ServerFnVideoGeneration() { const [prompt, setPrompt] = useState('') const hookReturn = useGenerateVideo({ + id: 'video:server-fn', fetcher: (input) => generateVideoStreamFn({ data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, }), + persistence: videoPersistence, + onResult: recordVideoPreview, }) return ( @@ -65,6 +91,7 @@ function VideoGenerationUI({ }) { const handleGenerate = () => { if (!prompt.trim()) return + rememberRunLabel('video', prompt) generate({ prompt: prompt.trim() }) } @@ -220,6 +247,7 @@ function VideoGenerationPage() { ) : ( )} + diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index fd91af204..4f0abf149 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -86,12 +86,19 @@ export interface MessageStore { export type RunStatus = 'running' | 'completed' | 'failed' | 'interrupted' /** - * A single **chat** run (one agent turn within a conversation). + * A single **chat** run: one `RUN_STARTED` → `RUN_FINISHED` cycle of the + * AG-UI protocol, persisted. + * + * A run is NOT a conversational turn, in either direction. It contains many + * agent-loop turns (each tool-call cycle is a turn whose text accumulates + * separately), and one user-visible turn may span several runs, because + * resuming after an interrupt mints a fresh `runId` — which is why + * {@link RunStore.findActiveRun} reconnects from the stable `threadId` rather + * than a run id. * * `threadId` is the conversation key ({@link Scope.threadId}) — never a - * generation `requestId`. Generation jobs must not reuse this record by - * faking `threadId = requestId`; they need a separate job store (see - * `withGenerationPersistence` JSDoc). + * generation `requestId`. Generation jobs do not use this record; they have + * their own {@link GenerationJobRecord} keyed on `jobId`. * * @property startedAt - Epoch ms when the run was first created. * @property finishedAt - Epoch ms when the run reached a terminal status. From 3ece807e3c30741f4c261b819fa7a947ddf0ae9a Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:43:49 +1000 Subject: [PATCH 29/66] 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. --- .changeset/generation-persistence.md | 6 +- docs/config.json | 14 +- docs/persistence/build-your-own-adapter.md | 136 +++++++++--------- docs/persistence/controls.md | 2 +- docs/persistence/generation-persistence.md | 6 +- docs/persistence/internals.md | 14 +- docs/persistence/keep-generated-files.md | 10 +- docs/persistence/overview.md | 6 +- packages/ai-client/src/generation-client.ts | 4 +- packages/ai-client/src/generation-types.ts | 31 ++-- .../ai-client/src/video-generation-client.ts | 5 +- .../tests/generation-resume-state.test.ts | 2 +- .../skills/ai-persistence/SKILL.md | 4 +- .../build-cloudflare-artifact-store/SKILL.md | 16 +-- packages/ai-persistence/src/index.ts | 10 +- packages/ai-persistence/src/memory.ts | 50 +++---- packages/ai-persistence/src/middleware.ts | 47 +++--- .../src/reconstruct-generation.ts | 85 +++++------ packages/ai-persistence/src/types.ts | 109 +++++++------- .../ai-persistence/tests/error-abort.test.ts | 24 ++-- .../tests/generation-artifacts.test.ts | 18 +-- packages/ai-persistence/tests/memory.test.ts | 2 +- .../tests/persistence-fixtures.ts | 24 ++-- .../tests/persistence-types.test-d.ts | 12 +- .../tests/persistence-validation.test.ts | 10 +- .../tests/reconstruct-generation.test.ts | 28 ++-- .../ai-core/client-persistence/SKILL.md | 6 +- .../skills/ai-core/media-generation/SKILL.md | 2 +- .../api.generation-persistence-server.ts | 2 +- 29 files changed, 353 insertions(+), 332 deletions(-) diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index e7c34c1eb..15ec6bae5 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -13,11 +13,11 @@ Add generation persistence, mirroring chat: media generation runs survive a reload or dropped connection, restoring transparently into the normal hook fields, with optional durable storage of the generated bytes. -**Generation job store (server).** `withGenerationPersistence` records each run in a dedicated `jobs` (`GenerationJobStore`) store, keyed by the job's `jobId` (`runId`), with `threadId` only an optional link — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `jobs` store, and `defineGenerationJobStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. +**Generation run store (server).** `withGenerationPersistence` records each run in a dedicated `generationRuns` (`GenerationRunStore`) store, keyed by the run's own `runId` (the same AG-UI run id the client sends), with `threadId` only an optional link — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `generationRuns` store, and `defineGenerationRunStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. -**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?jobId=` (or `?threadId=`) from the request, authorizes it via an optional `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `jobs` store. +**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?runId=` (or `?threadId=`) from the request, authorizes it via an optional `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `generationRuns` store. -**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the job record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. +**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the run record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. **Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take the same `persistence` option chat does: `true` (server-driven — cache nothing, hydrate the last run for a stable `threadId` on mount) or a storage adapter (client-driven — write a lightweight snapshot under `generation:` as the run streams and read it back on mount). Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and keeps `resumeState` for the in-flight run identity — there is no `resumeSnapshot` / `pendingArtifacts` / `resultArtifacts` hook field. If a run is still generating when the connection drops or the page reloads, the client re-attaches to it and finishes it in place (via the connection's `joinRun` durability replay), exactly like `useChat`. 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. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. diff --git a/docs/config.json b/docs/config.json index 516e70215..9e3b38905 100644 --- a/docs/config.json +++ b/docs/config.json @@ -242,7 +242,7 @@ "label": "Overview", "to": "persistence/overview", "addedAt": "2026-07-22", - "updatedAt": "2026-07-28" + "updatedAt": "2026-07-29" }, { "label": "Chat Persistence", @@ -260,25 +260,25 @@ "label": "Generation Persistence", "to": "persistence/generation-persistence", "addedAt": "2026-07-28", - "updatedAt": "2026-07-28" + "updatedAt": "2026-07-29" }, { "label": "Keep Generated Files", "to": "persistence/keep-generated-files", "addedAt": "2026-07-28", - "updatedAt": "2026-07-28" + "updatedAt": "2026-07-29" }, { "label": "Controls", "to": "persistence/controls", "addedAt": "2026-07-22", - "updatedAt": "2026-07-25" + "updatedAt": "2026-07-29" }, { "label": "Build Your Own Adapter", "to": "persistence/build-your-own-adapter", "addedAt": "2026-07-24", - "updatedAt": "2026-07-28" + "updatedAt": "2026-07-29" }, { "label": "Migrations", @@ -290,7 +290,7 @@ "label": "Internals", "to": "persistence/internals", "addedAt": "2026-07-22", - "updatedAt": "2026-07-25" + "updatedAt": "2026-07-29" } ] }, @@ -630,7 +630,7 @@ "updatedAt": "2026-07-08" }, { - "label": "Sampling → modelOptions", + "label": "Sampling \u2192 modelOptions", "to": "migration/sampling-options-to-model-options", "addedAt": "2026-06-03" } diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index f21d46a66..273c68ba1 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -39,20 +39,20 @@ const persistence: ChatTranscriptPersistence = { Each store is independent. Provide only the ones you need. For chat: `messages` for the transcript, `runs` for run lifecycle, `interrupts` for durable approvals (needs `runs`), `metadata` for namespaced key/value state. For generation: -`jobs` for the generation job lifecycle (the counterpart to `runs`, keyed by -`jobId`), plus `artifacts` and `blobs` to keep generated media bytes (see +`generationRuns` for the generation run lifecycle (the counterpart to `runs`, keyed by its own +`runId`), plus `artifacts` and `blobs` to keep generated media bytes (see [Generation & media stores](#generation--media-stores)). The middleware turns on behavior for whatever stores it finds, so a `messages`-only adapter is a valid adapter. -Those seven — `messages`, `runs`, `interrupts`, `metadata`, `jobs`, +Those seven — `messages`, `runs`, `interrupts`, `metadata`, `generationRuns`, `artifacts`, `blobs` — are the *only* keys `stores` accepts; anything else throws `Unknown AIPersistence store key` at construction. Need a mutex across instances? That is `withLocks`; see [Locks](../advanced/locks). Type each store with its `define*Store` helper — `defineMessageStore`, `defineRunStore`, `defineInterruptStore`, `defineMetadataStore`, -`defineGenerationJobStore`, `defineArtifactStore`, `defineBlobStore` — as the +`defineGenerationRunStore`, `defineArtifactStore`, `defineBlobStore` — as the sections below do. Each checks the object against the contract inline (autocomplete, no `: MessageStore` annotation) and composes into `defineAIPersistence`, which @@ -485,18 +485,18 @@ export async function POST(request: Request) { Everything above builds a **chat** adapter. [Media generation](./generation-persistence) persists differently: it does not use the chat `runs` store. Instead -`withGenerationPersistence` requires a `jobs` store — a `GenerationJobStore` -keyed by `jobId` (the run/request id a generation mints), the counterpart to +`withGenerationPersistence` requires a `generationRuns` store — a `GenerationRunStore` +keyed by `runId` (the run/request id a generation mints), the counterpart to `runs`. A generation has no conversation, so `threadId` is only an optional -*link* on the job record. Keeping the generated bytes is an optional add-on on -top of `jobs`: an `artifacts` store (metadata) and a `blobs` store (the bytes), +*link* on the run record. Keeping the generated bytes is an optional add-on on +top of `generationRuns`: an `artifacts` store (metadata) and a `blobs` store (the bytes), which must be provided **together**. These are three more tables alongside the four from the schema in step 1: ```sql -CREATE TABLE IF NOT EXISTS generation_jobs ( - job_id text PRIMARY KEY NOT NULL, +CREATE TABLE IF NOT EXISTS generation_runs ( + run_id text PRIMARY KEY NOT NULL, thread_id text, activity text NOT NULL, provider text NOT NULL, @@ -531,25 +531,25 @@ CREATE TABLE IF NOT EXISTS blobs ( ); ``` -### Jobs: idempotent create, patch, latest-for-thread +### Generation runs: idempotent create, patch, latest-for-thread -`GenerationJobStore` is the generation analogue of `RunStore`. `createOrResume` -is idempotent — a second call for a `jobId` returns the stored record unchanged, -so resuming a job never resets its `startedAt`, `activity`, or status. +`GenerationRunStore` is the generation analogue of `RunStore`. `createOrResume` +is idempotent — a second call for a `runId` returns the stored record unchanged, +so resuming a run never resets its `startedAt`, `activity`, or status. `INSERT ... ON CONFLICT DO NOTHING` gives you that. `update` on an unknown -`jobId` is a no-op. `findLatestForThread` returns the job with the greatest +`runId` is a no-op. `findLatestForThread` returns the run with the greatest `startedAt` linked to a thread — `reconstructGeneration` calls it to hydrate the last generation for a thread on a server-driven client's mount. ```ts import { DatabaseSync } from 'node:sqlite' -import { defineGenerationJobStore } from '@tanstack/ai-persistence' +import { defineGenerationRunStore } from '@tanstack/ai-persistence' import type { - GenerationJobRecord, - GenerationJobStatus, + GenerationRunRecord, + GenerationRunStatus, } from '@tanstack/ai-persistence' -function toJobStatus(value: unknown): GenerationJobStatus { +function toGenerationRunStatus(value: unknown): GenerationRunStatus { switch (value) { case 'running': case 'complete': @@ -557,20 +557,20 @@ function toJobStatus(value: unknown): GenerationJobStatus { case 'interrupted': return value default: - throw new TypeError(`Unexpected job status: ${String(value)}`) + throw new TypeError(`Unexpected generation run status: ${String(value)}`) } } // `node:sqlite` types columns as a SQL-value union, so coerce/narrow each field // (String / Number / typeof) and JSON-parse the text columns — no cast. -function mapJob(row: Record): GenerationJobRecord { +function mapGenerationRun(row: Record): GenerationRunRecord { return { - jobId: String(row.job_id), + runId: String(row.run_id), ...(typeof row.thread_id === 'string' ? { threadId: row.thread_id } : {}), activity: String(row.activity), provider: String(row.provider), model: String(row.model), - status: toJobStatus(row.status), + status: toGenerationRunStatus(row.status), startedAt: Number(row.started_at), ...(row.finished_at != null ? { finishedAt: Number(row.finished_at) } : {}), ...(typeof row.error_json === 'string' @@ -588,25 +588,25 @@ function mapJob(row: Record): GenerationJobRecord { } } -function createGenerationJobStore(db: DatabaseSync) { - const select = db.prepare('SELECT * FROM generation_jobs WHERE job_id = ?') +function createGenerationRunStore(db: DatabaseSync) { + const select = db.prepare('SELECT * FROM generation_runs WHERE run_id = ?') const insert = db.prepare( - `INSERT INTO generation_jobs - (job_id, thread_id, activity, provider, model, status, started_at) + `INSERT INTO generation_runs + (run_id, thread_id, activity, provider, model, status, started_at) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(job_id) DO NOTHING`, + ON CONFLICT(run_id) DO NOTHING`, ) const latest = db.prepare( - `SELECT * FROM generation_jobs WHERE thread_id = ? + `SELECT * FROM generation_runs WHERE thread_id = ? ORDER BY started_at DESC LIMIT 1`, ) - return defineGenerationJobStore({ + return defineGenerationRunStore({ async createOrResume(input) { - const existing = select.get(input.jobId) - if (existing) return mapJob(existing) - const status: GenerationJobStatus = input.status ?? 'running' + const existing = select.get(input.runId) + if (existing) return mapGenerationRun(existing) + const status: GenerationRunStatus = input.status ?? 'running' insert.run( - input.jobId, + input.runId, input.threadId ?? null, input.activity, input.provider, @@ -615,7 +615,7 @@ function createGenerationJobStore(db: DatabaseSync) { input.startedAt, ) return { - jobId: input.jobId, + runId: input.runId, activity: input.activity, provider: input.provider, model: input.model, @@ -624,7 +624,7 @@ function createGenerationJobStore(db: DatabaseSync) { ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), } }, - async update(jobId, patch) { + async update(runId, patch) { const sets: Array = [] const params: Array = [] if (patch.status !== undefined) { @@ -651,23 +651,23 @@ function createGenerationJobStore(db: DatabaseSync) { sets.push('usage_json = ?') params.push(JSON.stringify(patch.usage)) } - // Empty patch, or an unknown job id, touches nothing (UPDATE no-ops). + // Empty patch, or an unknown run id, touches nothing (UPDATE no-ops). if (sets.length === 0) return - params.push(jobId) + params.push(runId) db.prepare( - `UPDATE generation_jobs SET ${sets.join(', ')} WHERE job_id = ?`, + `UPDATE generation_runs SET ${sets.join(', ')} WHERE run_id = ?`, ).run(...params) }, - async get(jobId) { - const row = select.get(jobId) - return row ? mapJob(row) : null + async get(runId) { + const row = select.get(runId) + return row ? mapGenerationRun(row) : null }, - // The most recent job linked to a thread. `reconstructGeneration` calls this + // The most recent run linked to a thread. `reconstructGeneration` calls this // so a server-driven client (`persistence: true`) hydrates the last - // generation for its thread by the stable thread id, without a job id. + // generation for its thread by the stable thread id, without a run id. async findLatestForThread(threadId) { const row = latest.get(threadId) - return row ? mapJob(row) : null + return row ? mapGenerationRun(row) : null }, }) } @@ -925,8 +925,8 @@ function createBlobStore(db: DatabaseSync) { ### Assemble a generation adapter -Hand the three stores to `defineAIPersistence` the same way. `jobs` alone is a -valid generation adapter (job records, no byte storage); add `artifacts` + +Hand the three stores to `defineAIPersistence` the same way. `generationRuns` alone is a +valid generation adapter (run records, no byte storage); add `artifacts` + `blobs` — together — to keep the media: ```ts @@ -935,7 +935,7 @@ import { defineAIPersistence } from '@tanstack/ai-persistence' // The three generation store factories and the schema string, from your modules. import { createArtifactStore } from './artifact-store' import { createBlobStore } from './blob-store' -import { createGenerationJobStore } from './generation-job-store' +import { createGenerationRunStore } from './generation-run-store' import { GENERATION_SCHEMA_SQL } from './generation-schema' export function generationPersistence(options: { @@ -946,7 +946,7 @@ export function generationPersistence(options: { if (options.migrate) db.exec(GENERATION_SCHEMA_SQL) return defineAIPersistence({ stores: { - jobs: createGenerationJobStore(db), + generationRuns: createGenerationRunStore(db), artifacts: createArtifactStore(db), blobs: createBlobStore(db), }, @@ -1192,63 +1192,63 @@ composite identity. A stored `null` is indistinguishable from absence at the typ level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or reject nullish values outright the way the SQLite store above does. -### GenerationJobStore +### GenerationRunStore -The generation counterpart to `RunStore`, keyed by `jobId` rather than a +The generation counterpart to `RunStore`, keyed by `runId` rather than a conversation `threadId`. `withGenerationPersistence` requires this store, not `runs`. ```ts import type { PersistedArtifactRef, TokenUsage } from '@tanstack/ai' -type GenerationJobStatus = 'running' | 'complete' | 'error' | 'interrupted' +type GenerationRunStatus = 'running' | 'complete' | 'error' | 'interrupted' -interface GenerationJobRecord { - jobId: string +interface GenerationRunRecord { + runId: string threadId?: string // optional link to a chat conversation activity: string // 'image' | 'audio' | 'tts' | 'video' | 'transcription' provider: string model: string - status: GenerationJobStatus + status: GenerationRunStatus startedAt: number // epoch ms - finishedAt?: number // epoch ms, set once the job reaches a terminal status + finishedAt?: number // epoch ms, set once the run reaches a terminal status error?: { message: string; code?: string } result?: unknown // terminal result metadata (ids, urls) — never media bytes artifacts?: Array // present with an artifacts + blobs backend usage?: TokenUsage } -interface GenerationJobStore { +interface GenerationRunStore { createOrResume(input: { - jobId: string + runId: string activity: string provider: string model: string startedAt: number threadId?: string - status?: GenerationJobStatus - }): Promise + status?: GenerationRunStatus + }): Promise update( - jobId: string, + runId: string, patch: Partial< Pick< - GenerationJobRecord, + GenerationRunRecord, 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' > >, ): Promise - get(jobId: string): Promise - // The most recent job linked to a thread (greatest `startedAt`), or null. + get(runId: string): Promise + // The most recent run linked to a thread (greatest `startedAt`), or null. // Optional, but implement it: `reconstructGeneration` feature-detects it to // hydrate the last generation for a thread by its stable thread id. - findLatestForThread?(threadId: string): Promise + findLatestForThread?(threadId: string): Promise } ``` -Implement `createOrResume` idempotently: a second call for an existing `jobId` +Implement `createOrResume` idempotently: a second call for an existing `runId` returns the stored record unchanged (`startedAt` / `activity` / `provider` / -`model` / `threadId` are not mutated), which is what makes resuming a job safe. -`update` against an unknown `jobId` is a no-op. +`model` / `threadId` are not mutated), which is what makes resuming a run safe. +`update` against an unknown `runId` is a no-op. ### ArtifactStore diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md index 0e2cddeb0..5c70734f4 100644 --- a/docs/persistence/controls.md +++ b/docs/persistence/controls.md @@ -89,7 +89,7 @@ values arrive from untyped JavaScript. - `withPersistence` requires `messages`. - `interrupts` requires `runs`: an interrupt record is scoped to a run. -- `withGenerationPersistence` requires `jobs`. +- `withGenerationPersistence` requires `generationRuns`. To define a partial backend directly rather than by composing, use `defineAIPersistence({ stores: { ... } })` and pass only the stores you have. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 694cbdc53..45f4eb7d6 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -27,8 +27,8 @@ Generation persistence mirrors chat's `persistence` option: empty. Either way the server keeps a **generation job** record: -`withGenerationPersistence(persistence)` needs a `jobs` store (a -`GenerationJobStore` keyed by the run's `jobId`; a generation has no conversation +`withGenerationPersistence(persistence)` needs a `generationRuns` store (a +`GenerationRunStore` keyed by the run's own `runId`; a generation has no conversation of its own, so `threadId` is only an optional link). `memoryPersistence()` ships one out of the box; see [Build your own adapter](./build-your-own-adapter#generation--media-stores) for @@ -71,7 +71,7 @@ export async function POST(request: Request) { const stream = generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt, - // Link the job to the thread so the GET can find the last run for it. + // Link the run to the thread so the GET can find the last run for it. ...(threadId !== undefined ? { threadId } : {}), stream: true, // `artifactUrl` makes the restored media render from your own origin. It is diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md index 17b0155ce..897efd15d 100644 --- a/docs/persistence/internals.md +++ b/docs/persistence/internals.md @@ -51,15 +51,15 @@ stored transcript is loaded and used. ## Generation middleware lifecycle `withGenerationPersistence(persistence)` records the job: `onStart` creates or -resumes the job record, `onFinish`, `onError`, and `onAbort` terminalize it, +resumes the run record, `onFinish`, `onError`, and `onAbort` terminalize it, and a result transform captures the terminal result metadata (ids, urls — never media bytes) onto the record. When `artifacts` and `blobs` are both provided it also persists the generated media and merges the durable refs onto both the -result and the job record. +result and the run record. -Generation uses its own `jobs` store (`GenerationJobStore`), never chat's -`runs` / `messages`. A generation has no conversation, so the job is keyed on -its own `jobId` (`ctx.runId ?? ctx.requestId`) and `threadId` is **optional** — +Generation uses its own `generationRuns` store (`GenerationRunStore`), never chat's +`runs` / `messages`. A generation has no conversation, so the run is keyed on +its own `runId` (`ctx.runId ?? ctx.requestId`) and `threadId` is **optional** — carried through only when the caller supplies one, as a link to a chat or as the stable hydration key a server-driven client reloads by. It is never faked from the request id, and `threadId` never becomes the job's primary identity. @@ -96,9 +96,9 @@ removed. Unknown store keys are rejected statically and by runtime validation. Middleware adds entrypoint validation: - chat requires `messages`; rejects `interrupts` without `runs`. -- generation requires `jobs`. +- generation requires `generationRuns`. - `reconstructChat` requires `messages`. -- `reconstructGeneration` requires `jobs`. +- `reconstructGeneration` requires `generationRuns`. The runtime checks are required because JavaScript, configuration loading, and explicitly widened types can bypass static guarantees. diff --git a/docs/persistence/keep-generated-files.md b/docs/persistence/keep-generated-files.md index 3da0ac1c8..8554bf79a 100644 --- a/docs/persistence/keep-generated-files.md +++ b/docs/persistence/keep-generated-files.md @@ -11,14 +11,14 @@ once it does the output is gone. To keep the output, save the generated bytes to your own storage and serve them from your own origin, where they outlive the provider's link. -This is a server-side opt-in that layers on **top of** the `jobs` store +This is a server-side opt-in that layers on **top of** the `generationRuns` store [Generation persistence](./generation-persistence) already requires. Byte storage adds two more stores: an `artifacts` store (metadata) and a `blobs` store (the bytes). They are a both-or-neither pair — provide both and `withGenerationPersistence` writes each generated file's bytes to the blob store, records an `ArtifactRecord`, and attaches durable references to the result and the -job record; provide neither and only the job record is kept. -`memoryPersistence()` ships all three stores (`jobs`, `artifacts`, `blobs`), so it +run record; provide neither and only the run record is kept. +`memoryPersistence()` ships all three stores (`generationRuns`, `artifacts`, `blobs`), so it works out of the box; any backend that implements `ArtifactStore` and `BlobStore` (see [Build your own adapter](./build-your-own-adapter#generation--media-stores)) works the same way. @@ -93,7 +93,7 @@ export async function GET(request: Request) { ``` `memoryPersistence` keeps everything in process memory, which is right for -development and tests; point `jobs` / `artifacts` / `blobs` at a durable backend +development and tests; point `generationRuns` / `artifacts` / `blobs` at a durable backend for production. Control what gets captured with `withGenerationPersistence`'s `extractArtifacts` (return your own descriptors) and `nameArtifact` (name each file) options. @@ -119,7 +119,7 @@ are no separate top-level artifact fields on the hook, final refs live on ## Where to go next - [Generation persistence](./generation-persistence): the two-mode record that - survives a reload or a dropped connection, and the `jobs` store byte storage + survives a reload or a dropped connection, and the `generationRuns` store byte storage builds on. - [Build your own adapter](./build-your-own-adapter#generation--media-stores): a custom `ArtifactStore` / `BlobStore` on your own database. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index cf2ef5f50..5e29ace49 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -281,7 +281,7 @@ transcription — persists as a sibling to it: the generation hooks (`useGenerateImage`, `useGenerateVideo`, …) take the same `persistence: true | adapter` modes, so a long run's status and result survive a reload or a dropped connection the same way a conversation does. On the server it is backed by a -`jobs` store (the generation counterpart to chat's `runs`), with optional +`generationRuns` store (the generation counterpart to chat's `runs`), with optional `artifacts` + `blobs` stores to keep the generated bytes after the provider's URLs expire. @@ -301,7 +301,7 @@ from whichever stores are present (with entrypoint requirements — see | `runs` | Run status, timing, errors, and usage. Required on full `ChatPersistence`. | | `interrupts` | Pending, resolved, or cancelled human/tool waits (needs `runs`). | | `metadata` | App and integration key/value state. | -| `jobs` | Generation run status, result metadata, and artifact refs, keyed by `jobId`. Required by generation persistence. | +| `generationRuns` | Generation run status, result metadata, and artifact refs, keyed by the run's own `runId`. Required by generation persistence. | | `artifacts` | Generated-file metadata (needs `blobs`). | | `blobs` | The generated bytes (needs `artifacts`). | @@ -329,7 +329,7 @@ matching your database loads itself. The full skill list is in - [Chat persistence](./chat-persistence): the server middleware, the authoritative-history contract, and durable interrupts. - [Client persistence](./client-persistence): client- vs server-authoritative modes (`persistence: true`), reload restore, storage backends, and mid-stream rejoin. -- [Generation persistence](./generation-persistence): the same modes for media runs (image, audio, TTS, video, transcription), backed by a `jobs` store. +- [Generation persistence](./generation-persistence): the same modes for media runs (image, audio, TTS, video, transcription), backed by a `generationRuns` store. - [Keep generated files](./keep-generated-files): save the generated bytes to your own storage so they outlive the provider's expiring URLs. - [Controls](./controls): compose backends per store and choose which stores to run. - [Build your own adapter](./build-your-own-adapter): a complete SQLite example on the core, plus the store interface reference. diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index b45a9db34..824dd5ec6 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -670,7 +670,9 @@ export class GenerationClient< ...(result?.id !== undefined ? { id: result.id } : {}), ...(result?.model !== undefined ? { model: result.model } : {}), ...(result?.status !== undefined ? { status: result.status } : {}), - ...(result?.jobId !== undefined ? { jobId: result.jobId } : {}), + ...(result?.providerJobId !== undefined + ? { providerJobId: result.providerJobId } + : {}), ...(result?.expiresAt !== undefined ? { expiresAt: result.expiresAt } : {}), diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index f642e4879..2954613f4 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -103,7 +103,12 @@ export interface GenerationResultSnapshot { id?: string model?: string status?: string - jobId?: string + /** + * The provider's async job handle (e.g. a Veo/fal video job id used for + * status polling) — NOT the generation's own `runId`, which lives on + * {@link GenerationResumeState.runId}. + */ + providerJobId?: string expiresAt?: string /** * The text output of a text activity (a transcription's `text` or a summary's @@ -346,7 +351,8 @@ export interface GenerationRestoredResult { id?: string model?: string status?: string - jobId?: string + /** The provider's async job handle — see {@link GenerationResultSnapshot.providerJobId}. */ + providerJobId?: string expiresAt?: string text?: string usage?: TokenUsage @@ -406,14 +412,14 @@ export function updateGenerationResumeSnapshot( } } } else if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { - // Capture the job id as soon as the job exists — for a long video run - // this is the one piece of identity worth having after a reload, and - // the terminal `generation:result` may never arrive. - const jobId = isObject(chunk.value) + // Capture the provider job id as soon as the job exists — for a long + // video run this is the one piece of identity worth having after a + // reload, and the terminal `generation:result` may never arrive. + const providerJobId = isObject(chunk.value) ? stringField(chunk.value, 'jobId') : undefined - if (jobId) { - next.result = { ...next.result, jobId } + if (providerJobId) { + next.result = { ...next.result, providerJobId } } } } else if (chunk.type === 'RUN_FINISHED') { @@ -693,7 +699,12 @@ export function createGenerationResultSnapshot( const id = stringField(value, 'id') const model = stringField(value, 'model') const status = stringField(value, 'status') - const jobId = stringField(value, 'jobId') + // A live provider result carries its job handle as `jobId` (e.g. + // `VideoGenerateResult.jobId`); a persisted snapshot carries it as + // `providerJobId`. Accept both — this narrows raw results AND stored + // snapshots. + const providerJobId = + stringField(value, 'providerJobId') ?? stringField(value, 'jobId') // A transcription's output is `text`; a summary's is `summary`. Capture either // under `text` so a text result restores on reload. const text = stringField(value, 'text') ?? stringField(value, 'summary') @@ -701,7 +712,7 @@ export function createGenerationResultSnapshot( if (id) snapshot.id = id if (model) snapshot.model = model if (status) snapshot.status = status - if (jobId) snapshot.jobId = jobId + if (providerJobId) snapshot.providerJobId = providerJobId if (text) snapshot.text = text // Passthrough opaque token-usage metadata (untrusted; not deeply validated). if (isObject(usage)) snapshot.usage = usage as TokenUsage diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index af7aa08e4..2c6e3e5c5 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -722,7 +722,8 @@ export class VideoGenerationClient { ) : undefined, ) - if (snapshot.result?.jobId) this.setJobId(snapshot.result.jobId) + if (snapshot.result?.providerJobId) + this.setJobId(snapshot.result.providerJobId) const restored = this.reconstructVideoResult(snapshot) if (restored !== null) this.setResult(restored) } @@ -743,7 +744,7 @@ export class VideoGenerationClient { ) if (!output?.url) return null return { - jobId: result?.jobId ?? '', + jobId: result?.providerJobId ?? '', status: 'completed', url: output.url, ...(result?.expiresAt ? { expiresAt: new Date(result.expiresAt) } : {}), diff --git a/packages/ai-client/tests/generation-resume-state.test.ts b/packages/ai-client/tests/generation-resume-state.test.ts index ab8408d74..bc71488e8 100644 --- a/packages/ai-client/tests/generation-resume-state.test.ts +++ b/packages/ai-client/tests/generation-resume-state.test.ts @@ -262,7 +262,7 @@ describe('generation resume state reducer', () => { ]) expect(snapshot.status).toBe('running') - expect(snapshot.result?.jobId).toBe('job-42') + expect(snapshot.result?.providerJobId).toBe('job-42') }) it('merges an initial seed and lets later run events update it', () => { diff --git a/packages/ai-persistence/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md index 28c94f48e..86199d772 100644 --- a/packages/ai-persistence/skills/ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -45,7 +45,7 @@ the result to `withPersistence`. The core never inspects your tables. | Ships in the package | What it is | | --------------------------------------------------------------------------- | ----------------------------------------------------------- | | `MessageStore` / `RunStore` / `InterruptStore` / `MetadataStore` | The four **chat** state contracts | -| `GenerationJobStore` / `ArtifactStore` / `BlobStore` | The **generation** contracts (job lifecycle + bytes) | +| `GenerationRunStore` / `ArtifactStore` / `BlobStore` | The **generation** contracts (job lifecycle + bytes) | | `withPersistence` / `withGenerationPersistence` | Chat + generation middleware | | `memoryPersistence()` | In-process reference backend, all seven stores (dev, tests) | | `reconstructChat` / `reconstructGeneration` | Server hydrate route helpers (chat / generation) | @@ -55,7 +55,7 @@ the result to `withPersistence`. The core never inspects your tables. **Chat vs generation stores.** Chat persistence keys on `threadId` and uses `messages` + optional `runs` / `interrupts` / `metadata`. Generation persistence -keys on `jobId` and uses `jobs` (required by `withGenerationPersistence`) plus an +keys on its own `runId` and uses `generationRuns` (required by `withGenerationPersistence`) plus an optional `artifacts` + `blobs` **pair** — provide both or neither — to store the generated media bytes at blob key `artifacts//`. `threadId` on a generation is only an optional _link_ to a chat, never the job's identity. To diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index bc17818a2..99e564a92 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -5,7 +5,7 @@ description: Use when a Cloudflare Worker needs durable byte storage for TanStac # Cloudflare Artifact + Blob Store -`withGenerationPersistence(persistence)` needs only `stores.jobs` to track a +`withGenerationPersistence(persistence)` needs only `stores.generationRuns` to track a generation's lifecycle. Add `stores.artifacts` (metadata) **and** `stores.blobs` (the bytes) — both, or neither — and the middleware also persists the generated media: image/audio/TTS/video/transcription bytes land at blob key @@ -18,7 +18,7 @@ artifact bytes with `retrieveArtifact` / `retrieveBlob`. Read the sibling **ai-persistence/build-cloudflare-adapter** skill for the per-request-binding rule, `wrangler` config shape, D1 migration workflow, and the -chat (jobs/messages) side. This skill covers only the two byte-storage stores and +chat (generation-run/message) side. This skill covers only the two byte-storage stores and how to compose them. ## The two contracts, verbatim @@ -268,7 +268,7 @@ secondary index. ## 3. Compose and wire Bindings are per-request on Workers, so export a **factory**. Combine the byte -stores with a jobs store (and, if this Worker also does chat, the chat stores). +stores with a generation-run store (and, if this Worker also does chat, the chat stores). Either build the whole `AIPersistence` with `defineAIPersistence`, or layer the artifact stores onto an existing chat persistence with `composePersistence`: @@ -280,20 +280,20 @@ import { } from '@tanstack/ai-persistence' import { r2BlobStore } from './r2-blob-store' import { d1ArtifactStore } from './d1-artifact-store' -import { d1GenerationJobStore } from './d1-job-store' // your GenerationJobStore +import { d1GenerationRunStore } from './d1-job-store' // your GenerationRunStore /** Call inside a request handler — bindings are not available at module scope. */ export function generationPersistence(env: Env) { return defineAIPersistence({ stores: { - jobs: d1GenerationJobStore(env.DB), + generationRuns: d1GenerationRunStore(env.DB), artifacts: d1ArtifactStore(env.DB), blobs: r2BlobStore(env.ARTIFACTS_BUCKET), }, }) } -// …or add bytes to a persistence that already has chat + jobs: +// …or add bytes to a persistence that already has chat + generation runs: // composePersistence(chatAndJobsPersistence(env), { // overrides: { // artifacts: d1ArtifactStore(env.DB), @@ -361,7 +361,7 @@ export async function GET(request: Request, env: Env) { To hydrate a **server-driven generation client** (`persistence: true` + a stable `threadId`) on mount, also expose `reconstructGeneration(persistence, request)` -on a GET that reads `?threadId=` / `?jobId=` — see `ai-core/client-persistence`. +on a GET that reads `?threadId=` / `?runId=` — see `ai-core/client-persistence`. ## Other backends — the contract is tiny, here's how each maps @@ -386,7 +386,7 @@ For each: `contentType` and `customMetadata` ride the SDK's own metadata fields; The shared `runPersistenceConformance` testkit currently covers the four **state** stores only (`messages`, `runs`, `interrupts`, `metadata`) — it does **not** -exercise `blobs`, `artifacts`, or `jobs`. So the byte stores need their **own** +exercise `blobs`, `artifacts`, or `generationRuns`. So the byte stores need their **own** tests. Run them against a Miniflare R2 + D1 binding with the migration applied, reset between runs (see **ai-persistence/build-cloudflare-adapter** for the `cloudflare:test` harness pattern). Assert against the reference in-memory stores diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index 41964440d..5d1eead13 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -6,7 +6,7 @@ export { defineRunStore, defineInterruptStore, defineMetadataStore, - defineGenerationJobStore, + defineGenerationRunStore, defineArtifactStore, defineBlobStore, } from './types' @@ -26,10 +26,10 @@ export type { ChatTranscriptPersistence, ChatPersistence, ChatWithInterruptsPersistence, - // Generation job store contract - GenerationJobStatus, - GenerationJobRecord, - GenerationJobStore, + // Generation run store contract + GenerationRunStatus, + GenerationRunRecord, + GenerationRunStore, // Generation artifact + blob store contracts ArtifactRecord, ArtifactStore, diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts index 905ba29d1..47b227dd2 100644 --- a/packages/ai-persistence/src/memory.ts +++ b/packages/ai-persistence/src/memory.ts @@ -8,8 +8,8 @@ import type { BlobObject, BlobRecord, BlobStore, - GenerationJobRecord, - GenerationJobStore, + GenerationRunRecord, + GenerationRunStore, InterruptRecord, InterruptStore, MessageStore, @@ -69,18 +69,18 @@ class MemoryRunStore implements RunStore { } } -class MemoryGenerationJobStore implements GenerationJobStore { - private readonly jobs = new Map() +class MemoryGenerationRunStore implements GenerationRunStore { + private readonly generationRuns = new Map() createOrResume( input: Pick< - GenerationJobRecord, - 'jobId' | 'activity' | 'provider' | 'model' | 'startedAt' - > & { threadId?: string; status?: GenerationJobRecord['status'] }, - ): Promise { - const existing = this.jobs.get(input.jobId) + GenerationRunRecord, + 'runId' | 'activity' | 'provider' | 'model' | 'startedAt' + > & { threadId?: string; status?: GenerationRunRecord['status'] }, + ): Promise { + const existing = this.generationRuns.get(input.runId) if (existing) return Promise.resolve(existing) - const record: GenerationJobRecord = { - jobId: input.jobId, + const record: GenerationRunRecord = { + runId: input.runId, activity: input.activity, provider: input.provider, model: input.model, @@ -88,28 +88,28 @@ class MemoryGenerationJobStore implements GenerationJobStore { startedAt: input.startedAt, ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), } - this.jobs.set(record.jobId, record) + this.generationRuns.set(record.runId, record) return Promise.resolve(record) } update( - jobId: string, + runId: string, patch: Partial< Pick< - GenerationJobRecord, + GenerationRunRecord, 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' > >, ): Promise { - const existing = this.jobs.get(jobId) - if (existing) this.jobs.set(jobId, { ...existing, ...patch }) + const existing = this.generationRuns.get(runId) + if (existing) this.generationRuns.set(runId, { ...existing, ...patch }) return Promise.resolve() } - get(jobId: string): Promise { - return Promise.resolve(this.jobs.get(jobId) ?? null) + get(runId: string): Promise { + return Promise.resolve(this.generationRuns.get(runId) ?? null) } - findLatestForThread(threadId: string): Promise { - const linked = [...this.jobs.values()] - .filter((job) => job.threadId === threadId) + findLatestForThread(threadId: string): Promise { + const linked = [...this.generationRuns.values()] + .filter((run) => run.threadId === threadId) .sort((a, b) => b.startedAt - a.startedAt) return Promise.resolve(linked[0] ?? null) } @@ -420,7 +420,7 @@ class MemoryBlobStore implements BlobStore { interface MemoryPersistenceStores { messages: MessageStore runs: RunStore - jobs: GenerationJobStore + generationRuns: GenerationRunStore interrupts: InterruptStore metadata: MetadataStore artifacts: ArtifactStore @@ -430,15 +430,15 @@ interface MemoryPersistenceStores { /** * In-process reference backend for the full state + generation store set. * - * Returns messages + runs + jobs + interrupts + metadata + artifacts + blobs. - * Locks are not included — use `InMemoryLockStore` + `withLocks` from + * Returns messages + runs + generationRuns + interrupts + metadata + artifacts + * + blobs. Locks are not included — use `InMemoryLockStore` + `withLocks` from * `@tanstack/ai` when a test or single-process app needs coordination. */ export function memoryPersistence() { const stores: MemoryPersistenceStores = { messages: new MemoryMessageStore(), runs: new MemoryRunStore(), - jobs: new MemoryGenerationJobStore(), + generationRuns: new MemoryGenerationRunStore(), interrupts: new MemoryInterruptStore(), metadata: new MemoryMetadataStore(), artifacts: new MemoryArtifactStore(), diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index c1843f512..bf4fa9240 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -880,11 +880,12 @@ type InvalidChatPersistence = : false /** - * Generation entrypoint invalid when `jobs` is known-absent, or when exactly one - * of `artifacts` / `blobs` is present (artifact persistence needs both). + * Generation entrypoint invalid when `generationRuns` is known-absent, or when + * exactly one of `artifacts` / `blobs` is present (artifact persistence needs + * both). */ type InvalidGenerationPersistence = - StoreIsDefinitelyAbsent extends true + StoreIsDefinitelyAbsent extends true ? true : StoreIsDefinitelyPresent extends true ? StoreIsDefinitelyAbsent @@ -1200,20 +1201,20 @@ export function withPersistence( // --------------------------------------------------------------------------- /** - * Generation-only persistence middleware. Tracks generation job status (job - * records keyed by `jobId`) and, when `stores.artifacts` + `stores.blobs` are + * Generation-only persistence middleware. Tracks generation run status (run + * records keyed by `runId`) and, when `stores.artifacts` + `stores.blobs` are * both provided, persists the generated media for image, audio, TTS, video, and * transcription activities. * - * Requires `stores.jobs`. A generation activity has no conversation, so the job - * is keyed on its own `jobId` (`ctx.runId ?? ctx.requestId`). `ctx.threadId` is - * carried through only as an OPTIONAL *link* to a chat when the caller supplies - * one — it is never the job's primary identity and is never faked from the - * request id. + * Requires `stores.generationRuns`. A generation activity has no conversation, + * so the run is keyed on its own `runId` (`ctx.runId ?? ctx.requestId`). + * `ctx.threadId` is carried through only as an OPTIONAL *link* to a chat when + * the caller supplies one — it is never the run's primary identity and is never + * faked from the request id. * * On success the terminal result metadata (ids, urls — never media bytes) and, * when artifact persistence is on, the persisted artifact refs are captured onto - * the job record so a server-authoritative client can hydrate the last + * the run record so a server-authoritative client can hydrate the last * generation for a thread via {@link reconstructGeneration}. */ export function withGenerationPersistence( @@ -1226,23 +1227,23 @@ export function withGenerationPersistence( ): GenerationMiddleware { validateGenerationPersistenceStores(persistence) const { wantsArtifactPersistence } = resolvePersistencePlan(persistence) - const jobs = persistence.stores.jobs - if (!jobs) { + const generationRuns = persistence.stores.generationRuns + if (!generationRuns) { // validateGenerationPersistenceStores already throws; this narrows for TypeScript. - throw new Error('Generation persistence requires stores.jobs.') + throw new Error('Generation persistence requires stores.generationRuns.') } - const jobIdOf = (ctx: GenerationMiddlewareContext): string => + const runIdOf = (ctx: GenerationMiddlewareContext): string => ctx.runId ?? ctx.requestId return { name: 'generation-persistence', async onStart(ctx: GenerationMiddlewareContext) { - const jobId = jobIdOf(ctx) + const runId = runIdOf(ctx) const threadId = ctx.threadId - await jobs.createOrResume({ - jobId, + await generationRuns.createOrResume({ + runId, activity: ctx.activity, provider: ctx.provider, model: ctx.model, @@ -1274,7 +1275,7 @@ export function withGenerationPersistence( } // Always capture the terminal result metadata + any artifact refs onto the - // job record. Registered AFTER the artifact transform so it observes the + // run record. Registered AFTER the artifact transform so it observes the // fully-merged result (with the artifact refs attached). `result` is // metadata/urls only — the media bytes already live in the blob store. ctx.resultTransforms?.push(async (result) => { @@ -1282,7 +1283,7 @@ export function withGenerationPersistence( const artifacts = Array.isArray(rawArtifacts) ? rawArtifacts.filter(isArtifactRef) : [] - await jobs.update(jobId, { + await generationRuns.update(runId, { result, ...(artifacts.length > 0 ? { artifacts } : {}), }) @@ -1294,7 +1295,7 @@ export function withGenerationPersistence( ctx: GenerationMiddlewareContext, info: GenerationFinishInfo, ) { - await jobs.update(jobIdOf(ctx), { + await generationRuns.update(runIdOf(ctx), { status: 'complete', finishedAt: Date.now(), ...(info.usage ? { usage: info.usage } : {}), @@ -1302,7 +1303,7 @@ export function withGenerationPersistence( }, async onError(ctx: GenerationMiddlewareContext, info: GenerationErrorInfo) { - await jobs.update(jobIdOf(ctx), { + await generationRuns.update(runIdOf(ctx), { status: 'error', finishedAt: Date.now(), error: { @@ -1318,7 +1319,7 @@ export function withGenerationPersistence( ctx: GenerationMiddlewareContext, _info: GenerationAbortInfo, ) { - await jobs.update(jobIdOf(ctx), { + await generationRuns.update(runIdOf(ctx), { status: 'interrupted', finishedAt: Date.now(), }) diff --git a/packages/ai-persistence/src/reconstruct-generation.ts b/packages/ai-persistence/src/reconstruct-generation.ts index 2c49d4215..e96fce2c0 100644 --- a/packages/ai-persistence/src/reconstruct-generation.ts +++ b/packages/ai-persistence/src/reconstruct-generation.ts @@ -1,17 +1,17 @@ import { validateReconstructGenerationStores } from './types' -import type { AIPersistence, GenerationJobRecord } from './types' +import type { AIPersistence, GenerationRunRecord } from './types' /** * The JSON body `reconstructGeneration` returns and a server-authoritative * client hydrates from on mount. * - * `resumeSnapshot` mirrors the last generation job for the requested thread (or - * a specific job id): its terminal/running `status`, the `result` metadata and + * `resumeSnapshot` mirrors the last generation run for the requested thread (or + * a specific run id): its terminal/running `status`, the `result` metadata and * `error` it recorded, the `activity` it ran, and a `resumeState` cursor - * (present only while the job is still `running`) the client can use to tail the - * live generation. `null` when there is no matching job. + * (present only while the run is still `running`) the client can use to tail the + * live generation. `null` when there is no matching run. * - * `activeRun` is `{ runId }` when the resolved job is still `running`, else + * `activeRun` is `{ runId }` when the resolved run is still `running`, else * `null` — the parallel of {@link ReconstructedChat.activeRun}. */ export interface ReconstructedGeneration { @@ -29,17 +29,17 @@ export interface ReconstructedGeneration { export interface ReconstructGenerationOptions { /** Query parameter carrying the thread id. Defaults to `threadId`. */ param?: string - /** Query parameter carrying the job id. Defaults to `jobId`. */ - jobParam?: string + /** Query parameter carrying the run id. Defaults to `runId`. */ + runParam?: string /** * Authorize access to the requested generation before loading it. * - * ⚠️ Without this, any caller who knows or guesses `?threadId=` / `?jobId=` + * ⚠️ Without this, any caller who knows or guesses `?threadId=` / `?runId=` * receives the generation's status and result metadata. Multi-user / * multi-tenant deployments **must** supply an authorization check (session → - * owned thread/job) or resolve a validated id in the route. + * owned thread/run) or resolve a validated id in the route. * - * Called with whichever id was supplied — the `jobId` when present, else the + * Called with whichever id was supplied — the `runId` when present, else the * `threadId`. Return: * - `true` to allow the load * - `false` for a default `403` response @@ -52,12 +52,12 @@ export interface ReconstructGenerationOptions { } /** - * Map the persisted job status to the client-facing resume-snapshot status. - * An `interrupted` job surfaces as `error` — the client has no live run to + * Map the persisted run status to the client-facing resume-snapshot status. + * An `interrupted` run surfaces as `error` — the client has no live run to * resume, and an interrupted generation produced no usable result. */ function snapshotStatus( - status: GenerationJobRecord['status'], + status: GenerationRunRecord['status'], ): 'running' | 'complete' | 'error' { switch (status) { case 'running': @@ -70,23 +70,23 @@ function snapshotStatus( } } -function jobToSnapshot( - job: GenerationJobRecord, +function runToSnapshot( + run: GenerationRunRecord, ): NonNullable { - const status = snapshotStatus(job.status) + const status = snapshotStatus(run.status) return { schemaVersion: 1, resumeState: status === 'running' ? { - runId: job.jobId, - ...(job.threadId !== undefined ? { threadId: job.threadId } : {}), + runId: run.runId, + ...(run.threadId !== undefined ? { threadId: run.threadId } : {}), } : null, status, - ...(job.result !== undefined ? { result: job.result } : {}), - ...(job.error !== undefined ? { error: job.error } : {}), - ...(job.activity !== undefined ? { activity: job.activity } : {}), + ...(run.result !== undefined ? { result: run.result } : {}), + ...(run.error !== undefined ? { error: run.error } : {}), + ...(run.activity !== undefined ? { activity: run.activity } : {}), } } @@ -101,19 +101,20 @@ function jsonResponse(body: ReconstructedGeneration): Response { /** * Build the JSON `Response` a server-authoritative client hydrates a generation - * from on load. Reads a `?jobId=` (preferred) or `?threadId=` from the request + * from on load. Reads a `?runId=` (preferred) or `?threadId=` from the request * query and returns `{ resumeSnapshot, activeRun }` * ({@link ReconstructedGeneration}): * - * - Resolves the job by `jobId` via `stores.jobs.get`, else the latest job - * linked to `threadId` via the optional `stores.jobs.findLatestForThread`. - * - `resumeSnapshot` — the job mapped to a client snapshot (status, result, + * - Resolves the run by `runId` via `stores.generationRuns.get`, else the + * latest run linked to `threadId` via the optional + * `stores.generationRuns.findLatestForThread`. + * - `resumeSnapshot` — the run mapped to a client snapshot (status, result, * error, activity, and a `resumeState` cursor while still running), or `null`. - * - `activeRun` — `{ runId }` when the job is still generating, else `null`. + * - `activeRun` — `{ runId }` when the run is still generating, else `null`. * - * Requires `stores.jobs`. Returns `{ resumeSnapshot: null, activeRun: null }` - * when no id is supplied or no matching job exists, so the caller never has to - * special-case a first load. + * Requires `stores.generationRuns`. Returns + * `{ resumeSnapshot: null, activeRun: null }` when no id is supplied or no + * matching run exists, so the caller never has to special-case a first load. * * This helper does **not** enforce tenancy by itself. Pass * {@link ReconstructGenerationOptions.authorize} (or wrap the call in your own @@ -136,19 +137,19 @@ export async function reconstructGeneration( options?: ReconstructGenerationOptions, ): Promise { validateReconstructGenerationStores(persistence) - const jobStore = persistence.stores.jobs - if (!jobStore) { + const runStore = persistence.stores.generationRuns + if (!runStore) { // validateReconstructGenerationStores already throws; this narrows for TS. - throw new Error('reconstructGeneration requires stores.jobs.') + throw new Error('reconstructGeneration requires stores.generationRuns.') } const params = new URL(request.url).searchParams - const jobParam = options?.jobParam ?? 'jobId' + const runParam = options?.runParam ?? 'runId' const threadParam = options?.param ?? 'threadId' - const jobId = params.get(jobParam) ?? '' + const runId = params.get(runParam) ?? '' const threadId = params.get(threadParam) ?? '' - const id = jobId || threadId + const id = runId || threadId if (!id) { return jsonResponse({ resumeSnapshot: null, activeRun: null }) } @@ -169,16 +170,16 @@ export async function reconstructGeneration( } } - const job = jobId - ? await jobStore.get(jobId) - : ((await jobStore.findLatestForThread?.(threadId)) ?? null) + const run = runId + ? await runStore.get(runId) + : ((await runStore.findLatestForThread?.(threadId)) ?? null) - if (!job) { + if (!run) { return jsonResponse({ resumeSnapshot: null, activeRun: null }) } return jsonResponse({ - resumeSnapshot: jobToSnapshot(job), - activeRun: job.status === 'running' ? { runId: job.jobId } : null, + resumeSnapshot: runToSnapshot(run), + activeRun: run.status === 'running' ? { runId: run.runId } : null, }) } diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index 4f0abf149..438aaeb8d 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -97,8 +97,8 @@ export type RunStatus = 'running' | 'completed' | 'failed' | 'interrupted' * than a run id. * * `threadId` is the conversation key ({@link Scope.threadId}) — never a - * generation `requestId`. Generation jobs do not use this record; they have - * their own {@link GenerationJobRecord} keyed on `jobId`. + * generation `requestId`. Generation runs do not use this record; they have + * their own {@link GenerationRunRecord}. * * @property startedAt - Epoch ms when the run was first created. * @property finishedAt - Epoch ms when the run reached a terminal status. @@ -162,37 +162,39 @@ export interface RunStore { findActiveRun: (threadId: string) => Promise } -export type GenerationJobStatus = +export type GenerationRunStatus = | 'running' | 'complete' | 'error' | 'interrupted' /** - * A single generation job (one `generateImage` / `generateVideo` / … call). + * A single generation run (one `generateImage` / `generateVideo` / … call). * - * Its primary identity is `jobId` (the run/request id the activity mints) — a - * generation has no conversation, so `threadId` is only an OPTIONAL *link* to a - * chat when the product wants one (e.g. to correlate a generation with the - * thread that triggered it). Do not key generation state on the chat - * {@link RunStore} / {@link Scope.threadId}. + * Its primary identity is `runId` (the run/request id the activity mints — the + * same AG-UI run id the client sends on the wire) — a generation has no + * conversation, so `threadId` is only an OPTIONAL *link* to a chat when the + * product wants one (e.g. to correlate a generation with the thread that + * triggered it). Do not key generation state on the chat {@link RunStore} / + * {@link Scope.threadId}. * - * `result` holds terminal result METADATA (ids, model, urls, a video jobId), - * never the media bytes — those live in a {@link BlobStore}. `artifacts` are the - * durable {@link PersistedArtifactRef}s, present only when byte storage is on. + * `result` holds terminal result METADATA (ids, model, urls, a provider video + * job id), never the media bytes — those live in a {@link BlobStore}. + * `artifacts` are the durable {@link PersistedArtifactRef}s, present only when + * byte storage is on. * - * @property startedAt - Epoch ms when the job was first created. - * @property finishedAt - Epoch ms when the job reached a terminal status. + * @property startedAt - Epoch ms when the run was first created. + * @property finishedAt - Epoch ms when the run reached a terminal status. */ -export interface GenerationJobRecord { - jobId: string +export interface GenerationRunRecord { + runId: string /** Optional link to the chat conversation that triggered this generation. */ threadId?: string /** `'image' | 'audio' | 'tts' | 'video' | 'transcription'`. */ activity: string provider: string model: string - status: GenerationJobStatus + status: GenerationRunStatus startedAt: number finishedAt?: number error?: { message: string; code?: string } @@ -204,50 +206,51 @@ export interface GenerationJobRecord { } /** - * Durable store for generation job records — the generation counterpart to - * {@link RunStore}, keyed by `jobId` rather than a conversation `threadId`. + * Durable store for generation run records — the generation counterpart to + * {@link RunStore}, keyed by its own `runId` rather than a conversation + * `threadId`. */ -export interface GenerationJobStore { +export interface GenerationRunStore { /** - * Create a job record, or return the existing one if `jobId` is already + * Create a run record, or return the existing one if `runId` is already * present (resume). * - * INVARIANT (idempotency): a second call for a `jobId` returns the existing + * INVARIANT (idempotency): a second call for a `runId` returns the existing * record unchanged; `startedAt`/`activity`/`provider`/`model`/`threadId` are * not mutated. `status` defaults to `'running'` on first creation. */ createOrResume: ( input: Pick< - GenerationJobRecord, - 'jobId' | 'activity' | 'provider' | 'model' | 'startedAt' - > & { threadId?: string; status?: GenerationJobStatus }, - ) => Promise + GenerationRunRecord, + 'runId' | 'activity' | 'provider' | 'model' | 'startedAt' + > & { threadId?: string; status?: GenerationRunStatus }, + ) => Promise /** - * Patch a job record's mutable fields. + * Patch a run record's mutable fields. * - * INVARIANT: patching a `jobId` that does not exist is a **no-op** — it must + * INVARIANT: patching a `runId` that does not exist is a **no-op** — it must * not throw and must not create a record. */ update: ( - jobId: string, + runId: string, patch: Partial< Pick< - GenerationJobRecord, + GenerationRunRecord, 'status' | 'finishedAt' | 'error' | 'result' | 'artifacts' | 'usage' > >, ) => Promise - /** Return the job record for `jobId`, or `null` if none exists. */ - get: (jobId: string) => Promise + /** Return the run record for `runId`, or `null` if none exists. */ + get: (runId: string) => Promise /** - * The most recent job linked to `threadId`, or `null`. OPTIONAL — callers + * The most recent run linked to `threadId`, or `null`. OPTIONAL — callers * feature-detect it (`store.findLatestForThread?.(threadId)`). Lets a * server-authoritative client hydrate the last generation for a thread by the - * stable thread id, without handling a job id. + * stable thread id, without handling a run id. */ findLatestForThread?: ( threadId: string, - ) => Promise + ) => Promise } /** Lifecycle status of a human-in-the-loop interrupt. */ @@ -390,10 +393,10 @@ export function defineInterruptStore(store: InterruptStore): InterruptStore { export function defineMetadataStore(store: MetadataStore): MetadataStore { return store } -/** Type a {@link GenerationJobStore} implementation inline. */ -export function defineGenerationJobStore( - store: GenerationJobStore, -): GenerationJobStore { +/** Type a {@link GenerationRunStore} implementation inline. */ +export function defineGenerationRunStore( + store: GenerationRunStore, +): GenerationRunStore { return store } /** Type an {@link ArtifactStore} implementation inline. */ @@ -544,7 +547,7 @@ export interface AIPersistenceStores { runs?: RunStore interrupts?: InterruptStore metadata?: MetadataStore - jobs?: GenerationJobStore + generationRuns?: GenerationRunStore artifacts?: ArtifactStore blobs?: BlobStore } @@ -717,7 +720,7 @@ export type ComposedAIPersistenceStores< const storeKeys = [ 'messages', 'runs', - 'jobs', + 'generationRuns', 'interrupts', 'metadata', 'artifacts', @@ -756,10 +759,10 @@ export function validateChatPersistenceStores( } /** - * Generation middleware entrypoint rule: `jobs` is required (the generation job - * lifecycle is keyed on `jobId`, not a chat conversation `threadId`). When - * artifact persistence is used, `artifacts` and `blobs` must be provided - * together. + * Generation middleware entrypoint rule: `generationRuns` is required (the + * generation run lifecycle is keyed on its own `runId`, not a chat conversation + * `threadId`). When artifact persistence is used, `artifacts` and `blobs` must + * be provided together. */ export function validateGenerationPersistenceStores( persistence: AIPersistence, @@ -772,8 +775,8 @@ export function validateGenerationPersistenceStores( 'Generation artifact persistence requires both stores.artifacts and stores.blobs.', ) } - if (!persistence.stores.jobs) { - throw new Error('Generation persistence requires stores.jobs.') + if (!persistence.stores.generationRuns) { + throw new Error('Generation persistence requires stores.generationRuns.') } } @@ -790,17 +793,17 @@ export function validateReconstructChatStores( } /** - * Server hydrate entrypoint rule for generation: `jobs` is required. The job - * store resolves the latest generation for a thread (or a specific job id), so - * a server-authoritative client can hydrate the last generation's status, - * result, and artifact refs on load. + * Server hydrate entrypoint rule for generation: `generationRuns` is required. + * The run store resolves the latest generation for a thread (or a specific run + * id), so a server-authoritative client can hydrate the last generation's + * status, result, and artifact refs on load. */ export function validateReconstructGenerationStores( persistence: AIPersistence, ): void { validatePersistenceStoreKeys(persistence) - if (!persistence.stores.jobs) { - throw new Error('reconstructGeneration requires stores.jobs.') + if (!persistence.stores.generationRuns) { + throw new Error('reconstructGeneration requires stores.generationRuns.') } } diff --git a/packages/ai-persistence/tests/error-abort.test.ts b/packages/ai-persistence/tests/error-abort.test.ts index ac572924a..4c6ac9b30 100644 --- a/packages/ai-persistence/tests/error-abort.test.ts +++ b/packages/ai-persistence/tests/error-abort.test.ts @@ -245,8 +245,8 @@ function imageAdapterThatThrows(thrown: unknown): ImageAdapter { } } -// A generation job is keyed on `jobId` (`ctx.runId ?? ctx.requestId`). With no -// runId supplied the auto-generated `requestId` is the jobId, so the integration +// A generation run is keyed on `runId` (`ctx.runId ?? ctx.requestId`). With no +// runId supplied the auto-generated `requestId` is the runId, so the integration // tests capture it via a probe middleware, and the direct-drive tests set // `requestId` to the pre-created job's id. function generationContext(requestId: string): GenerationMiddlewareContext { @@ -281,7 +281,7 @@ describe('generation persistence error/abort hooks', () => { }), ).rejects.toThrow('image boom') - const job = await persistence.stores.jobs.get(requestId) + const job = await persistence.stores.generationRuns.get(requestId) expect(job?.status).toBe('error') expect(job?.error).toEqual({ message: 'image boom' }) }) @@ -305,7 +305,7 @@ describe('generation persistence error/abort hooks', () => { }), ).rejects.toBeDefined() - const job = await persistence.stores.jobs.get(requestId) + const job = await persistence.stores.generationRuns.get(requestId) expect(job?.status).toBe('error') expect(job?.error).toEqual({ message: 'image string failure' }) }) @@ -314,8 +314,8 @@ describe('generation persistence error/abort hooks', () => { const persistence = memoryPersistence() const middleware = withGenerationPersistence(persistence) - await persistence.stores.jobs.createOrResume({ - jobId: 'req-abort', + await persistence.stores.generationRuns.createOrResume({ + runId: 'req-abort', activity: 'image', provider: 'test', model: 'test-model', @@ -330,16 +330,16 @@ describe('generation persistence error/abort hooks', () => { } await middleware.onAbort?.(generationContext('req-abort'), abortInfo) - expect((await persistence.stores.jobs.get('req-abort'))?.status).toBe( - 'interrupted', - ) + expect( + (await persistence.stores.generationRuns.get('req-abort'))?.status, + ).toBe('interrupted') }) it('coerces a non-Error into the job error message via the onError handler', async () => { const persistence = memoryPersistence() const middleware = withGenerationPersistence(persistence) - await persistence.stores.jobs.createOrResume({ - jobId: 'req-err', + await persistence.stores.generationRuns.createOrResume({ + runId: 'req-err', activity: 'image', provider: 'test', model: 'test-model', @@ -351,7 +351,7 @@ describe('generation persistence error/abort hooks', () => { } await middleware.onError?.(generationContext('req-err'), errorInfo) - const job = await persistence.stores.jobs.get('req-err') + const job = await persistence.stores.generationRuns.get('req-err') expect(job?.status).toBe('error') expect(job?.error).toEqual({ message: '[object Object]' }) }) diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index 98c8e2736..0af24c34a 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -271,7 +271,7 @@ describe('withGenerationPersistence generation artifacts', () => { const full = memoryPersistence() const persistence = defineAIPersistence({ stores: { - jobs: full.stores.jobs, + generationRuns: full.stores.generationRuns, }, }) @@ -287,7 +287,7 @@ describe('withGenerationPersistence generation artifacts', () => { }) expect(() => withGenerationPersistence(persistence)).toThrow( - /Generation persistence requires stores\.jobs/i, + /Generation persistence requires stores\.generationRuns/i, ) }) @@ -302,9 +302,9 @@ describe('withGenerationPersistence generation artifacts', () => { middleware: [withGenerationPersistence(persistence)], }) - const job = await persistence.stores.jobs.get('run-job') + const job = await persistence.stores.generationRuns.get('run-job') expect(job).toMatchObject({ - jobId: 'run-job', + runId: 'run-job', threadId: 'thread-job', activity: 'image', provider: 'test-image-provider', @@ -333,8 +333,10 @@ describe('withGenerationPersistence generation artifacts', () => { }) const latest = - await persistence.stores.jobs.findLatestForThread!('thread-latest') - expect(latest?.jobId).toBe('run-latest-1') + await persistence.stores.generationRuns.findLatestForThread!( + 'thread-latest', + ) + expect(latest?.runId).toBe('run-latest-1') expect(latest?.status).toBe('complete') }) @@ -355,9 +357,9 @@ describe('withGenerationPersistence generation artifacts', () => { }), ).rejects.toThrow('boom') - const job = await persistence.stores.jobs.get('run-error') + const job = await persistence.stores.generationRuns.get('run-error') expect(job).toMatchObject({ - jobId: 'run-error', + runId: 'run-error', status: 'error', error: { message: 'boom' }, }) diff --git a/packages/ai-persistence/tests/memory.test.ts b/packages/ai-persistence/tests/memory.test.ts index 30d7bc780..7730a4fef 100644 --- a/packages/ai-persistence/tests/memory.test.ts +++ b/packages/ai-persistence/tests/memory.test.ts @@ -15,8 +15,8 @@ describe('memoryPersistence', () => { expect(Object.keys(memoryPersistence().stores).sort()).toEqual([ 'artifacts', 'blobs', + 'generationRuns', 'interrupts', - 'jobs', 'messages', 'metadata', 'runs', diff --git a/packages/ai-persistence/tests/persistence-fixtures.ts b/packages/ai-persistence/tests/persistence-fixtures.ts index 463738975..8bba11375 100644 --- a/packages/ai-persistence/tests/persistence-fixtures.ts +++ b/packages/ai-persistence/tests/persistence-fixtures.ts @@ -1,6 +1,6 @@ import type { - GenerationJobRecord, - GenerationJobStore, + GenerationRunRecord, + GenerationRunStore, InterruptStore, MessageStore, MetadataStore, @@ -50,14 +50,14 @@ export function createRunStore(): RunStore { } } -export function createGenerationJobStore(): GenerationJobStore { - const jobs = new Map() +export function createGenerationRunStore(): GenerationRunStore { + const generationRuns = new Map() return { createOrResume: (input) => { - const existing = jobs.get(input.jobId) + const existing = generationRuns.get(input.runId) if (existing) return Promise.resolve(existing) - const record: GenerationJobRecord = { - jobId: input.jobId, + const record: GenerationRunRecord = { + runId: input.runId, activity: input.activity, provider: input.provider, model: input.model, @@ -65,15 +65,15 @@ export function createGenerationJobStore(): GenerationJobStore { startedAt: input.startedAt, ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), } - jobs.set(record.jobId, record) + generationRuns.set(record.runId, record) return Promise.resolve(record) }, - update: (jobId, patch) => { - const existing = jobs.get(jobId) - if (existing) jobs.set(jobId, { ...existing, ...patch }) + update: (runId, patch) => { + const existing = generationRuns.get(runId) + if (existing) generationRuns.set(runId, { ...existing, ...patch }) return Promise.resolve() }, - get: (jobId) => Promise.resolve(jobs.get(jobId) ?? null), + get: (runId) => Promise.resolve(generationRuns.get(runId) ?? null), } } diff --git a/packages/ai-persistence/tests/persistence-types.test-d.ts b/packages/ai-persistence/tests/persistence-types.test-d.ts index 6c30066d8..7c2be7b9c 100644 --- a/packages/ai-persistence/tests/persistence-types.test-d.ts +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -19,7 +19,7 @@ import type { ChatPersistence, ChatTranscriptPersistence, ChatTranscriptStores, - GenerationJobStore, + GenerationRunStore, InterruptStore, MessageStore, MetadataStore, @@ -36,7 +36,7 @@ declare const replacementRuns: RunStore & { } declare const interrupts: InterruptStore declare const metadata: MetadataStore -declare const jobs: GenerationJobStore +declare const generationRuns: GenerationRunStore declare const locks: LockStore const messagesOnly = defineAIPersistence({ stores: { messages } }) @@ -132,7 +132,7 @@ expectTypeOf(memoryPersistence()).toEqualTypeOf< AIPersistence<{ messages: MessageStore runs: RunStore - jobs: GenerationJobStore + generationRuns: GenerationRunStore interrupts: InterruptStore metadata: MetadataStore artifacts: ArtifactStore @@ -152,9 +152,9 @@ withPersistence(defineAIPersistence({ stores: { runs } })) // @ts-expect-error a known interrupt store requires a known run store withPersistence(defineAIPersistence({ stores: { interrupts, messages } })) -// Generation requires jobs -withGenerationPersistence(defineAIPersistence({ stores: { jobs } })) -// @ts-expect-error generation persistence requires jobs +// Generation requires generationRuns +withGenerationPersistence(defineAIPersistence({ stores: { generationRuns } })) +// @ts-expect-error generation persistence requires generationRuns withGenerationPersistence(messagesOnly) // @ts-expect-error a runs store alone does not satisfy generation persistence withGenerationPersistence(defineAIPersistence({ stores: { runs } })) diff --git a/packages/ai-persistence/tests/persistence-validation.test.ts b/packages/ai-persistence/tests/persistence-validation.test.ts index e504ed911..829341180 100644 --- a/packages/ai-persistence/tests/persistence-validation.test.ts +++ b/packages/ai-persistence/tests/persistence-validation.test.ts @@ -4,7 +4,7 @@ import { reconstructChat } from '../src/reconstruct' import { defineAIPersistence } from '../src/types' import type { ChatTranscriptPersistence } from '../src/types' import { - createGenerationJobStore, + createGenerationRunStore, createInterruptStore, createMessageStore, createRunStore, @@ -61,7 +61,7 @@ describe('persistence store dependency validation', () => { expect(() => withPersistence(persistence)).not.toThrow() }) - it('rejects generation persistence without jobs', () => { + it('rejects generation persistence without generationRuns', () => { const persistence = defineAIPersistence({ stores: { messages: createMessageStore() }, }) @@ -70,12 +70,12 @@ describe('persistence store dependency validation', () => { withGenerationPersistence( persistence as Parameters[0], ), - ).toThrow(/requires stores\.jobs/i) + ).toThrow(/requires stores\.generationRuns/i) }) - it('allows generation persistence with jobs', () => { + it('allows generation persistence with generationRuns', () => { const persistence = defineAIPersistence({ - stores: { jobs: createGenerationJobStore() }, + stores: { generationRuns: createGenerationRunStore() }, }) expect(() => withGenerationPersistence(persistence)).not.toThrow() diff --git a/packages/ai-persistence/tests/reconstruct-generation.test.ts b/packages/ai-persistence/tests/reconstruct-generation.test.ts index 4f923a579..84f2f4070 100644 --- a/packages/ai-persistence/tests/reconstruct-generation.test.ts +++ b/packages/ai-persistence/tests/reconstruct-generation.test.ts @@ -10,15 +10,15 @@ async function body(response: Response): Promise { describe('reconstructGeneration', () => { it('maps a completed job to a resume snapshot', async () => { const persistence = memoryPersistence() - await persistence.stores.jobs.createOrResume({ - jobId: 'job-done', + await persistence.stores.generationRuns.createOrResume({ + runId: 'job-done', threadId: 'thread-1', activity: 'image', provider: 'test-image-provider', model: 'test-image-model', startedAt: 1000, }) - await persistence.stores.jobs.update('job-done', { + await persistence.stores.generationRuns.update('job-done', { status: 'complete', finishedAt: 2000, result: { id: 'image-result' }, @@ -46,8 +46,8 @@ describe('reconstructGeneration', () => { it('reports an active run and resume state for a running job', async () => { const persistence = memoryPersistence() - await persistence.stores.jobs.createOrResume({ - jobId: 'job-live', + await persistence.stores.generationRuns.createOrResume({ + runId: 'job-live', threadId: 'thread-live', activity: 'video', provider: 'test-video-provider', @@ -55,11 +55,11 @@ describe('reconstructGeneration', () => { startedAt: 5000, }) - // Resolve by jobId directly. + // Resolve by runId directly. const parsed = await body( await reconstructGeneration( persistence, - new Request('http://example.test/api/generation?jobId=job-live'), + new Request('http://example.test/api/generation?runId=job-live'), ), ) expect(parsed.activeRun).toEqual({ runId: 'job-live' }) @@ -72,15 +72,15 @@ describe('reconstructGeneration', () => { it('surfaces an interrupted job as error status', async () => { const persistence = memoryPersistence() - await persistence.stores.jobs.createOrResume({ - jobId: 'job-int', + await persistence.stores.generationRuns.createOrResume({ + runId: 'job-int', threadId: 'thread-int', activity: 'audio', provider: 'p', model: 'm', startedAt: 1, }) - await persistence.stores.jobs.update('job-int', { + await persistence.stores.generationRuns.update('job-int', { status: 'interrupted', finishedAt: 2, }) @@ -88,7 +88,7 @@ describe('reconstructGeneration', () => { const parsed = await body( await reconstructGeneration( persistence, - new Request('http://example.test/api/generation?jobId=job-int'), + new Request('http://example.test/api/generation?runId=job-int'), ), ) expect(parsed.resumeSnapshot?.status).toBe('error') @@ -118,8 +118,8 @@ describe('reconstructGeneration', () => { it('returns 403 when authorize returns false', async () => { const persistence = memoryPersistence() - await persistence.stores.jobs.createOrResume({ - jobId: 'job-secret', + await persistence.stores.generationRuns.createOrResume({ + runId: 'job-secret', threadId: 'thread-secret', activity: 'image', provider: 'p', @@ -129,7 +129,7 @@ describe('reconstructGeneration', () => { const response = await reconstructGeneration( persistence, - new Request('http://example.test/api/generation?jobId=job-secret'), + new Request('http://example.test/api/generation?runId=job-secret'), { authorize: () => false }, ) expect(response.status).toBe(403) diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index 343d50194..cbd575ea6 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -126,7 +126,7 @@ transparent, mirroring `useChat`:** whichever mode, a reload repaints the hook's extra field is `resumeState`: the in-flight run identity (`{ threadId, runId, pendingArtifacts? }`) or `null` — non-null only while a run streams. The persisted record holds run identity, status, error, and result metadata (ids, -model, video `jobId`), **never the generated media bytes**. +model, a provider video job id), **never the generated media bytes**. The hook return is exactly `generate` / `result` / `isLoading` / `error` / `status` / `stop` / `reset` / `resumeState`. @@ -174,8 +174,8 @@ const image = useGenerateImage({ `hydrateGeneration` handler (the SSE/HTTP adapters issue a `GET` with `?threadId=` to the same endpoint URL) and repaints it into the normal fields. - The server `GET` returns `reconstructGeneration(persistence, request)` from - `@tanstack/ai-persistence` — it resolves the job by `?jobId=` (preferred) or - the latest job linked to `?threadId=`, and needs `stores.jobs`. Pair it with + `@tanstack/ai-persistence` — it resolves the run by `?runId=` (preferred) or + the latest run linked to `?threadId=`, and needs `stores.generationRuns`. Pair it with `withGenerationPersistence` on the generation route. See `ai-core/media-generation` and `ai-persistence`. - Best for multi-device / compliance (no generation metadata in browser diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index 0de7843cd..d9c44f00d 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -567,7 +567,7 @@ returns `usage` and emits a `video:usage` devtools event when fal reports it. To make generations survive a server restart and be re-served later, add `withGenerationPersistence` from `@tanstack/ai-persistence` as generation -middleware. It requires `stores.jobs` (a `GenerationJobStore`, keyed on `jobId` — +middleware. It requires `stores.generationRuns` (a `GenerationRunStore`, keyed on the run's own `runId` — not a chat `threadId`) and, when you also pass an `stores.artifacts` + `stores.blobs` **pair** (both or neither), it persists the generated media bytes at blob key `artifacts//` with an `ArtifactRecord` per file. diff --git a/testing/e2e/src/routes/api.generation-persistence-server.ts b/testing/e2e/src/routes/api.generation-persistence-server.ts index 857d93f2d..05aba78d1 100644 --- a/testing/e2e/src/routes/api.generation-persistence-server.ts +++ b/testing/e2e/src/routes/api.generation-persistence-server.ts @@ -34,7 +34,7 @@ const TINY_PNG_B64 = const DURABLE_IMAGE_URL = '/durable/generation-server/image-1.png' // Server-authoritative record of the last completed generation per thread. In -// production this is a `GenerationJobStore` row; here a process-lifetime map is +// production this is a `GenerationRunStore` row; here a process-lifetime map is // enough for the reload round-trip (the e2e server stays up across reloads). const completedByThread = new Map>() From 9e4f1bb27759d161bace388fcfb29eae845a44e3 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:04:26 +1000 Subject: [PATCH 30/66] 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/chat/streaming.md | 43 ++++++++++++++++ docs/config.json | 8 +-- docs/interrupts/overview.md | 18 +++++++ docs/persistence/build-your-own-adapter.md | 51 +++++++++++++++++++ docs/persistence/chat-persistence.md | 45 +++++++++++++++++ docs/persistence/generation-persistence.md | 35 +++++++++++++ docs/persistence/overview.md | 57 ++++++++++++++++++++++ docs/resumable-streams/overview.md | 4 ++ 8 files changed, 257 insertions(+), 4 deletions(-) diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index b192a1bd7..c2b78f533 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -88,6 +88,49 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) > **Tip:** Some models expose their internal reasoning as thinking content that streams before the response. See [Thinking & Reasoning](./thinking-content). +### Threads, runs, and turns + +Two ids frame every stream, and they come from the AG-UI protocol itself — not +from any storage layer: + +- A **thread** (`threadId`) is the conversation: the stable identity you pass to + `chat()` / `useChat`, the same across every exchange, reload, and device. +- A **run** (`runId`) is one execution inside it: everything between one + `RUN_STARTED` and its `RUN_FINISHED` (or `RUN_ERROR`). Every start mints a + fresh run id, so a thread accumulates many runs over its life. + +A run is **not** a conversational turn, in either direction. One run can contain +several agent-loop cycles — each tool call and its follow-up stream inside the +same run — and one user-visible turn can span several runs, because continuing +after an [interrupt](../interrupts/overview) starts a fresh run: + +```mermaid +flowchart LR + subgraph thread ["Thread — threadId (stable)"] + direction LR + subgraph turn1 ["User turn 1 — one run"] + run1["run r1 — finished +tool call → tool result → final text, +all inside one run"] + end + subgraph turn2 ["User turn 2 — spans two runs"] + run2["run r2 — ended on +an interrupt"] -->|"continuation mints +a fresh runId"| run3["run r3 — streaming"] + end + turn1 --> turn2 + end + reconnect["a reconnecting client"] -.->|"finds the live run from +the stable threadId"| run3 +``` + +Because run ids are ephemeral like this, anything long-lived anchors on the +thread. [Resumable streams](../resumable-streams/overview) log delivery per +`runId` so a dropped connection can replay one run; +[server persistence](../persistence/chat-persistence#threads-runs-and-turns) +stores the transcript per `threadId` and resolves "which run is live?" from the +stable thread id when a client reconnects. + ### Type-Safe Tool Call Events When you pass typed tools (defined with `toolDefinition()` and Zod schemas) to `chat()`, the stream chunks automatically carry type information for tool call events. Prefer the AG-UI field `toolCallName` (or the deprecated `toolName` alias) — both narrow to the union of your tool name literals. The `input` field on `TOOL_CALL_END` is typed as the union of your tool input schemas (typically set on the adapter-emitted END once arguments are complete): diff --git a/docs/config.json b/docs/config.json index 9e3b38905..2801662b9 100644 --- a/docs/config.json +++ b/docs/config.json @@ -164,7 +164,7 @@ "label": "Streaming", "to": "chat/streaming", "addedAt": "2026-04-15", - "updatedAt": "2026-07-17" + "updatedAt": "2026-07-29" }, { "label": "Connection Adapters", @@ -186,7 +186,7 @@ "label": "Overview", "to": "interrupts/overview", "addedAt": "2026-07-16", - "updatedAt": "2026-07-22" + "updatedAt": "2026-07-29" }, { "label": "Tool Approval", @@ -221,7 +221,7 @@ "label": "Overview", "to": "resumable-streams/overview", "addedAt": "2026-07-17", - "updatedAt": "2026-07-23" + "updatedAt": "2026-07-29" }, { "label": "Advanced", @@ -248,7 +248,7 @@ "label": "Chat Persistence", "to": "persistence/chat-persistence", "addedAt": "2026-07-22", - "updatedAt": "2026-07-26" + "updatedAt": "2026-07-29" }, { "label": "Client Persistence", diff --git a/docs/interrupts/overview.md b/docs/interrupts/overview.md index 62b6fd595..7756d510a 100644 --- a/docs/interrupts/overview.md +++ b/docs/interrupts/overview.md @@ -30,6 +30,24 @@ picks up exactly where it left off once you answer. 4. The client starts a fresh continuation run that carries your answers and continues the agent. +```mermaid +sequenceDiagram + participant User + participant Client + participant Server + + Client->>Server: send message — run starts + Server-->>Client: interrupt outcome — run ends without a final answer + Client->>User: pending decisions surface as `interrupts` + User->>Client: approve / reject / submit a value + Client->>Server: continuation request with the answers — a fresh run + Server-->>Client: the agent picks up where it paused, final answer +``` + +Note that the pause spans **two runs**: the interrupted one ends, and the +continuation is a new run. One user-visible turn, two run lifecycles — see +[Threads, runs, and turns](../chat/streaming#threads-runs-and-turns). + No database is required. The browser sends the full message history back on the continuation request, so a stateless server can rebuild the paused step and keep going. diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index 273c68ba1..cf9ba7b24 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -70,6 +70,57 @@ The invariants (idempotent creates, insert-if-absent, ordered listings) are what the shared conformance suite checks, and getting one wrong is the usual source of subtle bugs. +The records the stores hold form a small schema. The thread is not a table of +its own — it exists as the `thread_id` key the other records hang off — and +`metadata` is independent of all of it (its identity is `(namespace, key)`). +Note the asymmetry on the generation side: a chat run always belongs to a +thread, but a generation run is keyed by its own `run_id` and links to a thread +only optionally: + +```mermaid +erDiagram + MESSAGES ||--o{ RUN : "thread_id — a thread has many runs" + RUN ||--o{ INTERRUPT : "run_id — a run may pause on interrupts" + MESSAGES ||..o{ GENERATION_RUN : "thread_id — optional link" + GENERATION_RUN ||--o{ ARTIFACT : "run_id — a run produces artifacts" + ARTIFACT ||--|| BLOB : "key — the bytes" + + MESSAGES { + string thread_id PK + json messages_json "full transcript, overwritten on save" + } + RUN { + string run_id PK + string thread_id + string status "running | completed | failed | interrupted" + int started_at + int finished_at + } + INTERRUPT { + string interrupt_id PK + string run_id + string thread_id + string status "pending | resolved | cancelled" + int requested_at + } + GENERATION_RUN { + string run_id PK + string thread_id "optional" + string activity "image | audio | tts | video | transcription" + string status "running | complete | error | interrupted" + } + ARTIFACT { + string artifact_id PK + string run_id + string mime_type + int size + } + BLOB { + string key PK + blob bytes + } +``` + ## New database: a SQLite adapter start to finish ### 1. The schema diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md index d0ae5c9d3..d8e229086 100644 --- a/docs/persistence/chat-persistence.md +++ b/docs/persistence/chat-persistence.md @@ -69,6 +69,24 @@ Creating tables on open is convenient for local development. In production, appl schema changes through your deployment workflow instead. See [Migrations](./migrations). +## Threads, runs, and turns + +Threads and runs are protocol concepts, not persistence ones — a **thread** +(`threadId`) is the stable conversation, a **run** (`runId`) one +`RUN_STARTED` → `RUN_FINISHED` execution, and one user-visible turn can span +several runs. [Threads, runs, and turns](../chat/streaming#threads-runs-and-turns) +in the streaming guide covers the anatomy. What persistence adds is the durable +record of them, anchored on the thread: + +- The transcript is stored per `threadId` (the `messages` store). +- Each run gets a `runs` record with status, timings, and usage — the id is + ephemeral, the record is not. +- A reconnecting client (a reload, or the same thread on another device) never + has to present a run id it may no longer know: the store resolves the + thread's live run (`findActiveRun(threadId)`) and the client tails that. +- Interrupt records carry both ids — the `runId` of the execution they paused + and the `threadId` of the conversation they live in. + ## Send the full transcript, or none of it `withPersistence` follows one rule, the authoritative-history contract: @@ -106,6 +124,21 @@ On **error**, the run is marked `failed`. On **abort**, the run is marked success boundary (interrupt or finish), so a failed run leaves pending interrupts retryable with the same resume batch. +Every run record moves through this lifecycle — all three end states are +terminal for that record, because a continuation after an interrupt is a new run +with a fresh `runId`: + +```mermaid +stateDiagram-v2 + [*] --> running : run starts (idempotent createOrResume) + running --> completed : finish — transcript saved first + running --> failed : error + running --> interrupted : interrupt boundary, or abort + completed --> [*] + failed --> [*] + interrupted --> [*] : continuation runs under a new runId +``` + ## Interrupts survive a restart When a run pauses on an interrupt (a tool approval, a client-side tool, a @@ -122,6 +155,18 @@ needs client message history the persistence flow deliberately omits). Resumes are committed (resolved/cancelled in the store) only once the run reaches a successful interrupt or finish boundary. +An interrupt record is born `pending` and only a commit moves it — which is why +a failed continuation leaves it answerable again: + +```mermaid +stateDiagram-v2 + [*] --> pending : run pauses — interrupt recorded + pending --> resolved : resume answers it, committed at a success boundary + pending --> cancelled : resume cancels it + resolved --> [*] + cancelled --> [*] +``` + ## Where to go next - Bring durability to the browser too, so a full page reload restores the diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 45f4eb7d6..0c045d356 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -38,6 +38,22 @@ The record never holds the generated bytes, so on its own a reload restores `status` and `error` while `result` stays `null`. To bring the media back too, add server byte storage: see [Keep generated files](./keep-generated-files). +The record's lifecycle is small — one status field the middleware advances: + +```mermaid +stateDiagram-v2 + [*] --> running : generation starts (idempotent createOrResume) + running --> complete : finish — result metadata saved + running --> error : error + running --> interrupted : abort + complete --> [*] + error --> [*] + interrupted --> [*] +``` + +A restored `interrupted` run surfaces to the hook as an error — an aborted +generation cannot be resumed, only re-run. + ## Server-driven: `persistence: true` One `POST` that runs the generation, and a `GET` on the same route that answers @@ -156,6 +172,25 @@ run and the client tails it through the durability log. In production, swap `memoryStream` for `durableStream` from `@tanstack/ai-durable-stream`, where requests span processes. See [Resumable Streams](../resumable-streams/overview). +```mermaid +sequenceDiagram + participant Hook as useGenerateImage (persistence: true) + participant Route as GET /api/generate/image + participant Runs as generationRuns store + participant Log as Delivery log + + Note over Hook: mount (or reload) with a threadId + Hook->>Route: ?threadId=… + Route->>Runs: reconstructGeneration — latest run for the thread + Runs-->>Route: run record (status, result metadata, artifact refs) + Route-->>Hook: status / error / result repainted + alt run still generating + Hook->>Route: ?runId=…&offset=-1 + Route->>Log: resumeServerSentEventsResponse + Log-->>Hook: replay + live tail — run finishes in place + end +``` + ## Client-driven: a storage adapter Pass a storage adapter instead, and give the hook a stable `id`. The client diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index 5e29ace49..e985f394b 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -51,6 +51,44 @@ Run that after the package is installed, not before — Intent scans They share no code and solve different problems. Delivery durability replays a live byte stream so a dropped connection resumes exactly where it stopped. State persistence stores the conversation itself, so it survives a reload or exists on another device. A replayable stream is not a saved conversation, and a saved conversation is not a live stream. Real apps usually want both. +The two layers also key on different ids. A **thread** (`threadId`) is the +conversation — the stable identity that survives reloads and exists on every +device. A **run** (`runId`) is one execution inside it: one streamed answer, +minted fresh each time. A thread accumulates many runs over its life; delivery +durability logs one run, state persistence stores the whole thread: + +```mermaid +flowchart TB + subgraph thread ["One thread — threadId (stable, the conversation)"] + direction LR + run1["run r1 +completed"] --> run2["run r2 +completed"] --> run3["run r3 +running"] + end + + subgraph delivery ["Delivery durability — one byte log per run"] + log["log for r3 +replays the live stream to a reconnecting client"] + end + + subgraph state ["State persistence — durable store per thread"] + store["transcript · run records · interrupts"] + end + + run3 -. "a dropped connection tails" .-> log + thread -- "saved on finish, loaded on mount" --> store +``` + +Run ids are too ephemeral to reconnect by — a reloading client may not know the +current one. Reconnection therefore resolves from the stable `threadId`: the +store answers "does this thread have a live run?" (`findActiveRun`), and only +then does the client tail that run's log. The anatomy of a thread — how runs +relate to conversational turns and interrupts — is covered in +[Threads, runs, and turns](../chat/streaming#threads-runs-and-turns) in the +streaming guide; what persistence records about them is in +[Chat persistence](./chat-persistence#threads-runs-and-turns). + ## State persistence has two halves Persistence runs on the client, the server, or both. They are independent, and they answer different questions. @@ -265,6 +303,25 @@ the transcript by message id, so nothing is duplicated or lost. Because the reconnect is resolved from the stable `threadId` on the server, a reload and the same thread opened on another device resume the same way. +```mermaid +sequenceDiagram + participant Hook as useChat (persistence: true) + participant Route as GET /api/chat + participant Store as Durable store + participant Log as Delivery log + + Note over Hook: page reloads while a run is streaming + Hook->>Route: ?threadId=support-chat + Route->>Store: reconstructChat — loadThread + findActiveRun + Store-->>Route: messages + activeRun (runId) + Route-->>Hook: transcript + activeRun cursor + Note over Hook: transcript paints + Hook->>Route: ?runId=…&offset=-1 + Route->>Log: resumeServerSentEventsResponse + Log-->>Hook: replay + live tail of the run + Note over Hook: reply finishes in place +``` + Why this wins over the alternatives: - **One source of truth.** History lives on the server, so there is no client/server copy to drift or reconcile. The same conversation opens on any device and survives a server restart. diff --git a/docs/resumable-streams/overview.md b/docs/resumable-streams/overview.md index 3fe9067c4..9fcec4e65 100644 --- a/docs/resumable-streams/overview.md +++ b/docs/resumable-streams/overview.md @@ -28,6 +28,10 @@ it survives a reload or reaches another device is a separate layer. For how the two fit together and when to pick each, see [Durability and Persistence](../persistence/overview). +The log is kept per **run** — one `RUN_STARTED` → `RUN_FINISHED` execution, not +a whole conversation. If the thread/run distinction is new, see +[Threads, runs, and turns](../chat/streaming#threads-runs-and-turns). + Three steps: pick an adapter, wrap your response with it, add a `GET` handler. ## 1. Pick an adapter From 20d594258d3be3d445ecc14bae5b1c165d0e9b76 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:45:52 +1000 Subject: [PATCH 31/66] 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. --- docs/chat/streaming.md | 45 +++++++++++++--------------- docs/interrupts/overview.md | 2 +- docs/persistence/chat-persistence.md | 2 +- docs/persistence/overview.md | 5 ++-- docs/resumable-streams/overview.md | 2 +- 5 files changed, 26 insertions(+), 30 deletions(-) diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index c2b78f533..95c26a212 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -88,48 +88,45 @@ TanStack AI implements the [AG-UI Protocol](https://docs.ag-ui.com/introduction) > **Tip:** Some models expose their internal reasoning as thinking content that streams before the response. See [Thinking & Reasoning](./thinking-content). -### Threads, runs, and turns +### Threads and runs Two ids frame every stream, and they come from the AG-UI protocol itself — not from any storage layer: -- A **thread** (`threadId`) is the conversation: the stable identity you pass to - `chat()` / `useChat`, the same across every exchange, reload, and device. +- A **thread** (`threadId`) is the conversation: the stable identity across + every exchange, reload, and device. - A **run** (`runId`) is one execution inside it: everything between one `RUN_STARTED` and its `RUN_FINISHED` (or `RUN_ERROR`). Every start mints a fresh run id, so a thread accumulates many runs over its life. -A run is **not** a conversational turn, in either direction. One run can contain -several agent-loop cycles — each tool call and its follow-up stream inside the -same run — and one user-visible turn can span several runs, because continuing -after an [interrupt](../interrupts/overview) starts a fresh run: +A run is not limited to a single model response. Tool calls and their +follow-up responses stream inside the same run — the whole +[agentic cycle](./agentic-cycle), however many loops it takes, is one run: ```mermaid flowchart LR subgraph thread ["Thread — threadId (stable)"] direction LR - subgraph turn1 ["User turn 1 — one run"] - run1["run r1 — finished -tool call → tool result → final text, -all inside one run"] + subgraph r1 ["Run r1 — finished"] + direction TB + e1["RUN_STARTED → text → tool call → tool result → final text → RUN_FINISHED"] end - subgraph turn2 ["User turn 2 — spans two runs"] - run2["run r2 — ended on -an interrupt"] -->|"continuation mints -a fresh runId"| run3["run r3 — streaming"] + subgraph r2 ["Run r2 — finished"] + direction TB + e2["RUN_STARTED → text → RUN_FINISHED"] end - turn1 --> turn2 + subgraph r3 ["Run r3 — running"] + direction TB + e3["RUN_STARTED → text"] + end + r1 --> r2 --> r3 end - reconnect["a reconnecting client"] -.->|"finds the live run from -the stable threadId"| run3 ``` -Because run ids are ephemeral like this, anything long-lived anchors on the -thread. [Resumable streams](../resumable-streams/overview) log delivery per -`runId` so a dropped connection can replay one run; -[server persistence](../persistence/chat-persistence#threads-runs-and-turns) -stores the transcript per `threadId` and resolves "which run is live?" from the -stable thread id when a client reconnects. +Because run ids are ephemeral, anything long-lived anchors on the thread: +[resumable streams](../resumable-streams/overview) log delivery per `runId`, +while [server persistence](../persistence/chat-persistence#threads-runs-and-turns) +stores the transcript per `threadId`. ### Type-Safe Tool Call Events diff --git a/docs/interrupts/overview.md b/docs/interrupts/overview.md index 7756d510a..9a5233b10 100644 --- a/docs/interrupts/overview.md +++ b/docs/interrupts/overview.md @@ -46,7 +46,7 @@ sequenceDiagram Note that the pause spans **two runs**: the interrupted one ends, and the continuation is a new run. One user-visible turn, two run lifecycles — see -[Threads, runs, and turns](../chat/streaming#threads-runs-and-turns). +[Threads and runs](../chat/streaming#threads-and-runs). No database is required. The browser sends the full message history back on the continuation request, so a stateless server can rebuild the paused step and keep diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md index d8e229086..473bcbe75 100644 --- a/docs/persistence/chat-persistence.md +++ b/docs/persistence/chat-persistence.md @@ -74,7 +74,7 @@ schema changes through your deployment workflow instead. See Threads and runs are protocol concepts, not persistence ones — a **thread** (`threadId`) is the stable conversation, a **run** (`runId`) one `RUN_STARTED` → `RUN_FINISHED` execution, and one user-visible turn can span -several runs. [Threads, runs, and turns](../chat/streaming#threads-runs-and-turns) +several runs. [Threads and runs](../chat/streaming#threads-and-runs) in the streaming guide covers the anatomy. What persistence adds is the durable record of them, anchored on the thread: diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index e985f394b..a6f69c17b 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -83,9 +83,8 @@ replays the live stream to a reconnecting client"] Run ids are too ephemeral to reconnect by — a reloading client may not know the current one. Reconnection therefore resolves from the stable `threadId`: the store answers "does this thread have a live run?" (`findActiveRun`), and only -then does the client tail that run's log. The anatomy of a thread — how runs -relate to conversational turns and interrupts — is covered in -[Threads, runs, and turns](../chat/streaming#threads-runs-and-turns) in the +then does the client tail that run's log. The anatomy of a thread and its runs +is covered in [Threads and runs](../chat/streaming#threads-and-runs) in the streaming guide; what persistence records about them is in [Chat persistence](./chat-persistence#threads-runs-and-turns). diff --git a/docs/resumable-streams/overview.md b/docs/resumable-streams/overview.md index 9fcec4e65..e4868e6f5 100644 --- a/docs/resumable-streams/overview.md +++ b/docs/resumable-streams/overview.md @@ -30,7 +30,7 @@ two fit together and when to pick each, see The log is kept per **run** — one `RUN_STARTED` → `RUN_FINISHED` execution, not a whole conversation. If the thread/run distinction is new, see -[Threads, runs, and turns](../chat/streaming#threads-runs-and-turns). +[Threads and runs](../chat/streaming#threads-and-runs). Three steps: pick an adapter, wrap your response with it, add a `GET` handler. From b6b9fa6c6eb642abd9f3a3e45dc9699b0da5be9a Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:55:58 +1000 Subject: [PATCH 32/66] refactor(examples): drop generation run history, switch image/video to Grok Imagine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/GenerationRunHistory.tsx | 236 ------------ .../src/lib/generation-persistence.ts | 14 + .../ts-react-chat/src/lib/generation-runs.ts | 339 ------------------ examples/ts-react-chat/src/lib/server-fns.ts | 18 +- .../src/routes/api.generate.image.ts | 4 +- .../src/routes/api.generate.video.ts | 4 +- .../src/routes/generation-hooks.tsx | 63 +--- .../src/routes/generations.audio.tsx | 38 +- .../src/routes/generations.image.tsx | 40 +-- .../src/routes/generations.speech.tsx | 39 +- .../src/routes/generations.summarize.tsx | 13 +- .../src/routes/generations.transcription.tsx | 13 +- .../src/routes/generations.video.tsx | 29 +- 13 files changed, 62 insertions(+), 788 deletions(-) delete mode 100644 examples/ts-react-chat/src/components/GenerationRunHistory.tsx create mode 100644 examples/ts-react-chat/src/lib/generation-persistence.ts delete mode 100644 examples/ts-react-chat/src/lib/generation-runs.ts diff --git a/examples/ts-react-chat/src/components/GenerationRunHistory.tsx b/examples/ts-react-chat/src/components/GenerationRunHistory.tsx deleted file mode 100644 index 9dd92bb60..000000000 --- a/examples/ts-react-chat/src/components/GenerationRunHistory.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useState } from 'react' -import { ChevronRight } from 'lucide-react' -import { clearGenerationRuns, useGenerationRuns } from '../lib/generation-runs' -import type { GenerationKind, GenerationRunEntry } from '../lib/generation-runs' - -const KIND_LABELS: Record = { - image: 'Image', - audio: 'Audio', - speech: 'Speech', - transcription: 'Transcription', - summarize: 'Summarize', - video: 'Video', -} - -function formatWhen(at: number): string { - const elapsed = Date.now() - at - if (elapsed < 60_000) return 'just now' - if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago` - if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago` - return new Date(at).toLocaleString() -} - -function RunOutput({ entry }: { entry: GenerationRunEntry }) { - const preview = entry.preview ?? [] - const hasMedia = preview.length > 0 || entry.artifacts.length > 0 - - return ( -
- {entry.label && ( -
-

- Input -

-

- {entry.label} -

-
- )} - - {entry.error ? ( -
-

- Error -

-

- {entry.error} -

-
- ) : ( -
-

- Output -

- {entry.text && ( -

- {entry.text} -

- )} - {preview.length > 0 && ( -
- {preview.map((item, index) => - item.type === 'image' ? ( - {entry.label - ) : item.type === 'audio' ? ( -
- )} - {entry.artifacts.length > 0 && ( -
- {entry.artifacts.map((artifact) => - artifact.mimeType.startsWith('image/') ? ( - {artifact.name} - ) : artifact.mimeType.startsWith('audio/') ? ( -
- )} - {!entry.text && !hasMedia && ( -

- The output wasn't stored — media bytes are never persisted, and - this run's preview was missing or too large for localStorage. Only - the run's metadata survives. -

- )} -
- )} - - {entry.model && ( -

model: {entry.model}

- )} -
- ) -} - -/** - * The previous-runs list every generation page shows. Entries are recorded by - * `generationRunPersistence()` (see `lib/generation-runs.ts`) whenever a run - * finishes or fails, and survive reloads in localStorage. Click an entry to - * see what was generated: the input, text output, and any media preview the - * route captured at result time. Pass `kind` to show one activity's runs, or - * omit it to show everything. - */ -export function GenerationRunHistory({ kind }: { kind?: GenerationKind }) { - const runs = useGenerationRuns(kind) - const [expandedId, setExpandedId] = useState(null) - - return ( -
-
-

- Previous runs - {runs.length > 0 && ( - - {runs.length} - - )} -

- {runs.length > 0 && ( - - )} -
- - {runs.length === 0 ? ( -

- No previous runs yet. Every generation on this page is persisted to - localStorage; finished runs are listed here and survive a reload. -

- ) : ( -
    - {runs.map((entry) => { - const expanded = expandedId === entry.entryId - return ( -
  • - - {expanded && } -
  • - ) - })} -
- )} -
- ) -} diff --git a/examples/ts-react-chat/src/lib/generation-persistence.ts b/examples/ts-react-chat/src/lib/generation-persistence.ts new file mode 100644 index 000000000..09b95e3a7 --- /dev/null +++ b/examples/ts-react-chat/src/lib/generation-persistence.ts @@ -0,0 +1,14 @@ +import { localStoragePersistence } from '@tanstack/ai-client' +import type { GenerationPersistence } from '@tanstack/ai-client' + +/** + * Shared generation persistence for the example app. + * + * Every generation route wires its hooks through this adapter. The client + * namespaces its record under `generation:`, so one adapter serves + * every hook as long as each hook passes a stable `id` — each hook's last + * run (status, result metadata, error — never media bytes) then survives a + * full page reload exactly as the library intends. + */ +export const generationPersistence: GenerationPersistence = + localStoragePersistence({ keyPrefix: 'example:' }) diff --git a/examples/ts-react-chat/src/lib/generation-runs.ts b/examples/ts-react-chat/src/lib/generation-runs.ts deleted file mode 100644 index 2f0fb4336..000000000 --- a/examples/ts-react-chat/src/lib/generation-runs.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { useSyncExternalStore } from 'react' -import { localStoragePersistence } from '@tanstack/ai-client' -import type { - GenerationPersistence, - GenerationResumeSnapshot, -} from '@tanstack/ai-client' - -/** - * Shared generation persistence + run history for the example app. - * - * Every generation route wires its hooks through `generationRunPersistence()`, - * which does two things: - * - * 1. Delegates to the standard `localStoragePersistence()` adapter, so each - * hook's last run (status, result metadata, error — never media bytes) - * survives a full page reload exactly as the library intends. - * 2. Watches the snapshots the client writes and, whenever one reaches a - * terminal status (`complete` / `error`), appends a compact entry to a - * single shared run-history list in localStorage. The `GenerationRunHistory` - * component renders that list, so every page shows its previous runs. - * - * The library itself only keeps the *last* snapshot per hook id — the history - * list is plain example-app code layered on top of the storage adapter - * contract, which is the point: run history is an app concern, and the adapter - * seam is where you build it. - */ - -export type GenerationKind = - | 'image' - | 'audio' - | 'speech' - | 'transcription' - | 'summarize' - | 'video' - -export interface GenerationRunArtifact { - url: string - name: string - mimeType: string -} - -export interface GenerationRunPreviewItem { - type: 'image' | 'audio' | 'video' - src: string -} - -export interface GenerationRunEntry { - entryId: string - kind: GenerationKind - /** Epoch millis when the run reached a terminal state. */ - at: number - status: 'complete' | 'error' - /** The prompt / input summary captured at generate time, when available. */ - label?: string - model?: string - /** Text output (a transcript or summary), when the activity produces text. */ - text?: string - error?: string - /** Durable artifact serve URLs, when the server persisted media. */ - artifacts: Array - /** - * Lightweight media previews captured at result time (remote URLs or small - * data: URLs), so clicking a history entry can show what was generated even - * though snapshots never persist media bytes. Oversized outputs are skipped. - */ - preview?: Array -} - -const HISTORY_STORAGE_KEY = 'example:generation-runs' -const MAX_ENTRIES = 30 -// Per-item ceiling for stored preview sources. A remote URL is tiny; a data: -// URL for a short audio clip or SVG fits comfortably; a full-size PNG's base64 -// does not and is skipped rather than blowing the localStorage quota. -const PREVIEW_SRC_MAX_CHARS = 300_000 -const PREVIEW_MAX_ITEMS = 4 - -// The base adapter every generation hook in this app shares. The client -// namespaces its record under `generation:`, so one adapter serves -// every hook as long as each hook passes a stable `id`. -const snapshotStore = localStoragePersistence({ - keyPrefix: 'example:', -}) - -// The most recent input per kind, captured by `rememberRunLabel()` just before -// `generate()` is called. Snapshots never contain the input (only run identity -// and result metadata), so the label rides along out-of-band. -const pendingLabels = new Map() - -// Media previews captured by `rememberRunPreview()` from `onResult` while the -// run is finishing; consumed when the terminal snapshot records the entry. -const pendingPreviews = new Map< - GenerationKind, - Array ->() - -// Last terminal snapshot recorded per hook id, to avoid double-recording when -// the client re-persists an unchanged terminal snapshot. -const recordedSnapshots = new Map() - -const listeners = new Set<() => void>() -let crossTabListenerAttached = false - -const EMPTY_ENTRIES: Array = [] -let cachedRaw: string | null = null -let cachedEntries: Array = EMPTY_ENTRIES -const cachedByKind = new Map>() - -function getLocalStorage(): Storage | null { - const browserGlobals: { localStorage?: Storage } = globalThis - return browserGlobals.localStorage ?? null -} - -function notify(): void { - for (const listener of listeners) listener() -} - -function subscribe(listener: () => void): () => void { - listeners.add(listener) - if (!crossTabListenerAttached && typeof window !== 'undefined') { - crossTabListenerAttached = true - window.addEventListener('storage', (event) => { - if (event.key === HISTORY_STORAGE_KEY) notify() - }) - } - return () => { - listeners.delete(listener) - } -} - -function isRunEntry(value: unknown): value is GenerationRunEntry { - return ( - typeof value === 'object' && - value !== null && - 'entryId' in value && - typeof value.entryId === 'string' && - 'kind' in value && - typeof value.kind === 'string' && - 'at' in value && - typeof value.at === 'number' && - 'status' in value && - (value.status === 'complete' || value.status === 'error') && - 'artifacts' in value && - Array.isArray(value.artifacts) - ) -} - -function readEntries(): Array { - const storage = getLocalStorage() - if (!storage) return EMPTY_ENTRIES - let raw: string | null = null - try { - raw = storage.getItem(HISTORY_STORAGE_KEY) - } catch { - return EMPTY_ENTRIES - } - if (raw === null) return EMPTY_ENTRIES - if (raw === cachedRaw) return cachedEntries - try { - const parsed: unknown = JSON.parse(raw) - cachedEntries = Array.isArray(parsed) ? parsed.filter(isRunEntry) : [] - } catch { - cachedEntries = [] - } - cachedRaw = raw - cachedByKind.clear() - return cachedEntries -} - -function writeEntries(entries: Array): void { - const storage = getLocalStorage() - if (!storage) return - // Best-effort with graceful degradation: if the write exceeds the quota, - // drop previews from all but the newest few entries and retry, then fall - // back to a shorter, preview-free list before giving up. - const attempts: Array> = [ - entries, - entries.map((entry, index) => - index < 3 || !entry.preview ? entry : { ...entry, preview: undefined }, - ), - entries.slice(0, 10).map((entry) => ({ ...entry, preview: undefined })), - ] - for (const attempt of attempts) { - try { - storage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(attempt)) - notify() - return - } catch { - continue - } - } -} - -function entriesForKind(kind: GenerationKind): Array { - const all = readEntries() - const cached = cachedByKind.get(kind) - if (cached) return cached - const filtered = all.filter((entry) => entry.kind === kind) - cachedByKind.set(kind, filtered) - return filtered -} - -function toRunEntry( - kind: GenerationKind, - snapshot: GenerationResumeSnapshot, -): GenerationRunEntry | null { - if (snapshot.status !== 'complete' && snapshot.status !== 'error') return null - - const artifacts: Array = [] - for (const ref of snapshot.result?.artifacts ?? - snapshot.pendingArtifacts ?? - []) { - if (ref.role === 'output' && ref.url) { - artifacts.push({ url: ref.url, name: ref.name, mimeType: ref.mimeType }) - } - } - - const label = pendingLabels.get(kind) - // Consume the pending preview: it belongs to this run only. A failed run - // gets no preview (its onResult never fired for this run's output). - const preview = pendingPreviews.get(kind) - pendingPreviews.delete(kind) - return { - entryId: `${kind}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - kind, - at: Date.now(), - status: snapshot.status, - ...(label ? { label } : {}), - ...(snapshot.result?.model ? { model: snapshot.result.model } : {}), - ...(snapshot.result?.text ? { text: snapshot.result.text } : {}), - ...(snapshot.error ? { error: snapshot.error.message } : {}), - artifacts, - ...(snapshot.status === 'complete' && preview && preview.length > 0 - ? { preview } - : {}), - } -} - -function recordTerminalSnapshot( - kind: GenerationKind, - hookId: string, - snapshot: GenerationResumeSnapshot, -): void { - const entry = toRunEntry(kind, snapshot) - if (!entry) return - // The client persists on every observed chunk; a terminal snapshot is - // written once per run, but guard against identical re-writes anyway. - const serialized = JSON.stringify({ - status: snapshot.status, - result: snapshot.result, - error: snapshot.error, - }) - if (recordedSnapshots.get(hookId) === serialized) return - recordedSnapshots.set(hookId, serialized) - writeEntries([entry, ...readEntries()].slice(0, MAX_ENTRIES)) -} - -/** - * A `GenerationPersistence` adapter for one generation kind: standard - * localStorage snapshot persistence, plus run-history recording. Pass a stable - * `id` to the hook alongside this adapter so a reload finds the same record. - */ -export function generationRunPersistence( - kind: GenerationKind, -): GenerationPersistence { - return { - getItem: (id) => snapshotStore.getItem(id), - setItem: (id, snapshot) => { - recordTerminalSnapshot(kind, id, snapshot) - return snapshotStore.setItem(id, snapshot) - }, - removeItem: (id) => snapshotStore.removeItem(id), - } -} - -/** - * Capture the user's input just before calling `generate()`, so the history - * entry for the finishing run can show what was asked for. Persisted snapshots - * deliberately carry no input, so this is the app's job. - */ -export function rememberRunLabel(kind: GenerationKind, label: string): void { - const trimmed = label.trim() - if (trimmed) pendingLabels.set(kind, trimmed.slice(0, 200)) - // A new run is starting — a preview left over from an earlier run must not - // attach to this one. - pendingPreviews.delete(kind) -} - -/** - * Capture what a finishing run generated, from the hook's `onResult`, so the - * history entry can show the output when clicked. Only remote http(s) URLs and - * small data: URLs are kept — a full-size image's base64 would blow the - * localStorage quota, so oversized items are dropped (the entry then shows - * metadata only). - */ -export function rememberRunPreview( - kind: GenerationKind, - items: Array, -): void { - const usable = items - .filter( - (item) => - (item.src.startsWith('https://') || - item.src.startsWith('http://') || - item.src.startsWith('data:')) && - item.src.length <= PREVIEW_SRC_MAX_CHARS, - ) - .slice(0, PREVIEW_MAX_ITEMS) - if (usable.length > 0) pendingPreviews.set(kind, usable) -} - -/** Previous runs, newest first — all kinds, or one kind when given. */ -export function useGenerationRuns( - kind?: GenerationKind, -): Array { - return useSyncExternalStore( - subscribe, - () => (kind ? entriesForKind(kind) : readEntries()), - () => EMPTY_ENTRIES, - ) -} - -/** Remove history entries — one kind's, or everything when omitted. */ -export function clearGenerationRuns(kind?: GenerationKind): void { - if (!kind) { - const storage = getLocalStorage() - if (!storage) return - try { - storage.removeItem(HISTORY_STORAGE_KEY) - } catch { - return - } - cachedRaw = null - cachedEntries = EMPTY_ENTRIES - cachedByKind.clear() - notify() - return - } - writeEntries(readEntries().filter((entry) => entry.kind !== kind)) -} diff --git a/examples/ts-react-chat/src/lib/server-fns.ts b/examples/ts-react-chat/src/lib/server-fns.ts index 9b8e17223..a86e72732 100644 --- a/examples/ts-react-chat/src/lib/server-fns.ts +++ b/examples/ts-react-chat/src/lib/server-fns.ts @@ -1,4 +1,4 @@ -import { createServerFn } from '@tanstack/react-start' +import { createServerFn } from '@tanstack/react-start' import { z } from 'zod' import { chat, @@ -11,12 +11,8 @@ import { summarize, toServerSentEventsResponse, } from '@tanstack/ai' -import { - openaiImage, - openaiSummarize, - openaiText, - openaiVideo, -} from '@tanstack/ai-openai' +import { openaiSummarize, openaiText } from '@tanstack/ai-openai' +import { grokImage, grokVideo } from '@tanstack/ai-grok' import type { UIMessage } from '@tanstack/ai' import { InvalidModelOverrideError, @@ -109,7 +105,7 @@ export const generateImageFn = createServerFn({ method: 'POST' }) ) .handler(async ({ data }) => { return generateImage({ - adapter: openaiImage('gpt-image-1'), + adapter: grokImage('grok-imagine-image'), prompt: data.prompt, numberOfImages: data.numberOfImages, size: data.size as any, @@ -227,7 +223,7 @@ export const generateVideoFn = createServerFn({ method: 'POST' }) }), ) .handler(async ({ data }) => { - const adapter = openaiVideo('sora-2') + const adapter = grokVideo('grok-imagine-video') // Create the job const { jobId } = await generateVideo({ @@ -280,7 +276,7 @@ export const generateImageStreamFn = createServerFn({ method: 'POST' }) .handler(({ data }) => { return toServerSentEventsResponse( generateImage({ - adapter: openaiImage('gpt-image-1'), + adapter: grokImage('grok-imagine-image'), prompt: data.prompt, numberOfImages: data.numberOfImages, size: data.size as any, @@ -386,7 +382,7 @@ export const generateVideoStreamFn = createServerFn({ method: 'POST' }) .handler(({ data }) => { return toServerSentEventsResponse( generateVideo({ - adapter: openaiVideo('sora-2'), + adapter: grokVideo('grok-imagine-video'), prompt: data.prompt, size: data.size as any, duration: data.duration, diff --git a/examples/ts-react-chat/src/routes/api.generate.image.ts b/examples/ts-react-chat/src/routes/api.generate.image.ts index 8ec1ed4ed..8c13700f3 100644 --- a/examples/ts-react-chat/src/routes/api.generate.image.ts +++ b/examples/ts-react-chat/src/routes/api.generate.image.ts @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiImage } from '@tanstack/ai-openai' +import { grokImage } from '@tanstack/ai-grok' export const Route = createFileRoute('/api/generate/image')({ server: { @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/generate/image')({ const { prompt, size, model, numberOfImages } = body.data const stream = generateImage({ - adapter: openaiImage(model ?? 'gpt-image-1'), + adapter: grokImage(model ?? 'grok-imagine-image'), prompt, size, numberOfImages, diff --git a/examples/ts-react-chat/src/routes/api.generate.video.ts b/examples/ts-react-chat/src/routes/api.generate.video.ts index 632c822b3..d26469fa6 100644 --- a/examples/ts-react-chat/src/routes/api.generate.video.ts +++ b/examples/ts-react-chat/src/routes/api.generate.video.ts @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { generateVideo, toServerSentEventsResponse } from '@tanstack/ai' -import { openaiVideo } from '@tanstack/ai-openai' +import { grokVideo } from '@tanstack/ai-grok' export const Route = createFileRoute('/api/generate/video')({ server: { @@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/generate/video')({ const { prompt, size, duration, model } = body.data const stream = generateVideo({ - adapter: openaiVideo(model ?? 'sora-2'), + adapter: grokVideo(model ?? 'grok-imagine-video'), prompt, size, duration, diff --git a/examples/ts-react-chat/src/routes/generation-hooks.tsx b/examples/ts-react-chat/src/routes/generation-hooks.tsx index 8d6f2feca..c5cab72bf 100644 --- a/examples/ts-react-chat/src/routes/generation-hooks.tsx +++ b/examples/ts-react-chat/src/routes/generation-hooks.tsx @@ -34,12 +34,7 @@ import type { TTSResult, } from '@tanstack/ai' import type { LucideIcon } from 'lucide-react' -import { - generationRunPersistence, - rememberRunLabel, - rememberRunPreview, -} from '../lib/generation-runs' -import { GenerationRunHistory } from '../components/GenerationRunHistory' +import { generationPersistence } from '../lib/generation-persistence' const SAMPLE_WAV_BASE64 = createToneWavBase64() const SAMPLE_AUDIO_DATA_URL = `data:audio/wav;base64,${SAMPLE_WAV_BASE64}` @@ -140,17 +135,6 @@ const summarizeConnection = createGenerationConnection( const videoConnection = createVideoConnection() -// Every hook persists its lightweight resume snapshot and records finished -// runs into the shared history list rendered at the bottom of the page. -const fixturePersistence = { - image: generationRunPersistence('image'), - audio: generationRunPersistence('audio'), - speech: generationRunPersistence('speech'), - transcription: generationRunPersistence('transcription'), - summarize: generationRunPersistence('summarize'), - video: generationRunPersistence('video'), -} - const SAMPLE_SUMMARY_TEXT = 'Generation hooks emit core devtools snapshots with input, progress, result, and renderable previews for media and text outputs.' @@ -172,62 +156,37 @@ function GenerationHooksPage() { const image = useGenerateImage({ id: 'generation-hooks:useGenerateImage', connection: imageConnection, - persistence: fixturePersistence.image, - onResult: (result) => { - rememberRunPreview( - 'image', - result.images.map((img) => ({ - type: 'image' as const, - src: img.url ?? '', - })), - ) - }, + persistence: generationPersistence, }) const audio = useGenerateAudio({ id: 'generation-hooks:useGenerateAudio', connection: audioConnection, - persistence: fixturePersistence.audio, - onResult: (result) => { - rememberRunPreview('audio', [ - { type: 'audio', src: result.audio.url ?? '' }, - ]) - }, + persistence: generationPersistence, }) const speech = useGenerateSpeech({ id: 'generation-hooks:useGenerateSpeech', connection: speechConnection, - persistence: fixturePersistence.speech, - onResult: (result) => { - rememberRunPreview('speech', [ - { - type: 'audio', - src: `data:${result.contentType ?? 'audio/wav'};base64,${result.audio}`, - }, - ]) - }, + persistence: generationPersistence, }) const transcription = useTranscription({ id: 'generation-hooks:useTranscription', connection: transcriptionConnection, - persistence: fixturePersistence.transcription, + persistence: generationPersistence, }) const summarize = useSummarize({ id: 'generation-hooks:useSummarize', connection: summarizeConnection, - persistence: fixturePersistence.summarize, + persistence: generationPersistence, }) const video = useGenerateVideo({ id: 'generation-hooks:useGenerateVideo', connection: videoConnection, - persistence: fixturePersistence.video, - onResult: (result) => { - rememberRunPreview('video', [{ type: 'video', src: result.url }]) - }, + persistence: generationPersistence, }) const loadingCount = [ @@ -240,33 +199,27 @@ function GenerationHooksPage() { ].filter(Boolean).length const runImage = () => { - rememberRunLabel('image', prompt) return image.generate({ prompt, numberOfImages: imageCount }) } const runAudio = () => { - rememberRunLabel('audio', prompt) return audio.generate({ prompt, duration: audioDuration }) } const runSpeech = () => { - rememberRunLabel('speech', speechText) return speech.generate({ text: speechText, voice: 'local' }) } const runTranscription = () => { - rememberRunLabel('transcription', 'Local fixture audio') return transcription.generate({ audio: SAMPLE_TRANSCRIPTION_AUDIO, language: 'en', }) } const runSummarize = () => { - rememberRunLabel('summarize', summaryText) return summarize.generate({ text: summaryText, style: 'bullet-points', }) } const runVideo = () => { - rememberRunLabel('video', prompt) return video.generate({ prompt }) } @@ -537,8 +490,6 @@ function GenerationHooksPage() { )} - - ) diff --git a/examples/ts-react-chat/src/routes/generations.audio.tsx b/examples/ts-react-chat/src/routes/generations.audio.tsx index 5671cd96c..643c12fc6 100644 --- a/examples/ts-react-chat/src/routes/generations.audio.tsx +++ b/examples/ts-react-chat/src/routes/generations.audio.tsx @@ -10,18 +10,12 @@ import { type AudioProviderConfig, type AudioProviderId, } from '../lib/audio-providers' -import { - generationRunPersistence, - rememberRunLabel, - rememberRunPreview, -} from '../lib/generation-runs' -import { GenerationRunHistory } from '../components/GenerationRunHistory' +import { generationPersistence } from '../lib/generation-persistence' type Mode = 'hooks' | 'server-fn' -// Persist each variant's lightweight resume snapshot across reloads and record -// finished runs into the shared history list rendered below the form. -const audioPersistence = generationRunPersistence('audio') +// Persist each variant's lightweight resume snapshot across reloads. +const audioPersistence = generationPersistence interface AudioOutput { url: string @@ -68,26 +62,6 @@ function toAudioOutput(raw: AudioGenerationResult): AudioOutput | null { return null } -// Capture what was generated (a remote URL, or a small data: URL — never a -// session-only blob: URL) so clicking the history entry can replay it, then -// hand off to the normal UI transform. -function toAudioOutputWithPreview( - raw: AudioGenerationResult, -): AudioOutput | null { - const { audio } = raw - rememberRunPreview('audio', [ - { - type: 'audio', - src: - audio.url ?? - (audio.b64Json - ? `data:${audio.contentType ?? 'audio/mpeg'};base64,${audio.b64Json}` - : ''), - }, - ]) - return toAudioOutput(raw) -} - function AudioGenerationForm({ mode, config, @@ -108,7 +82,7 @@ function AudioGenerationForm({ connection: fetchServerSentEvents('/api/generate/audio'), body: { provider: config.id, model: selectedModel }, persistence: audioPersistence, - onResult: toAudioOutputWithPreview, + onResult: toAudioOutput, } } return { @@ -118,7 +92,7 @@ function AudioGenerationForm({ data: { ...input, provider: config.id, model: selectedModel }, }), persistence: audioPersistence, - onResult: toAudioOutputWithPreview, + onResult: toAudioOutput, } }, [mode, config.id, selectedModel]) @@ -162,7 +136,6 @@ function AudioGenerationUI({ }) { const handleGenerate = () => { if (!prompt.trim()) return - rememberRunLabel('audio', prompt) generate({ prompt: prompt.trim(), duration }) } @@ -383,7 +356,6 @@ function AudioGenerationPage() { mode={mode} config={config} /> - diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 4ac8ad1e0..71deb520c 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -5,35 +5,14 @@ import type { UseGenerateImageReturn } from '@tanstack/ai-react' import { fetchServerSentEvents } from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' -import { - generationRunPersistence, - rememberRunLabel, - rememberRunPreview, -} from '../lib/generation-runs' -import { GenerationRunHistory } from '../components/GenerationRunHistory' -import type { ImageGenerationResult } from '@tanstack/ai' +import { generationPersistence } from '../lib/generation-persistence' // Every variant persists its lightweight resume snapshot (run identity, -// status, errors, result metadata — never image bytes) and records finished -// runs into the shared history list. The client namespaces its record under -// `generation:` and reads it back on mount, repainting the hook's normal -// `status` / `result` / `error` fields so the last run's outcome survives a -// full page reload. -const imagePersistence = generationRunPersistence('image') - -// Capture what was generated so clicking the history entry can show it. -// Remote URLs store as-is; base64 payloads become data: URLs but oversized -// ones are dropped by the size guard in `rememberRunPreview`. -function recordImagePreview(result: ImageGenerationResult) { - rememberRunPreview( - 'image', - result.images.map((img) => ({ - type: 'image' as const, - src: - img.url ?? (img.b64Json ? `data:image/png;base64,${img.b64Json}` : ''), - })), - ) -} +// status, errors, result metadata — never image bytes). The client namespaces +// its record under `generation:` and reads it back on mount, repainting +// the hook's normal `status` / `result` / `error` fields so the last run's +// outcome survives a full page reload. +const imagePersistence = generationPersistence function StreamingImageGeneration() { const [prompt, setPrompt] = useState('') @@ -43,7 +22,6 @@ function StreamingImageGeneration() { id: 'image:streaming', connection: fetchServerSentEvents('/api/generate/image'), persistence: imagePersistence, - onResult: recordImagePreview, }) return ( @@ -68,7 +46,6 @@ function DirectImageGeneration() { data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, }), persistence: imagePersistence, - onResult: recordImagePreview, }) return ( @@ -93,7 +70,6 @@ function ServerFnImageGeneration() { data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, }), persistence: imagePersistence, - onResult: recordImagePreview, }) return ( @@ -125,7 +101,6 @@ function ImageGenerationUI({ }) { const handleGenerate = () => { if (!prompt.trim()) return - rememberRunLabel('image', prompt) generate({ prompt: prompt.trim(), numberOfImages }) } @@ -220,7 +195,7 @@ function ImageGenerationPage() {

Image Generation

- Generate images using OpenAI's image models + Generate images using xAI's Grok Imagine models

@@ -267,7 +242,6 @@ function ImageGenerationPage() { ) : ( )} -
diff --git a/examples/ts-react-chat/src/routes/generations.speech.tsx b/examples/ts-react-chat/src/routes/generations.speech.tsx index 4638e27a4..7631b5768 100644 --- a/examples/ts-react-chat/src/routes/generations.speech.tsx +++ b/examples/ts-react-chat/src/routes/generations.speech.tsx @@ -9,20 +9,14 @@ import { type SpeechProviderConfig, type SpeechProviderId, } from '../lib/audio-providers' -import { - generationRunPersistence, - rememberRunLabel, - rememberRunPreview, -} from '../lib/generation-runs' -import { GenerationRunHistory } from '../components/GenerationRunHistory' +import { generationPersistence } from '../lib/generation-persistence' type SpeechOutput = { audioUrl: string; format?: string; duration?: number } type Mode = 'streaming' | 'direct' | 'server-fn' -// Persist each variant's lightweight resume snapshot across reloads and record -// finished runs into the shared history list rendered below the form. -const speechPersistence = generationRunPersistence('speech') +// Persist each variant's lightweight resume snapshot across reloads. +const speechPersistence = generationPersistence function toSpeechOutput(raw: { audio: string @@ -45,25 +39,6 @@ function toSpeechOutput(raw: { } } -// Capture what was generated as a small data: URL (never the session-only -// blob: URL the UI plays) so clicking the history entry can replay it, then -// hand off to the normal UI transform. Oversized clips are dropped by the -// size guard in `rememberRunPreview`. -function toSpeechOutputWithPreview(raw: { - audio: string - contentType?: string - format?: string - duration?: number -}): SpeechOutput { - rememberRunPreview('speech', [ - { - type: 'audio', - src: `data:${raw.contentType ?? 'audio/mpeg'};base64,${raw.audio}`, - }, - ]) - return toSpeechOutput(raw) -} - function SpeechGenerationForm({ mode, config, @@ -81,7 +56,7 @@ function SpeechGenerationForm({ connection: fetchServerSentEvents('/api/generate/speech'), body: { provider: config.id }, persistence: speechPersistence, - onResult: toSpeechOutputWithPreview, + onResult: toSpeechOutput, } } if (mode === 'direct') { @@ -92,7 +67,7 @@ function SpeechGenerationForm({ data: { ...input, provider: config.id }, }), persistence: speechPersistence, - onResult: toSpeechOutputWithPreview, + onResult: toSpeechOutput, } } return { @@ -102,7 +77,7 @@ function SpeechGenerationForm({ data: { ...input, provider: config.id }, }), persistence: speechPersistence, - onResult: toSpeechOutputWithPreview, + onResult: toSpeechOutput, } }, [mode, config.id]) @@ -140,7 +115,6 @@ function SpeechGenerationUI({ }) { const handleGenerate = () => { if (!text.trim()) return - rememberRunLabel('speech', text) generate({ text: text.trim(), voice }) } @@ -313,7 +287,6 @@ function SpeechGenerationPage() { mode={mode} config={config} /> - diff --git a/examples/ts-react-chat/src/routes/generations.summarize.tsx b/examples/ts-react-chat/src/routes/generations.summarize.tsx index 3353990d4..c8627a9b0 100644 --- a/examples/ts-react-chat/src/routes/generations.summarize.tsx +++ b/examples/ts-react-chat/src/routes/generations.summarize.tsx @@ -5,15 +5,10 @@ import type { UseSummarizeReturn } from '@tanstack/ai-react' import { fetchServerSentEvents } from '@tanstack/ai-client' import { summarizeFn, summarizeStreamFn } from '../lib/server-fns' import type { StreamChunk } from '@tanstack/ai' -import { - generationRunPersistence, - rememberRunLabel, -} from '../lib/generation-runs' -import { GenerationRunHistory } from '../components/GenerationRunHistory' +import { generationPersistence } from '../lib/generation-persistence' -// Persist each variant's lightweight resume snapshot across reloads and record -// finished runs into the shared history list rendered below the form. -const summarizePersistence = generationRunPersistence('summarize') +// Persist each variant's lightweight resume snapshot across reloads. +const summarizePersistence = generationPersistence const SAMPLE_TEXT = `Artificial intelligence (AI) has rapidly transformed from a niche academic pursuit into one of the most influential technologies of the 21st century. The development of large language models, in particular, has demonstrated capabilities that were previously thought to be decades away. These models can generate human-like text, translate languages, write code, and even engage in complex reasoning tasks. @@ -171,7 +166,6 @@ function SummarizeUI({ }) { const handleSummarize = () => { if (!text.trim()) return - rememberRunLabel('summarize', text) onBeforeGenerate?.() // Intentionally no `maxLength` — for the OpenAI Responses API, // `maxLength` is mapped to `max_output_tokens`, which on GPT-5.x @@ -344,7 +338,6 @@ function SummarizePage() { ) : ( )} - diff --git a/examples/ts-react-chat/src/routes/generations.transcription.tsx b/examples/ts-react-chat/src/routes/generations.transcription.tsx index 583067ed5..b31590fc7 100644 --- a/examples/ts-react-chat/src/routes/generations.transcription.tsx +++ b/examples/ts-react-chat/src/routes/generations.transcription.tsx @@ -10,17 +10,12 @@ import type { } from '../lib/audio-providers' import type { UseTranscriptionReturn } from '@tanstack/ai-react' import type { TranscriptionGenerateInput } from '@tanstack/ai-client' -import { - generationRunPersistence, - rememberRunLabel, -} from '../lib/generation-runs' -import { GenerationRunHistory } from '../components/GenerationRunHistory' +import { generationPersistence } from '../lib/generation-persistence' type Mode = 'streaming' | 'direct' | 'server-fn' -// Persist each variant's lightweight resume snapshot across reloads and record -// finished runs into the shared history list rendered below the form. -const transcriptionPersistence = generationRunPersistence('transcription') +// Persist each variant's lightweight resume snapshot across reloads. +const transcriptionPersistence = generationPersistence function TranscriptionForm({ mode, @@ -94,7 +89,6 @@ function TranscriptionUI({ ) const dataUrl = `data:${file.type};base64,${base64}` - rememberRunLabel('transcription', file.name) await generate({ audio: dataUrl, language: 'en', @@ -262,7 +256,6 @@ function TranscriptionPage() { mode={mode} config={config} /> - diff --git a/examples/ts-react-chat/src/routes/generations.video.tsx b/examples/ts-react-chat/src/routes/generations.video.tsx index ffd9c7e36..b2dbed910 100644 --- a/examples/ts-react-chat/src/routes/generations.video.tsx +++ b/examples/ts-react-chat/src/routes/generations.video.tsx @@ -5,23 +5,11 @@ import type { UseGenerateVideoReturn } from '@tanstack/ai-react' import { fetchServerSentEvents } from '@tanstack/ai-client' import { resolveMediaPrompt } from '@tanstack/ai' import { generateVideoFn, generateVideoStreamFn } from '../lib/server-fns' -import { - generationRunPersistence, - rememberRunLabel, - rememberRunPreview, -} from '../lib/generation-runs' -import { GenerationRunHistory } from '../components/GenerationRunHistory' -import type { VideoGenerateResult } from '@tanstack/ai-client' +import { generationPersistence } from '../lib/generation-persistence' -// Persist each variant's lightweight resume snapshot across reloads and record -// finished runs into the shared history list rendered below the form. For a +// Persist each variant's lightweight resume snapshot across reloads. For a // long video run this keeps the job id around after a reload. -const videoPersistence = generationRunPersistence('video') - -// Capture the generated video's URL so clicking the history entry can play it. -function recordVideoPreview(result: VideoGenerateResult) { - rememberRunPreview('video', [{ type: 'video', src: result.url }]) -} +const videoPersistence = generationPersistence function StreamingVideoGeneration() { const [prompt, setPrompt] = useState('') @@ -30,7 +18,6 @@ function StreamingVideoGeneration() { id: 'video:streaming', connection: fetchServerSentEvents('/api/generate/video'), persistence: videoPersistence, - onResult: recordVideoPreview, }) return ( @@ -48,7 +35,6 @@ function DirectVideoGeneration() { data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, }), persistence: videoPersistence, - onResult: recordVideoPreview, }) return ( @@ -66,7 +52,6 @@ function ServerFnVideoGeneration() { data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, }), persistence: videoPersistence, - onResult: recordVideoPreview, }) return ( @@ -91,7 +76,6 @@ function VideoGenerationUI({ }) { const handleGenerate = () => { if (!prompt.trim()) return - rememberRunLabel('video', prompt) generate({ prompt: prompt.trim() }) } @@ -99,8 +83,8 @@ function VideoGenerationUI({

- Video generation is experimental and requires Sora API access in your - OpenAI account. Generation can take several minutes. + Video generation is experimental and requires an XAI_API_KEY with + Grok Imagine access. Generation can take several minutes.

@@ -200,7 +184,7 @@ function VideoGenerationPage() {

Video Generation

- Generate videos using OpenAI Sora (experimental) + Generate videos using xAI Grok Imagine (experimental)

@@ -247,7 +231,6 @@ function VideoGenerationPage() { ) : ( )} -
From 3df8aba761ca85b9428d6cbf6ccff8233bb27039 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:23:31 +0000 Subject: [PATCH 33/66] ci: apply automated fixes --- examples/ts-react-chat/src/routes/generations.video.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/ts-react-chat/src/routes/generations.video.tsx b/examples/ts-react-chat/src/routes/generations.video.tsx index b2dbed910..f269f4684 100644 --- a/examples/ts-react-chat/src/routes/generations.video.tsx +++ b/examples/ts-react-chat/src/routes/generations.video.tsx @@ -83,8 +83,8 @@ function VideoGenerationUI({

- Video generation is experimental and requires an XAI_API_KEY with - Grok Imagine access. Generation can take several minutes. + Video generation is experimental and requires an XAI_API_KEY with Grok + Imagine access. Generation can take several minutes.

From 7d87a30f73c7b2ba382e2c11e139d745ded00860 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:34:35 +1000 Subject: [PATCH 34/66] fix(persistence): don't fetch caller-supplied prompt URLs + review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/generation-persistence.md | 4 +- docs/media/video-generation.md | 6 +- docs/persistence/client-persistence.md | 8 +- docs/persistence/keep-generated-files.md | 56 ++++- packages/ai-client/src/connection-adapters.ts | 8 +- packages/ai-client/src/generation-client.ts | 13 ++ packages/ai-client/src/generation-types.ts | 5 +- .../skills/ai-persistence/SKILL.md | 17 ++ .../build-cloudflare-artifact-store/SKILL.md | 4 + packages/ai-persistence/src/middleware.ts | 217 +++++++++++++++++- .../tests/generation-artifacts.test.ts | 191 +++++++++++++++ .../ai-core/client-persistence/SKILL.md | 52 +++++ 12 files changed, 558 insertions(+), 23 deletions(-) diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index 15ec6bae5..b27c8c495 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -15,9 +15,9 @@ Add generation persistence, mirroring chat: media generation runs survive a relo **Generation run store (server).** `withGenerationPersistence` records each run in a dedicated `generationRuns` (`GenerationRunStore`) store, keyed by the run's own `runId` (the same AG-UI run id the client sends), with `threadId` only an optional link — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `generationRuns` store, and `defineGenerationRunStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. -**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?runId=` (or `?threadId=`) from the request, authorizes it via an optional `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `generationRuns` store. +**Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?runId=` (or `?threadId=`) from the request, authorizes it via an `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `generationRuns` store. `authorize` is optional at the type level for single-user and prototype routes, but any multi-user deployment must pass it: the run and thread ids arrive from the caller, so identity has to be derived from server-side session state and ownership checked before the helper reads persistence. The same applies to a route that serves artifact bytes by id. -**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the run record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. +**Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the run record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. Prompt media referenced by **URL** is not downloaded: the URL is caller-supplied, so fetching it server-side would be an SSRF vector, and the bytes are redundant. Opt in per-app with `allowInputUrl` (a predicate, so the check can't be skipped). Every artifact fetch is limited to `http:`/`https:`, timed out (`artifactFetchTimeoutMs`, default 30s) and size-capped (`maxArtifactBytes`, default 100 MiB); input fetches additionally block loopback/private/link-local hosts and refuse redirects. `artifactFetch` injects the `fetch` used, for routing downloads through an egress-restricted proxy. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. **Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take the same `persistence` option chat does: `true` (server-driven — cache nothing, hydrate the last run for a stable `threadId` on mount) or a storage adapter (client-driven — write a lightweight snapshot under `generation:` as the run streams and read it back on mount). Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and keeps `resumeState` for the in-flight run identity — there is no `resumeSnapshot` / `pendingArtifacts` / `resultArtifacts` hook field. If a run is still generating when the connection drops or the page reloads, the client re-attaches to it and finishes it in place (via the connection's `joinRun` durability replay), exactly like `useChat`. 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. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. diff --git a/docs/media/video-generation.md b/docs/media/video-generation.md index 518ae5757..4e1cec9b3 100644 --- a/docs/media/video-generation.md +++ b/docs/media/video-generation.md @@ -48,8 +48,10 @@ Currently supported: > **Video runs take minutes — don't lose them to a reload.** This is the > strongest case for [Generation Persistence](../persistence/generation-persistence): -> it keeps a record of each job so a page reload or dropped connection picks the -> run back up instead of starting over. And because provider video URLs expire, +> it keeps a record of each run, so after a reload the hook shows that run's last +> known status and result instead of an empty form. A run that is still streaming +> against a durable server-side stream is re-attached and finished in place; +> otherwise the record is restored, not the provider work. And because provider video URLs expire, > [keep the finished clip](../persistence/keep-generated-files) by saving its > bytes to your own storage. diff --git a/docs/persistence/client-persistence.md b/docs/persistence/client-persistence.md index cf65c4a06..008d6ccb0 100644 --- a/docs/persistence/client-persistence.md +++ b/docs/persistence/client-persistence.md @@ -72,9 +72,11 @@ pointer. On the next load `useChat` reads it and: The generation hooks (`useGenerateImage` / `useGenerateVideo` / …) make the same choice: `persistence: true` is server-driven and a storage adapter is -client-driven, so a long media run survives a reload the way a conversation does. -See [Generation persistence](./generation-persistence) for the generation-specific -setup. +client-driven. A reload restores the last known `status` / `result` / `error` +for the run — it does not restart provider work. Only a run that is still +streaming against a server-side durable stream can be re-attached and finished +in place. See [Generation persistence](./generation-persistence) for the +generation-specific setup. ### An adapter: client-authoritative diff --git a/docs/persistence/keep-generated-files.md b/docs/persistence/keep-generated-files.md index 8554bf79a..95deb9514 100644 --- a/docs/persistence/keep-generated-files.md +++ b/docs/persistence/keep-generated-files.md @@ -14,7 +14,7 @@ provider's link. This is a server-side opt-in that layers on **top of** the `generationRuns` store [Generation persistence](./generation-persistence) already requires. Byte storage adds two more stores: an `artifacts` store (metadata) and a `blobs` store (the -bytes). They are a both-or-neither pair — provide both and +bytes). The two must be provided together — provide both and `withGenerationPersistence` writes each generated file's bytes to the blob store, records an `ArtifactRecord`, and attaches durable references to the result and the run record; provide neither and only the run record is kept. @@ -73,6 +73,12 @@ export async function POST(request: Request) { // Serve a stored artifact's bytes by id. This is a plain file endpoint: it // serves one stored file, it does not resume a run or rebuild a conversation. +// +// Security: the id comes from the caller, so this route MUST authorize before +// it serves. `ArtifactRecord` carries the `threadId` / `runId` the file was +// generated under — check that against an identity you derive server-side from +// the session, never from the query string. Without this check, any caller who +// learns or guesses an artifact id can read another user's media. export async function GET(request: Request) { const artifactId = new URL(request.url).searchParams.get('id') if (!artifactId) return new Response('missing id', { status: 400 }) @@ -80,6 +86,14 @@ export async function GET(request: Request) { const artifact = await retrieveArtifact(persistence, artifactId) if (!artifact) return new Response('not found', { status: 404 }) + // Replace with your session + ownership check, e.g.: + // const user = await auth(request) + // const owned = user != null && (await db.threadOwnedBy(user.id, artifact.threadId)) + const owned = true + void request + // 404, not 403 — a distinguishable "exists but forbidden" confirms valid ids. + if (!owned) return new Response('not found', { status: 404 }) + const blob = await retrieveBlob(persistence, artifact) if (!blob) return new Response('not found', { status: 404 }) @@ -98,6 +112,42 @@ for production. Control what gets captured with `withGenerationPersistence`'s `extractArtifacts` (return your own descriptors) and `nameArtifact` (name each file) options. +## Prompt media referenced by URL + +What gets stored is the **generated output**. When a provider returns an +expiring link, the middleware downloads it and keeps the bytes — that is the +whole point of this page. + +Prompt media is different. Media you send *in* as base64 (`source: { type: +'data' }`) is stored alongside the output, because the bytes are already in +hand. Media you send in as a **URL** (`source: { type: 'url' }`) is **not +fetched**, and no artifact is recorded for it. That URL comes from the caller, +so downloading it server-side would let anyone name an address your server can +reach — cloud metadata endpoints, `localhost` admin services — and then read the +response back through the artifact `GET` route. The copy is also redundant: +whoever supplied the URL already had the media. + +If you do need a durable copy of caller-supplied media — a "paste an image URL" +input box, say — opt in with `allowInputUrl`, which is a predicate rather than a +flag precisely so the check is not optional: + +```ts group=generation-bytes +const inputUrlOptions = withGenerationPersistence(persistence, { + allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'), +}) +``` + +Every artifact fetch — input or output — is limited to `http:` / `https:`, +aborted after `artifactFetchTimeoutMs` (default 30s), and capped at +`maxArtifactBytes` (default 100 MiB) as the body drains. Input fetches add a +loopback / private / link-local host block and refuse redirects, so a `302` +cannot hop somewhere the check never saw. + +Treat those as a backstop, not the control: a hostname that *resolves* to a +private address still passes a literal-IP check. Keep `allowInputUrl` narrow, +and for stronger isolation inject `artifactFetch` to route downloads through an +egress-restricted proxy that can check the address actually connected to. + ## Wire the durable URL through to the client `artifactUrl` is what makes the stored bytes reachable from the client without @@ -119,7 +169,7 @@ are no separate top-level artifact fields on the hook, final refs live on ## Where to go next - [Generation persistence](./generation-persistence): the two-mode record that - survives a reload or a dropped connection, and the `generationRuns` store byte storage - builds on. + survives a reload or a dropped connection, and the `generationRuns` store that + byte storage builds on. - [Build your own adapter](./build-your-own-adapter#generation--media-stores): a custom `ArtifactStore` / `BlobStore` on your own database. diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 1dc86dacc..0d9d60e69 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -473,7 +473,13 @@ async function fetchGenerationHydration( credentials, }) assertResponseOk(response) - const data = (await response.json()) as { + const raw: unknown = await response.json() + // A 200 carrying `null` (or any non-object body) is a hydration miss, not a + // crash: reading `.activeRun` off `null` would throw here. + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { resumeSnapshot: null, activeRun: null } + } + const data = raw as { resumeSnapshot?: GenerationHydrationResult['resumeSnapshot'] activeRun?: { runId?: unknown } | null } diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 824dd5ec6..3cfeb1941 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -116,6 +116,7 @@ export class GenerationClient< private resumeSnapshotPersistenceQueue: Promise = Promise.resolve() private resumeSnapshotHydration: Promise | undefined private queuedSnapshotSignature: string | undefined + private lastEmittedResumeState: string | undefined private resumePersistenceError: Error | undefined = undefined private abortController: AbortController | null = null private rejoinedRunId: string | undefined @@ -616,6 +617,12 @@ export class GenerationClient< * Derive the public `resumeState` from the internal snapshot: the in-flight * run identity, with any in-flight artifact refs folded under it. `null` once * no run is in flight. + * + * The snapshot is rebuilt for every chunk, so emitting unconditionally would + * hand each framework hook a fresh object per chunk and re-render the + * component on every stream event. `resumeState` only changes at run + * boundaries and when artifacts land, so skip the notification unless it + * materially changed — same gate the persistence writes use. */ private emitResumeState(): void { const snapshot = this.resumeSnapshot @@ -628,6 +635,11 @@ export class GenerationClient< : {}), } : null + const signature = JSON.stringify(resumeState) + if (signature === this.lastEmittedResumeState) { + return + } + this.lastEmittedResumeState = signature this.callbacksRef.onResumeStateChange?.(resumeState) } @@ -741,6 +753,7 @@ export class GenerationClient< private clearResumeSnapshot(): void { this.resumeSnapshot = undefined this.queuedSnapshotSignature = undefined + this.lastEmittedResumeState = undefined this.notifyResumeSnapshotChanged() if (!this.resumePersistence) { return diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index 2954613f4..ddf3164fd 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -719,7 +719,10 @@ export function createGenerationResultSnapshot( const expiresAt = Reflect.get(value, 'expiresAt') if (typeof expiresAt === 'string') { snapshot.expiresAt = expiresAt - } else if (expiresAt instanceof Date) { + } else if (expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())) { + // `toISOString()` throws on an invalid Date. This runs per chunk on live + // provider values, so drop an unusable date like every other bad field + // here rather than throwing out of the stream loop. snapshot.expiresAt = expiresAt.toISOString() } if (artifacts.length > 0) { diff --git a/packages/ai-persistence/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md index 86199d772..ea3dfb329 100644 --- a/packages/ai-persistence/skills/ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -62,6 +62,23 @@ a generation is only an optional _link_ to a chat, never the job's identity. To build the R2/D1-backed byte stores for a Worker, see **ai-persistence/build-cloudflare-artifact-store**. +**Byte storage stores generated output, not prompt URLs.** Provider result URLs +expire, so they are downloaded and kept. Prompt media sent as base64 +(`source: { type: 'data' }`) is stored too. Prompt media sent as a **URL** is +NOT fetched — that URL is caller-supplied, so downloading it server-side is an +SSRF vector, and the bytes are redundant. Apps that genuinely need a durable +copy opt in with `allowInputUrl`, a predicate so the check can't be skipped: +`allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com')`. Never +suggest `() => true`. All artifact fetches are http/https-only, timed out +(`artifactFetchTimeoutMs`) and size-capped (`maxArtifactBytes`); input fetches +also block loopback/private/link-local hosts and refuse redirects. `artifactFetch` +injects the `fetch`, for routing through an egress-restricted proxy. + +Two related route-level rules: a `GET` that serves artifact bytes by id MUST +authorize the caller against `ArtifactRecord.threadId` before serving (404, not +403, so valid ids aren't confirmed), and `reconstructGeneration` MUST be given +`authorize` on any multi-user route. Both take ids straight from the caller. + ## Sub-skills | Need to... | Read | diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index 99e564a92..ede73f71b 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -123,6 +123,10 @@ export function r2BlobStore(bucket: R2Bucket) { }, async list(options) { + // R2 reads `limit: 0` as "use the default", so short-circuit it. + if (options?.limit === 0) { + return { objects: [] } + } const page = await bucket.list({ ...(options?.prefix !== undefined ? { prefix: options.prefix } : {}), ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}), diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index bf4fa9240..348835866 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -59,8 +59,50 @@ export interface WithPersistenceOptions { * Return `undefined` to leave a ref without a durable URL. */ artifactUrl?: (ref: PersistedArtifactRef) => string | undefined + /** + * Opt in to fetching prompt media referenced by URL (`role: 'input'`). + * + * Off by default, and deliberately expressed as a predicate rather than a + * boolean: input URLs come from the caller, so fetching them server-side + * turns your server into a proxy for whatever the caller names — cloud + * metadata endpoints, `localhost` admin services, anything your network can + * reach. The bytes are also redundant in the common case, since the client + * already had the media it referenced. + * + * Enable this only when you genuinely need a durable copy of caller-supplied + * media (a "paste an image URL" input box, say), and validate the target: + * + * ```ts + * allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com') + * ``` + * + * Requests are additionally forced through the same baseline checks every + * artifact fetch gets (http/https only, timeout, size cap), plus — because + * the target is untrusted — a loopback/private/link-local host block and + * `redirect: 'manual'` so a 302 cannot hop to an internal address. Those are + * a backstop, not a substitute for a narrow predicate: a hostname that + * resolves to a private address still passes a literal-IP check. + */ + allowInputUrl?: (input: { + url: URL + descriptor: GenerationArtifactDescriptor + }) => boolean | Promise + /** Abort an artifact fetch after this many ms. Default 30_000. */ + artifactFetchTimeoutMs?: number + /** Refuse an artifact body larger than this many bytes. Default 100 MiB. */ + maxArtifactBytes?: number + /** + * `fetch` used to download artifact bytes. Defaults to the global. Inject to + * route downloads through a proxy or an egress-restricted agent — the most + * robust SSRF control available here, since it can resolve and check the + * address actually connected to. + */ + artifactFetch?: typeof globalThis.fetch } +const DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS = 30_000 +const DEFAULT_MAX_ARTIFACT_BYTES = 100 * 1024 * 1024 + export interface GenerationArtifactDescriptor { role: PersistedArtifactRole path: string @@ -344,7 +386,16 @@ function parseDataUrl( const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(value) if (!match) return undefined const mimeType = match[1] || 'application/octet-stream' - const payload = decodeURIComponent(match[3] ?? '') + const raw = match[3] ?? '' + // A plain (non-base64) data URL may carry a bare `%` (`data:text/plain,100%`), + // which makes `decodeURIComponent` throw. Fall back to the literal payload so + // a malformed escape doesn't fail the whole generation. + let payload: string + try { + payload = decodeURIComponent(raw) + } catch { + payload = raw + } return { mimeType, bytes: match[2] @@ -585,14 +636,97 @@ function builtInArtifactDescriptors( return descriptors } +/** + * Reject hosts that only make sense as an SSRF target: loopback, link-local + * (including the cloud metadata address), private, and unique-local ranges. + * + * Applied to caller-supplied input URLs only. Provider result URLs skip it on + * purpose — a self-hosted or local provider legitimately returns a `localhost` + * URL, and those live inside the same trust boundary as the adapter itself. + * + * This checks IP *literals*. A hostname that resolves to a private address + * passes, which is why `allowInputUrl` is required rather than optional. + */ +function isBlockedInputHost(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, '') + if (host === 'localhost' || host.endsWith('.localhost')) return true + + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host) + if (ipv4) { + const [a, b] = [Number(ipv4[1]), Number(ipv4[2])] + if (a === 127 || a === 0 || a === 10) return true + if (a === 169 && b === 254) return true // link-local + cloud metadata + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + return false + } + + if (host === '::' || host === '::1') return true + if (host.startsWith('fe80:')) return true // link-local + if (/^f[cd][0-9a-f]{2}:/.test(host)) return true // unique-local + // IPv4-mapped IPv6 — re-check the embedded address. `new URL()` normalizes + // `::ffff:127.0.0.1` to the hex form `::ffff:7f00:1`, so accept both. + const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec( + host, + ) + if (mappedDotted?.[1]) return isBlockedInputHost(mappedDotted[1]) + const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host) + if (mappedHex?.[1] && mappedHex[2]) { + const high = Number.parseInt(mappedHex[1], 16) + const low = Number.parseInt(mappedHex[2], 16) + return isBlockedInputHost( + `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`, + ) + } + return false +} + +/** + * Fail the stream once more than `maxBytes` have passed through, so an + * unexpectedly huge artifact can't fill the blob store. Enforced during the + * drain because `content-length` is advisory (and absent on chunked replies). + */ +function capBodySize( + body: ReadableStream, + maxBytes: number, + url: string, +): ReadableStream { + let seen = 0 + return body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + seen += chunk.byteLength + if (seen > maxBytes) { + controller.error( + new Error( + `Artifact at ${url} exceeds maxArtifactBytes (${maxBytes}).`, + ), + ) + return + } + controller.enqueue(chunk) + }, + }), + ) +} + +/** + * Resolve a descriptor to the bytes to store. Returns `undefined` when the + * descriptor is deliberately not persisted — today that means a caller-supplied + * input URL without an `allowInputUrl` opt-in. + */ async function descriptorBody( descriptor: GenerationArtifactDescriptor, -): Promise<{ - body: BlobBody - size: number - mimeType: string - externalUrl?: string -}> { + opts: WithPersistenceOptions | undefined, +): Promise< + | { + body: BlobBody + size: number + mimeType: string + externalUrl?: string + } + | undefined +> { if (descriptor.json !== undefined) { const body = JSON.stringify(descriptor.json) return { @@ -632,12 +766,65 @@ async function descriptorBody( mimeType: descriptor.mimeType ?? data.mimeType, } } - const response = await fetch(descriptor.url) + // A caller-controlled input URL is never fetched unless the app opted in + // with a validating predicate. Skipped, not thrown: not mirroring someone + // else's URL is the intended default, and the run itself is fine. + const isCallerSupplied = descriptor.role === 'input' + const allowInputUrl = opts?.allowInputUrl + if (isCallerSupplied && !allowInputUrl) return undefined + + let target: URL + try { + target = new URL(descriptor.url) + } catch { + throw new Error( + `Failed to persist artifact: ${descriptor.url} is not a valid URL.`, + ) + } + if (target.protocol !== 'https:' && target.protocol !== 'http:') { + throw new Error( + `Refusing to fetch artifact over ${target.protocol} (${descriptor.path}).`, + ) + } + if (allowInputUrl && isCallerSupplied) { + if (isBlockedInputHost(target.hostname)) { + throw new Error( + `Refusing to fetch input artifact from internal host ${target.hostname}.`, + ) + } + if (!(await allowInputUrl({ url: target, descriptor }))) { + throw new Error( + `Refusing to fetch input artifact from ${target.hostname}: rejected by allowInputUrl.`, + ) + } + } + + const maxBytes = opts?.maxArtifactBytes ?? DEFAULT_MAX_ARTIFACT_BYTES + const fetchArtifact = opts?.artifactFetch ?? globalThis.fetch + const response = await fetchArtifact(target, { + // Provider CDNs redirect routinely, so output fetches follow. An input + // fetch must not: a 302 would land on a host neither check ever saw. + redirect: isCallerSupplied ? 'manual' : 'follow', + signal: AbortSignal.timeout( + opts?.artifactFetchTimeoutMs ?? DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS, + ), + }) + if (isCallerSupplied && response.status >= 300 && response.status < 400) { + throw new Error( + `Refusing to follow a redirect for input artifact ${descriptor.path}.`, + ) + } if (!response.ok) { throw new Error( `Failed to persist artifact from ${descriptor.url}: HTTP ${response.status}`, ) } + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error( + `Artifact at ${descriptor.url} exceeds maxArtifactBytes (${maxBytes}).`, + ) + } const mimeType = descriptor.mimeType ?? response.headers.get('content-type') ?? @@ -648,13 +835,18 @@ async function descriptorBody( // buffering only when the response has no body to stream. if (response.body) { return { - body: response.body, + body: capBodySize(response.body, maxBytes, descriptor.url), size: 0, mimeType, externalUrl: descriptor.url, } } const body = await response.arrayBuffer() + if (body.byteLength > maxBytes) { + throw new Error( + `Artifact at ${descriptor.url} exceeds maxArtifactBytes (${maxBytes}).`, + ) + } return { body, size: body.byteLength, @@ -710,8 +902,11 @@ async function persistGenerationArtifacts( const refs: Array = [...existingRefs] for (const [index, descriptor] of descriptors.entries()) { const artifactId = ctx.createId('artifact') - const { body, size, mimeType, externalUrl } = - await descriptorBody(descriptor) + const resolved = await descriptorBody(descriptor, opts) + // Deliberately not persisted (an input URL with no `allowInputUrl` opt-in): + // no blob, no record, no ref — the rest of the run is unaffected. + if (!resolved) continue + const { body, size, mimeType, externalUrl } = resolved const key = artifactBlobKey({ runId, artifactId }) const stored = await persistence.stores.blobs.put(key, body, { contentType: mimeType, diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index 0af24c34a..83a527dad 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -605,3 +605,194 @@ describe('withGenerationPersistence generation artifacts', () => { await expect(blob?.text()).resolves.toContain('"segments"') }) }) + +describe('artifact URL fetching', () => { + /** An image adapter whose result is a URL rather than inline base64. */ + function urlImageAdapter(url: string): ImageAdapter { + return { + kind: 'image', + name: 'test-image-provider', + model: 'test-image-model', + '~types': imageAdapterTypes, + generateImages: vi.fn(async () => ({ + id: 'image-result', + model: 'test-image-model', + images: [{ url }], + })), + } + } + + function okFetch(body: string) { + return vi.fn(async () => new Response(body, { status: 200 })) + } + + /** A prompt referencing media by URL — the caller-controlled input case. */ + function urlPrompt(url: string) { + return [ + { type: 'text' as const, content: 'edit this' }, + { + type: 'image' as const, + source: { type: 'url' as const, value: url, mimeType: 'image/png' }, + }, + ] + } + + it('does not fetch a caller-supplied input URL by default', async () => { + const persistence = memoryPersistence() + const artifactFetch = okFetch('input-bytes') + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: urlPrompt('https://evil.example.com/pixel.png'), + threadId: 'thread-input-url', + runId: 'run-input-url', + middleware: [withGenerationPersistence(persistence, { artifactFetch })], + }) + + expect(artifactFetch).not.toHaveBeenCalled() + // Only the generated output is persisted; the input URL is skipped whole. + expect(result.artifacts?.map((artifact) => artifact.role)).toEqual([ + 'output', + ]) + }) + + it('fetches an input URL once allowInputUrl approves it', async () => { + const persistence = memoryPersistence() + const artifactFetch = okFetch('input-bytes') + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: urlPrompt('https://cdn.example.com/pixel.png'), + threadId: 'thread-allow', + runId: 'run-allow', + middleware: [ + withGenerationPersistence(persistence, { + artifactFetch, + allowInputUrl: ({ url }) => url.hostname === 'cdn.example.com', + }), + ], + }) + + expect(artifactFetch).toHaveBeenCalledTimes(1) + const input = result.artifacts?.find((a) => a.role === 'input') + expect(input).toBeDefined() + const blob = await persistence.stores.blobs!.get( + `artifacts/run-allow/${input!.artifactId}`, + ) + await expect(blob?.text()).resolves.toBe('input-bytes') + }) + + it('rejects an input URL that allowInputUrl declines', async () => { + const persistence = memoryPersistence() + const artifactFetch = okFetch('input-bytes') + + await expect( + generateImage({ + adapter: imageAdapter(), + prompt: urlPrompt('https://other.example.com/pixel.png'), + threadId: 'thread-deny', + runId: 'run-deny', + middleware: [ + withGenerationPersistence(persistence, { + artifactFetch, + allowInputUrl: ({ url }) => url.hostname === 'cdn.example.com', + }), + ], + }), + ).rejects.toThrow(/rejected by allowInputUrl/) + expect(artifactFetch).not.toHaveBeenCalled() + }) + + it.each([ + ['cloud metadata', 'http://169.254.169.254/latest/meta-data/'], + ['loopback', 'http://127.0.0.1:8080/admin'], + ['localhost', 'http://localhost:8080/admin'], + ['private range', 'http://10.0.0.5/internal'], + ['ipv6 loopback', 'http://[::1]:8080/admin'], + ['ipv4-mapped ipv6', 'http://[::ffff:127.0.0.1]/admin'], + ])('blocks an internal input host (%s)', async (_label, url) => { + const persistence = memoryPersistence() + const artifactFetch = okFetch('secrets') + + await expect( + generateImage({ + adapter: imageAdapter(), + prompt: urlPrompt(url), + threadId: 'thread-ssrf', + runId: 'run-ssrf', + middleware: [ + withGenerationPersistence(persistence, { + artifactFetch, + // Even a wide-open predicate must not defeat the host block. + allowInputUrl: () => true, + }), + ], + }), + ).rejects.toThrow(/internal host/) + expect(artifactFetch).not.toHaveBeenCalled() + }) + + it('refuses a non-http artifact URL', async () => { + const persistence = memoryPersistence() + const artifactFetch = okFetch('nope') + + await expect( + generateImage({ + adapter: urlImageAdapter('file:///etc/passwd'), + prompt: 'make an image', + threadId: 'thread-scheme', + runId: 'run-scheme', + middleware: [withGenerationPersistence(persistence, { artifactFetch })], + }), + ).rejects.toThrow(/Refusing to fetch artifact over file:/) + expect(artifactFetch).not.toHaveBeenCalled() + }) + + it('still fetches a provider output URL, and allows internal provider hosts', async () => { + const persistence = memoryPersistence() + // A self-hosted provider legitimately returns a loopback URL; the internal + // host block applies to caller-supplied input URLs only. + const artifactFetch = okFetch('generated-bytes') + + const result = await generateImage({ + adapter: urlImageAdapter('http://127.0.0.1:11434/out.png'), + prompt: 'make an image', + threadId: 'thread-output', + runId: 'run-output', + middleware: [withGenerationPersistence(persistence, { artifactFetch })], + }) + + expect(artifactFetch).toHaveBeenCalledTimes(1) + const output = result.artifacts?.find((a) => a.role === 'output') + const blob = await persistence.stores.blobs!.get( + `artifacts/run-output/${output!.artifactId}`, + ) + await expect(blob?.text()).resolves.toBe('generated-bytes') + }) + + it('refuses an artifact larger than maxArtifactBytes', async () => { + const persistence = memoryPersistence() + const artifactFetch = vi.fn( + async () => + new Response('x'.repeat(50), { + status: 200, + headers: { 'content-length': '50' }, + }), + ) + + await expect( + generateImage({ + adapter: urlImageAdapter('https://cdn.example.com/big.png'), + prompt: 'make an image', + threadId: 'thread-cap', + runId: 'run-cap', + middleware: [ + withGenerationPersistence(persistence, { + artifactFetch, + maxArtifactBytes: 10, + }), + ], + }), + ).rejects.toThrow(/exceeds maxArtifactBytes/) + }) +}) diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index cbd575ea6..81c3d9510 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -169,6 +169,58 @@ const image = useGenerateImage({ // generation for `threadId`, fetched from the server — nothing was cached. ``` +The server half of Mode B — the same route handles the run and the hydration +`GET`: + +```ts +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + memoryPersistence, + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' + +// Needs `stores.generationRuns`; `memoryPersistence()` ships one. +const persistence = memoryPersistence() + +export async function POST(request: Request) { + const { input } = await generationParamsFromRequest('image', request) + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + + return toServerSentEventsResponse( + generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + stream: true, + middleware: [withGenerationPersistence(persistence)], + }), + ) +} + +// Mount-time hydration: resolves `?runId=` (preferred) or the latest run linked +// to `?threadId=`, and returns `{ resumeSnapshot, activeRun }`. +export function GET(request: Request) { + return reconstructGeneration(persistence, request, { + // Multi-user routes MUST authorize: the ids come from the caller. Derive + // identity from server-side session state, then check ownership. + authorize: async (id, req) => { + // const user = await auth(req) + // return user != null && (await db.threadOwnedBy(user.id, id)) + void id + void req + return true + }, + }) +} +``` + - Nothing is cached client-side. On mount the client hydrates the **last generation** for its `threadId` from the server via the connection's `hydrateGeneration` handler (the SSE/HTTP adapters issue a `GET` with From 951030f855fda23ce7a831b5d1e17eb49fe3be98 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:07:23 +1000 Subject: [PATCH 35/66] refactor(persistence): rename artifact externalUrl to sourceUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- docs/persistence/build-your-own-adapter.md | 8 ++++---- packages/ai-client/src/generation-types.ts | 4 ++-- .../tests/generation-resume-state.test.ts | 14 +++++++------- .../build-cloudflare-artifact-store/SKILL.md | 6 +++--- packages/ai-persistence/src/middleware.ts | 12 ++++++------ packages/ai-persistence/src/types.ts | 4 ++-- .../tests/generation-artifacts.test.ts | 2 +- packages/ai-react/tests/use-generation.test.ts | 2 +- packages/ai/src/types.ts | 9 +++++++-- 9 files changed, 33 insertions(+), 28 deletions(-) diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index cf9ba7b24..4db297fcf 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -745,7 +745,7 @@ function mapArtifact(row: Record): ArtifactRecord { mimeType: String(row.mime_type), size: Number(row.size), ...(typeof row.external_url === 'string' - ? { externalUrl: row.external_url } + ? { sourceUrl: row.external_url } : {}), createdAt: Number(row.created_at), } @@ -775,7 +775,7 @@ function createArtifactStore(db: DatabaseSync) { record.name, record.mimeType, record.size, - record.externalUrl ?? null, + record.sourceUrl ?? null, record.createdAt, ) }, @@ -1304,7 +1304,7 @@ returns the stored record unchanged (`startedAt` / `activity` / `provider` / ### ArtifactStore Metadata rows for persisted media. The bytes live in a `BlobStore`; this record -holds the descriptive metadata and an optional `externalUrl` for reference-only +holds the descriptive metadata and an optional `sourceUrl` for reference-only backends. Provide it together with a `BlobStore` to keep generated bytes. ```ts @@ -1315,7 +1315,7 @@ interface ArtifactRecord { name: string mimeType: string size: number - externalUrl?: string + sourceUrl?: string createdAt: number // epoch ms } diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index ddf3164fd..eec47c402 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -794,7 +794,7 @@ function createPersistedArtifactRefSnapshot( return undefined } - const externalUrl = durableUrlField(value, 'externalUrl') + const sourceUrl = durableUrlField(value, 'sourceUrl') const url = serveUrlField(value, 'url') const mediaType = persistedArtifactMediaTypeField(source, 'mediaType') const jobId = stringField(source, 'jobId') @@ -809,7 +809,7 @@ function createPersistedArtifactRefSnapshot( mimeType, size, createdAt, - ...(externalUrl ? { externalUrl } : {}), + ...(sourceUrl ? { sourceUrl } : {}), ...(url ? { url } : {}), source: { activity, diff --git a/packages/ai-client/tests/generation-resume-state.test.ts b/packages/ai-client/tests/generation-resume-state.test.ts index bc71488e8..2f704c2e9 100644 --- a/packages/ai-client/tests/generation-resume-state.test.ts +++ b/packages/ai-client/tests/generation-resume-state.test.ts @@ -98,7 +98,7 @@ describe('generation resume state reducer', () => { it('sanitizes artifact refs before storing them in resume snapshots', () => { const unsafeArtifactRef = { ...artifactRef, - externalUrl: 'data:image/png;base64,raw-artifact-bytes', + sourceUrl: 'data:image/png;base64,raw-artifact-bytes', b64Json: 'raw-artifact-bytes', blob: new Blob(['raw-artifact-bytes']), url: `https://example.com/${'x'.repeat(4096)}`, @@ -178,19 +178,19 @@ describe('generation resume state reducer', () => { expect(JSON.stringify(snapshot)).not.toContain('b64Json') }) - it('keeps a durable artifact externalUrl and strips non-durable or oversized ones', () => { + it('keeps a durable artifact sourceUrl and strips non-durable or oversized ones', () => { const durableUrl = 'https://cdn.example.com/artifacts/image.png' const durable = reduceChunks([ { type: EventType.CUSTOM, name: GENERATION_EVENTS.ARTIFACTS, - value: [{ ...artifactRef, externalUrl: durableUrl }], + value: [{ ...artifactRef, sourceUrl: durableUrl }], threadId: 'thread-1', runId: 'run-1', timestamp: 1, }, ]) - expect(durable.pendingArtifacts?.[0]?.externalUrl).toBe(durableUrl) + expect(durable.pendingArtifacts?.[0]?.sourceUrl).toBe(durableUrl) const unsafeUrls = [ 'data:image/png;base64,raw-image-bytes', @@ -198,19 +198,19 @@ describe('generation resume state reducer', () => { `https://example.com/${'x'.repeat(4096)}`, 'not a url', ] - for (const externalUrl of unsafeUrls) { + for (const sourceUrl of unsafeUrls) { const snapshot = reduceChunks([ { type: EventType.CUSTOM, name: GENERATION_EVENTS.ARTIFACTS, - value: [{ ...artifactRef, externalUrl }], + value: [{ ...artifactRef, sourceUrl }], threadId: 'thread-1', runId: 'run-1', timestamp: 1, }, ]) expect(snapshot.pendingArtifacts).toEqual([artifactRef]) - expect(snapshot.pendingArtifacts?.[0]).not.toHaveProperty('externalUrl') + expect(snapshot.pendingArtifacts?.[0]).not.toHaveProperty('sourceUrl') } }) diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index ede73f71b..89e8e6c5c 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -156,7 +156,7 @@ Invariants that matter (asserted by the conformance testkit): ## 2. ArtifactStore backed by D1 `ArtifactRecord` is `{ artifactId, runId, threadId, name, mimeType, size, -externalUrl?, createdAt }` (`createdAt` epoch ms). One flat table, keyed by +sourceUrl?, createdAt }` (`createdAt` epoch ms). One flat table, keyed by `artifact_id`, indexed by `run_id` for `list`. ```sql @@ -196,7 +196,7 @@ function fromRow(row: ArtifactRow): ArtifactRecord { name: row.name, mimeType: row.mime_type, size: row.size, - ...(row.external_url != null ? { externalUrl: row.external_url } : {}), + ...(row.external_url != null ? { sourceUrl: row.external_url } : {}), createdAt: row.created_at, } } @@ -223,7 +223,7 @@ export function d1ArtifactStore(db: D1Database) { record.name, record.mimeType, record.size, - record.externalUrl ?? null, + record.sourceUrl ?? null, record.createdAt, ) .run() diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 348835866..0a52b8ed2 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -723,7 +723,7 @@ async function descriptorBody( body: BlobBody size: number mimeType: string - externalUrl?: string + sourceUrl?: string } | undefined > { @@ -838,7 +838,7 @@ async function descriptorBody( body: capBodySize(response.body, maxBytes, descriptor.url), size: 0, mimeType, - externalUrl: descriptor.url, + sourceUrl: descriptor.url, } } const body = await response.arrayBuffer() @@ -851,7 +851,7 @@ async function descriptorBody( body, size: body.byteLength, mimeType, - externalUrl: descriptor.url, + sourceUrl: descriptor.url, } } @@ -906,7 +906,7 @@ async function persistGenerationArtifacts( // Deliberately not persisted (an input URL with no `allowInputUrl` opt-in): // no blob, no record, no ref — the rest of the run is unaffected. if (!resolved) continue - const { body, size, mimeType, externalUrl } = resolved + const { body, size, mimeType, sourceUrl } = resolved const key = artifactBlobKey({ runId, artifactId }) const stored = await persistence.stores.blobs.put(key, body, { contentType: mimeType, @@ -941,7 +941,7 @@ async function persistGenerationArtifacts( name, mimeType, size: resolvedSize, - externalUrl, + sourceUrl, createdAt: createdAtMs, } await persistence.stores.artifacts.save(record) @@ -954,7 +954,7 @@ async function persistGenerationArtifacts( mimeType, size: resolvedSize, createdAt: new Date(createdAtMs).toISOString(), - ...(externalUrl ? { externalUrl } : {}), + ...(sourceUrl ? { sourceUrl } : {}), source: { activity, path: descriptor.path, diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index 438aaeb8d..18ceec6ed 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -412,7 +412,7 @@ export function defineBlobStore(store: BlobStore): BlobStore { * Metadata row describing a persisted artifact (generated media, tool output). * * The bytes themselves live in a {@link BlobStore}; this record holds the - * descriptive metadata and an optional `externalUrl` for reference-only + * descriptive metadata and an optional `sourceUrl` for reference-only * backends. * * @property createdAt - Epoch ms. (Core's wire-facing `PersistedArtifactRef` @@ -425,7 +425,7 @@ export interface ArtifactRecord { name: string mimeType: string size: number - externalUrl?: string + sourceUrl?: string createdAt: number } diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index 83a527dad..bbc4db067 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -437,7 +437,7 @@ describe('withGenerationPersistence generation artifacts', () => { }) expect(result.artifacts).toHaveLength(2) - expect(result.artifacts?.map((artifact) => artifact.externalUrl)).toEqual([ + expect(result.artifacts?.map((artifact) => artifact.sourceUrl)).toEqual([ undefined, undefined, ]) diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index dc6693602..dd92d44d4 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -100,7 +100,7 @@ const replayedVideoArtifact: PersistedArtifactRef = { mimeType: 'video/mp4', size: 1234, createdAt: '2026-07-06T00:00:00.000Z', - externalUrl: 'https://example.com/video.mp4', + sourceUrl: 'https://example.com/video.mp4', source: { activity: 'video', path: 'runs/run-resume/video.mp4', diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 82d17f2c4..2d6e1ca3d 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -2157,8 +2157,13 @@ export interface PersistedArtifactRef { mimeType: string size: number createdAt: string - /** The provider's original media URL that was fetched (may be expiring). */ - externalUrl?: string + /** + * Where these bytes were fetched FROM — the provider's original result URL, + * or a caller-supplied prompt URL when `allowInputUrl` opted that in. Usually + * expiring, and provenance only: serve from {@link PersistedArtifactRef.url} + * instead. + */ + sourceUrl?: string /** * Durable app-origin URL that serves this artifact's persisted bytes (your * `GET` route around `retrieveArtifact` / `retrieveBlob`). Stamped by From 9c73d8646a5c153435880e751a24c352959f922d Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:45:37 +1000 Subject: [PATCH 36/66] feat(generation-persistence): require threadId when persistence is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../ai-angular/src/inject-generate-audio.ts | 8 +- .../ai-angular/src/inject-generate-image.ts | 8 +- .../ai-angular/src/inject-generate-speech.ts | 8 +- .../ai-angular/src/inject-generate-video.ts | 25 ++++- packages/ai-angular/src/inject-generation.ts | 25 ++++- packages/ai-angular/src/inject-summarize.ts | 8 +- .../ai-angular/src/inject-transcription.ts | 8 +- .../tests/inject-generation.test.ts | 6 ++ packages/ai-client/src/generation-client.ts | 28 +++++- packages/ai-client/src/generation-types.ts | 69 +++++++++++-- packages/ai-client/src/index.ts | 1 + packages/ai-react/src/use-generate-audio.ts | 25 ++++- packages/ai-react/src/use-generate-image.ts | 25 ++++- packages/ai-react/src/use-generate-speech.ts | 25 ++++- packages/ai-react/src/use-generate-video.ts | 25 ++++- packages/ai-react/src/use-generation.ts | 25 ++++- packages/ai-react/src/use-summarize.ts | 25 ++++- packages/ai-react/src/use-transcription.ts | 25 ++++- .../use-generation-persistence-types.test.ts | 98 +++++++++++++++++++ .../ai-react/tests/use-generation.test.ts | 5 + packages/ai-solid/src/use-generate-audio.ts | 8 +- packages/ai-solid/src/use-generate-image.ts | 8 +- packages/ai-solid/src/use-generate-speech.ts | 8 +- packages/ai-solid/src/use-generate-video.ts | 25 ++++- packages/ai-solid/src/use-generation.ts | 25 ++++- packages/ai-solid/src/use-summarize.ts | 8 +- packages/ai-solid/src/use-transcription.ts | 8 +- .../ai-solid/tests/use-generation.test.ts | 7 ++ .../src/create-generate-audio.svelte.ts | 8 +- .../src/create-generate-image.svelte.ts | 8 +- .../src/create-generate-speech.svelte.ts | 8 +- .../src/create-generate-video.svelte.ts | 25 ++++- .../ai-svelte/src/create-generation.svelte.ts | 25 ++++- .../ai-svelte/src/create-summarize.svelte.ts | 8 +- .../src/create-transcription.svelte.ts | 8 +- .../ai-svelte/tests/create-generation.test.ts | 6 ++ packages/ai-vue/src/use-generate-audio.ts | 8 +- packages/ai-vue/src/use-generate-image.ts | 8 +- packages/ai-vue/src/use-generate-speech.ts | 8 +- packages/ai-vue/src/use-generate-video.ts | 25 ++++- packages/ai-vue/src/use-generation.ts | 25 ++++- packages/ai-vue/src/use-summarize.ts | 8 +- packages/ai-vue/src/use-transcription.ts | 8 +- packages/ai-vue/tests/use-generation.test.ts | 5 + 44 files changed, 650 insertions(+), 110 deletions(-) create mode 100644 packages/ai-react/tests/use-generation-persistence-types.test.ts diff --git a/packages/ai-angular/src/inject-generate-audio.ts b/packages/ai-angular/src/inject-generate-audio.ts index b1d5a4323..091291451 100644 --- a/packages/ai-angular/src/inject-generate-audio.ts +++ b/packages/ai-angular/src/inject-generate-audio.ts @@ -7,6 +7,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' import type { Signal } from '@angular/core' @@ -105,9 +106,12 @@ export interface InjectGenerateAudioResult< * ``` */ export function injectGenerateAudio( - options: Omit & { + options: Omit< + InjectGenerateAudioOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: AudioGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): InjectGenerateAudioResult< InferGenerationOutputFromReturn > { diff --git a/packages/ai-angular/src/inject-generate-image.ts b/packages/ai-angular/src/inject-generate-image.ts index 84c66ac58..65c2681d9 100644 --- a/packages/ai-angular/src/inject-generate-image.ts +++ b/packages/ai-angular/src/inject-generate-image.ts @@ -4,6 +4,7 @@ import type { Signal } from '@angular/core' import type { ImageGenerationResult } from '@tanstack/ai' import type { GenerationClientState, + GenerationPersistenceOptions, ImageGenerateInput, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -30,9 +31,12 @@ export interface InjectGenerateImageResult< } export function injectGenerateImage( - options: Omit & { + options: Omit< + InjectGenerateImageOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: ImageGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): InjectGenerateImageResult< InferGenerationOutputFromReturn > { diff --git a/packages/ai-angular/src/inject-generate-speech.ts b/packages/ai-angular/src/inject-generate-speech.ts index cdc1ff8b8..972e2a132 100644 --- a/packages/ai-angular/src/inject-generate-speech.ts +++ b/packages/ai-angular/src/inject-generate-speech.ts @@ -3,6 +3,7 @@ import type { Signal } from '@angular/core' import type { TTSResult } from '@tanstack/ai' import type { GenerationClientState, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, SpeechGenerateInput, } from '@tanstack/ai-client' @@ -30,9 +31,12 @@ export interface InjectGenerateSpeechResult extends Omit< } export function injectGenerateSpeech( - options: Omit & { + options: Omit< + InjectGenerateSpeechOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TTSResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): InjectGenerateSpeechResult< InferGenerationOutputFromReturn > { diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index f799d9a1e..56c507fbc 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -18,6 +18,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -42,11 +43,24 @@ export interface InjectGenerateVideoOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -75,9 +89,12 @@ export interface InjectGenerateVideoResult { // parameter is typed as `VideoGenerateResult` and `result` narrows to the // transform's return. See issue #848. export function injectGenerateVideo( - options: Omit & { + options: Omit< + InjectGenerateVideoOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: VideoGenerateResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): InjectGenerateVideoResult< InferGenerationOutputFromReturn > { diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index 54a33682f..d2b56f19e 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -19,6 +19,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, @@ -46,11 +47,24 @@ export interface InjectGenerationOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -116,9 +130,12 @@ export function injectGeneration< TResult, TTransformed = void, >( - options: Omit, 'onResult'> & { + options: Omit< + InjectGenerationOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): InjectGenerationResult< InferGenerationOutputFromReturn, TInput diff --git a/packages/ai-angular/src/inject-summarize.ts b/packages/ai-angular/src/inject-summarize.ts index 2edb5e165..991de8ba0 100644 --- a/packages/ai-angular/src/inject-summarize.ts +++ b/packages/ai-angular/src/inject-summarize.ts @@ -4,6 +4,7 @@ import type { Signal } from '@angular/core' import type { SummarizationResult } from '@tanstack/ai' import type { GenerationClientState, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, SummarizeGenerateInput, } from '@tanstack/ai-client' @@ -30,9 +31,12 @@ export interface InjectSummarizeResult< } export function injectSummarize( - options: Omit & { + options: Omit< + InjectSummarizeOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: SummarizationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): InjectSummarizeResult< InferGenerationOutputFromReturn > { diff --git a/packages/ai-angular/src/inject-transcription.ts b/packages/ai-angular/src/inject-transcription.ts index fe2e240a4..1a25bf9b3 100644 --- a/packages/ai-angular/src/inject-transcription.ts +++ b/packages/ai-angular/src/inject-transcription.ts @@ -4,6 +4,7 @@ import type { Signal } from '@angular/core' import type { TranscriptionResult } from '@tanstack/ai' import type { GenerationClientState, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, TranscriptionGenerateInput, } from '@tanstack/ai-client' @@ -34,9 +35,12 @@ export interface InjectTranscriptionResult< } export function injectTranscription( - options: Omit & { + options: Omit< + InjectTranscriptionOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TranscriptionResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): InjectTranscriptionResult< InferGenerationOutputFromReturn > { diff --git a/packages/ai-angular/tests/inject-generation.test.ts b/packages/ai-angular/tests/inject-generation.test.ts index 2f9068506..311052759 100644 --- a/packages/ai-angular/tests/inject-generation.test.ts +++ b/packages/ai-angular/tests/inject-generation.test.ts @@ -188,6 +188,7 @@ describe('injectGeneration', () => { const getItem = vi.fn(() => snapshot) const { result } = renderInjectGeneration({ id: 'no-auto-fire', + threadId: 'no-auto-fire', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: snapshot, @@ -213,6 +214,7 @@ describe('injectGeneration', () => { }) const { result } = renderInjectGeneration({ id: 'hydrate-me', + threadId: 'hydrate-me', connection: adapter, persistence, }) @@ -244,6 +246,7 @@ describe('injectGeneration', () => { }) const { result } = renderInjectGeneration({ id: 'reset-me', + threadId: 'reset-me', connection: adapter, persistence, initialResumeSnapshot: snapshot, @@ -268,6 +271,7 @@ describe('injectGenerateVideo', () => { const getItem = vi.fn(() => videoResumeSnapshot) const { result } = renderInjectGenerateVideo({ id: 'video-no-auto-fire', + threadId: 'video-no-auto-fire', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: videoResumeSnapshot, @@ -290,6 +294,7 @@ describe('injectGenerateVideo', () => { }) const { result } = renderInjectGenerateVideo({ id: 'video-hydrate', + threadId: 'video-hydrate', connection: adapter, persistence, }) @@ -352,6 +357,7 @@ describe('injectGenerateImage', () => { }) const { result } = renderInjectGenerateImage({ id: 'img-hydrate', + threadId: 'img-hydrate', connection: adapter, persistence, }) diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 3cfeb1941..5808e2a4a 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -101,6 +101,7 @@ export class GenerationClient< private readonly devtoolsMetadata: AIDevtoolsClientMetadata private readonly devtoolsBridge: GenerationDevtoolsBridge private readonly threadId: string + private readonly persistenceScope: string | undefined private readonly resumePersistence: GenerationPersistence | undefined // Server-driven mode (`persistence: true`): no local snapshot store; on mount // the client hydrates the last generation for `threadId` from the server. @@ -135,9 +136,15 @@ export class GenerationClient< ), ) { this.uniqueId = options.id ?? this.generateUniqueId('generation') - // The wire/hydration thread key. Server-driven mode needs a stable key, so - // prefer an explicit `threadId`, then `id`, then a generated id. + // AG-UI requires a thread id on every run, so fall back to `id` and then to + // a generated one. That fallback is for the WIRE ONLY: it is not stable + // across reloads, so persistence must never key on it — see + // `resumeSnapshotKey`, which uses the explicit scope below. this.threadId = options.threadId ?? this.uniqueId + // The persistence scope: the explicit `threadId` and nothing else. The + // types require it whenever `persistence` is set; this field keeps the + // fallback from silently becoming a storage key for JS callers. + this.persistenceScope = options.threadId this.connection = options.connection this.fetcher = options.fetcher this.body = options.body ?? {} @@ -151,6 +158,15 @@ export class GenerationClient< } else if (options.persistence) { this.resumePersistence = options.persistence } + // The types require `threadId` alongside `persistence`, so this only fires + // for JS callers. Warn rather than fall back silently: keying on the + // generated wire id would write a different slot every reload, restoring + // nothing while accumulating orphaned records. + if (options.persistence && !this.persistenceScope) { + console.warn( + '[TanStack AI] `persistence` needs a stable `threadId` to key on. Without one nothing will be restored after a reload. Pass a `threadId` derived from your own domain (e.g. `product-123-hero`).', + ) + } this.resumeSnapshot = options.initialResumeSnapshot this.callbacksRef = { @@ -770,8 +786,14 @@ export class GenerationClient< * a generation client and a chat client that share an id (and a storage * adapter with the default key prefix) from overwriting each other. */ + /** + * Storage key for the client-driven snapshot. Keys on the explicit scope so + * both persistence modes address the same slot — server-driven hydrates by + * `threadId` too. Falls back to the wire id only for the JS-caller case the + * constructor already warned about. + */ private get resumeSnapshotKey(): string { - return `generation:${this.uniqueId}` + return `generation:${this.persistenceScope ?? this.threadId}` } private maybeHydrateResumeSnapshot(): void { diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index eec47c402..0230d8082 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -170,11 +170,48 @@ export type GenerationPersistence = ChatStorageAdapter * never auto-starts a run. Requires a connection that implements * `hydrateGeneration`. * - a {@link GenerationPersistence} adapter: client-driven. The lightweight - * resume snapshot is cached in the browser under `generation:` as a run - * streams and read back (validated) on mount. + * resume snapshot is cached in the browser under `generation:` as a + * run streams and read back (validated) on mount. */ export type GenerationPersistenceOption = boolean | GenerationPersistence +/** + * The `persistence` / `threadId` pair shared by every generation hook. + * + * Turning persistence on **requires** a `threadId` — the stable scope runs are + * filed under. Without one the client would key on a generated id that changes + * every reload, so nothing would ever restore; making it a type error means the + * compiler asks for the scope instead of the runtime silently inventing one. + * + * Ephemeral generations (no `persistence`, or `persistence: false`) leave + * `threadId` optional, exactly as before — this adds no requirement to code + * that does not opt into persistence. + * + * USAGE: intersect this onto a hook's parameter and subtract the two keys from + * the options interface, leaving that interface a plain (non-union) object so + * `Pick` / `Omit` composition elsewhere keeps working: + * + * ```ts + * options: Omit & { + * onResult?: (result: ImageGenerationResult) => TTransformed + * } & GenerationPersistenceOptions + * ``` + * + * Do NOT bake the union into the options interface itself: a later plain `Omit` + * over a union collapses it to a single object type and the requirement + * silently disappears. `use-generation-persistence-types.test.ts` pins this. + */ +export type GenerationPersistenceOptions = + | { + persistence: true | GenerationPersistence + /** Required by `persistence` — the stable scope runs are filed under. */ + threadId: string + } + | { + persistence?: false | undefined + threadId?: string + } + // =========================== // Event Constants // =========================== @@ -249,10 +286,28 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { id?: string /** - * The thread id for this generation, stable across reloads. Used as the AG-UI - * thread key on the wire AND, in server-driven mode (`persistence: true`), as - * the key the client hydrates the last generation under on mount. Falls back - * to `id`, then to a generated id. + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill, not a link to a chat conversation. + * + * A generation hook starts empty and produces many runs over its life — each + * run gets its own `runId`, but they all belong to one scope. Persistence + * keys on this in **both** modes: client-driven writes under + * `generation:`, server-driven hydrates the last run for it on + * mount. It is also sent as the AG-UI thread id on the wire, since the + * protocol requires one. + * + * Derive it from your own domain — it must be meaningful before any media + * exists and identical after a reload: + * + * ```ts + * threadId: `video-${videoId}-start-frame` + * ``` + * + * **Required whenever `persistence` is set.** An app that cannot name the + * scope has nothing to restore *to*, and a generated fallback would key each + * reload differently — silently restoring nothing. Optional only for + * ephemeral runs, where it falls back to `id` (or a generated id) purely to + * satisfy the wire and nothing is written. */ threadId?: string @@ -285,7 +340,7 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { * - a {@link GenerationPersistence} adapter (any {@link ChatStorageAdapter}, * including the shared `localStoragePersistence` / `sessionStoragePersistence` * / `indexedDBPersistence` factories): client-driven. The client writes the - * lightweight snapshot under the key `generation:` as a run streams, and + * lightweight snapshot under the key `generation:` as a run streams, and * reads it back (validated) on construction unless `initialResumeSnapshot` is * provided. Generated media bytes are never written. */ diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index 38a5da277..f82ff2afb 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -79,6 +79,7 @@ export type { GenerationEventSnapshot, GenerationPersistence, GenerationPersistenceOption, + GenerationPersistenceOptions, GenerationClientOptions, GenerationFetcher, GenerationFetcherOptions, diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index af976c48a..41423adc0 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -8,6 +8,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -36,11 +37,24 @@ export interface UseGenerateAudioOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -115,9 +129,12 @@ export interface UseGenerateAudioReturn { * ``` */ export function useGenerateAudio( - options: Omit & { + options: Omit< + UseGenerateAudioOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: AudioGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateAudioReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index 693ff2f7c..5c0788e2b 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -7,6 +7,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, ImageGenerateInput, @@ -36,11 +37,24 @@ export interface UseGenerateImageOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -117,9 +131,12 @@ export interface UseGenerateImageReturn { * ``` */ export function useGenerateImage( - options: Omit & { + options: Omit< + UseGenerateImageOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: ImageGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateImageReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index 3afe373b0..c897f3a6c 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -6,6 +6,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -35,11 +36,24 @@ export interface UseGenerateSpeechOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -110,9 +124,12 @@ export interface UseGenerateSpeechReturn { * ``` */ export function useGenerateSpeech( - options: Omit & { + options: Omit< + UseGenerateSpeechOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TTSResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateSpeechReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index 688b37a3a..94f84c01a 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -8,6 +8,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -37,11 +38,24 @@ export interface UseGenerateVideoOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -128,9 +142,12 @@ export interface UseGenerateVideoReturn { // parameter is typed as `VideoGenerateResult` and `result` narrows to the // transform's return. See issue #848. export function useGenerateVideo( - options: Omit & { + options: Omit< + UseGenerateVideoOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: VideoGenerateResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateVideoReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 0d00bbae6..79b219331 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -9,6 +9,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, @@ -42,11 +43,24 @@ export interface UseGenerationOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -130,9 +144,12 @@ export function useGeneration< TResult, TTransformed = void, >( - options: Omit, 'onResult'> & { + options: Omit< + UseGenerationOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerationReturn< InferGenerationOutputFromReturn, TInput diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index 03703fa32..d8b56ee74 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -7,6 +7,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -36,11 +37,24 @@ export interface UseSummarizeOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -114,9 +128,12 @@ export interface UseSummarizeReturn { * ``` */ export function useSummarize( - options: Omit & { + options: Omit< + UseSummarizeOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: SummarizationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseSummarizeReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index 316d50871..aae548bfc 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -7,6 +7,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -36,11 +37,24 @@ export interface UseTranscriptionOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -119,9 +133,12 @@ export interface UseTranscriptionReturn { * ``` */ export function useTranscription( - options: Omit & { + options: Omit< + UseTranscriptionOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TranscriptionResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseTranscriptionReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-react/tests/use-generation-persistence-types.test.ts b/packages/ai-react/tests/use-generation-persistence-types.test.ts new file mode 100644 index 000000000..87778c349 --- /dev/null +++ b/packages/ai-react/tests/use-generation-persistence-types.test.ts @@ -0,0 +1,98 @@ +/** + * Type-level tests for the `persistence` / `threadId` pairing on the generation + * hooks. These assertions are pure types — they never invoke the hooks at + * runtime (which would require a React renderer). + * + * Turning `persistence` on requires a `threadId`: the stable scope runs are + * filed under. Without it the client keys on a generated wire id that changes + * every reload, so nothing restores while orphaned records accumulate — a + * silent failure the compiler should catch instead. + * + * The pairing is expressed as a union (`GenerationPersistenceOptions`) + * intersected onto each hook's parameter. That makes it FRAGILE in one specific + * way: applying a plain `Omit` to a union collapses it to a single object type + * and the requirement silently disappears. These tests exist so that regression + * fails the build rather than shipping. + */ + +import { describe, it } from 'vitest' +import { useGenerateImage } from '../src/use-generate-image' +import { useGenerateVideo } from '../src/use-generate-video' +import { useGeneration } from '../src/use-generation' +import { useSummarize } from '../src/use-summarize' +import { useTranscription } from '../src/use-transcription' +import type { GenerationPersistence } from '@tanstack/ai-client' + +const connection = {} as never +const adapter = {} as GenerationPersistence + +describe('generation persistence requires a threadId', () => { + it('rejects `persistence: true` without a threadId', () => { + // Type-level only: never invoked, so no renderer is needed. + const _typeCheck = () => { + // @ts-expect-error threadId is required whenever persistence is set + useGenerateImage({ connection, persistence: true }) + // @ts-expect-error threadId is required whenever persistence is set + useGenerateVideo({ connection, persistence: true }) + // @ts-expect-error threadId is required whenever persistence is set + useGeneration({ connection, persistence: true }) + // @ts-expect-error threadId is required whenever persistence is set + useSummarize({ connection, persistence: true }) + // @ts-expect-error threadId is required whenever persistence is set + useTranscription({ connection, persistence: true }) + } + void _typeCheck + }) + + it('rejects a storage adapter without a threadId', () => { + // Type-level only: never invoked, so no renderer is needed. + const _typeCheck = () => { + // @ts-expect-error threadId is required whenever persistence is set + useGenerateImage({ connection, persistence: adapter }) + // @ts-expect-error threadId is required whenever persistence is set + useGeneration({ connection, persistence: adapter }) + } + void _typeCheck + }) + + it('accepts persistence when a threadId is supplied', () => { + // Type-level only: never invoked, so no renderer is needed. + const _typeCheck = () => { + useGenerateImage({ connection, persistence: true, threadId: 'hero' }) + useGenerateImage({ connection, persistence: adapter, threadId: 'hero' }) + useGeneration({ connection, persistence: true, threadId: 'hero' }) + } + void _typeCheck + }) + + it('leaves threadId optional for ephemeral generations', () => { + // Type-level only: never invoked, so no renderer is needed. + const _typeCheck = () => { + // The published no-persistence signature must keep compiling untouched — + // adding a required option would break every existing call site. + useGenerateImage({ connection }) + useGenerateImage({ connection, persistence: false }) + useGeneration({ connection }) + useGeneration({ connection, persistence: false }) + } + void _typeCheck + }) + + it('still infers the onResult transform through the union', () => { + // Type-level only: never invoked, so no renderer is needed. + const _typeCheck = () => { + // The union is intersected onto the same parameter that infers + // `TTransformed`; a bad formulation breaks inference before it breaks the + // requirement, so pin it here too. + const image = useGenerateImage({ + connection, + persistence: true, + threadId: 'hero', + onResult: (result) => result.images.length, + }) + const count: number | null = image.result + void count + } + void _typeCheck + }) +}) diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index dd92d44d4..c661624f5 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -390,6 +390,7 @@ describe('useGeneration', () => { const { result } = renderHook(() => useGeneration({ id: 'no-auto-fire', + threadId: 'no-auto-fire', connection: adapter, persistence: persistence, initialResumeSnapshot: { @@ -435,6 +436,7 @@ describe('useGeneration', () => { const { result } = renderHook(() => useGeneration({ id: 'hydrated', + threadId: 'hydrated', connection: adapter, persistence: persistence, }), @@ -467,6 +469,7 @@ describe('useGeneration', () => { const { result } = renderHook(() => useGeneration({ id: 'running-hydrate', + threadId: 'running-hydrate', connection: adapter, persistence: persistence, }), @@ -682,6 +685,7 @@ describe('useGenerateImage', () => { const { result } = renderHook(() => useGenerateImage({ id: 'img-hydrate', + threadId: 'img-hydrate', connection: adapter, persistence: persistence, }), @@ -1032,6 +1036,7 @@ describe('useGenerateVideo', () => { const { result } = renderHook(() => useGenerateVideo({ id: 'video-no-auto-fire', + threadId: 'video-no-auto-fire', connection: adapter, persistence: persistence, initialResumeSnapshot: videoResumeSnapshot, diff --git a/packages/ai-solid/src/use-generate-audio.ts b/packages/ai-solid/src/use-generate-audio.ts index efafa8943..0e99c4cdc 100644 --- a/packages/ai-solid/src/use-generate-audio.ts +++ b/packages/ai-solid/src/use-generate-audio.ts @@ -11,6 +11,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' import type { Accessor } from 'solid-js' @@ -97,9 +98,12 @@ export interface UseGenerateAudioReturn< * ``` */ export function useGenerateAudio( - options: Omit & { + options: Omit< + UseGenerateAudioOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: AudioGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateAudioReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-solid/src/use-generate-image.ts b/packages/ai-solid/src/use-generate-image.ts index 40f226604..03b310693 100644 --- a/packages/ai-solid/src/use-generate-image.ts +++ b/packages/ai-solid/src/use-generate-image.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, ImageGenerateInput, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -105,9 +106,12 @@ export interface UseGenerateImageReturn< * ``` */ export function useGenerateImage( - options: Omit & { + options: Omit< + UseGenerateImageOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: ImageGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateImageReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-solid/src/use-generate-speech.ts b/packages/ai-solid/src/use-generate-speech.ts index 0daf9e1b1..0e77f5f8a 100644 --- a/packages/ai-solid/src/use-generate-speech.ts +++ b/packages/ai-solid/src/use-generate-speech.ts @@ -9,6 +9,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, SpeechGenerateInput, } from '@tanstack/ai-client' @@ -97,9 +98,12 @@ export interface UseGenerateSpeechReturn extends Omit< * ``` */ export function useGenerateSpeech( - options: Omit & { + options: Omit< + UseGenerateSpeechOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TTSResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateSpeechReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index 6fd991046..e2aa33f90 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -15,6 +15,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -47,11 +48,24 @@ export interface UseGenerateVideoOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -138,9 +152,12 @@ export interface UseGenerateVideoReturn { // parameter is typed as `VideoGenerateResult` and `result` narrows to the // transform's return. See issue #848. export function useGenerateVideo( - options: Omit & { + options: Omit< + UseGenerateVideoOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: VideoGenerateResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateVideoReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index 1d37e1e94..8bd066bda 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -16,6 +16,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, @@ -50,11 +51,24 @@ export interface UseGenerationOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -139,9 +153,12 @@ export function useGeneration< TResult, TTransformed = void, >( - options: Omit, 'onResult'> & { + options: Omit< + UseGenerationOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerationReturn< InferGenerationOutputFromReturn, TInput diff --git a/packages/ai-solid/src/use-summarize.ts b/packages/ai-solid/src/use-summarize.ts index 59380ea8b..cf2567244 100644 --- a/packages/ai-solid/src/use-summarize.ts +++ b/packages/ai-solid/src/use-summarize.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, SummarizeGenerateInput, } from '@tanstack/ai-client' @@ -103,9 +104,12 @@ export interface UseSummarizeReturn extends Omit< * ``` */ export function useSummarize( - options: Omit & { + options: Omit< + UseSummarizeOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: SummarizationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseSummarizeReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-solid/src/use-transcription.ts b/packages/ai-solid/src/use-transcription.ts index 00b139633..e4e9be7fc 100644 --- a/packages/ai-solid/src/use-transcription.ts +++ b/packages/ai-solid/src/use-transcription.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, TranscriptionGenerateInput, } from '@tanstack/ai-client' @@ -112,9 +113,12 @@ export interface UseTranscriptionReturn< * ``` */ export function useTranscription( - options: Omit & { + options: Omit< + UseTranscriptionOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TranscriptionResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseTranscriptionReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-solid/tests/use-generation.test.ts b/packages/ai-solid/tests/use-generation.test.ts index 7b95ad439..47c8c277e 100644 --- a/packages/ai-solid/tests/use-generation.test.ts +++ b/packages/ai-solid/tests/use-generation.test.ts @@ -1154,6 +1154,7 @@ describe('useGenerateVideo', () => { const { result } = renderHook(() => useGenerateVideo({ id: 'video-no-auto-fire', + threadId: 'video-no-auto-fire', connection: adapter, persistence: persistence, initialResumeSnapshot: videoResumeSnapshot, @@ -1237,6 +1238,7 @@ describe('resume snapshot persistence', () => { const { result } = renderHook(() => useGeneration({ id: 'no-auto-fire', + threadId: 'no-auto-fire', connection: adapter, persistence, initialResumeSnapshot: { @@ -1278,6 +1280,7 @@ describe('resume snapshot persistence', () => { const { result } = renderHook(() => useGeneration({ id: 'hydrated', + threadId: 'hydrated', connection: adapter, persistence, }), @@ -1310,6 +1313,7 @@ describe('resume snapshot persistence', () => { const { result } = renderHook(() => useGeneration({ id: 'running-hydrate', + threadId: 'running-hydrate', connection: adapter, persistence, }), @@ -1412,6 +1416,7 @@ describe('resume snapshot persistence', () => { const { result } = renderHook(() => useGenerateImage({ id: 'img-hydrate', + threadId: 'img-hydrate', connection: adapter, persistence, }), @@ -1443,6 +1448,7 @@ describe('resume snapshot persistence', () => { const { result } = renderHook(() => useGenerateVideo({ id: 'video-hydrate-me', + threadId: 'video-hydrate-me', connection: adapter, persistence, }), @@ -1465,6 +1471,7 @@ describe('resume snapshot persistence', () => { const { result } = renderHook(() => useGeneration({ id: 'reset-me', + threadId: 'reset-me', fetcher: async () => ({ id: '1' }), persistence, }), diff --git a/packages/ai-svelte/src/create-generate-audio.svelte.ts b/packages/ai-svelte/src/create-generate-audio.svelte.ts index dd9da4c18..d58dcccf3 100644 --- a/packages/ai-svelte/src/create-generate-audio.svelte.ts +++ b/packages/ai-svelte/src/create-generate-audio.svelte.ts @@ -11,6 +11,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -98,9 +99,12 @@ export interface CreateGenerateAudioReturn< * ``` */ export function createGenerateAudio( - options: Omit & { + options: Omit< + CreateGenerateAudioOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: AudioGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): CreateGenerateAudioReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-svelte/src/create-generate-image.svelte.ts b/packages/ai-svelte/src/create-generate-image.svelte.ts index 684e5c366..e151b519b 100644 --- a/packages/ai-svelte/src/create-generate-image.svelte.ts +++ b/packages/ai-svelte/src/create-generate-image.svelte.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, ImageGenerateInput, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -107,9 +108,12 @@ export interface CreateGenerateImageReturn< * ``` */ export function createGenerateImage( - options: Omit & { + options: Omit< + CreateGenerateImageOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: ImageGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): CreateGenerateImageReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-svelte/src/create-generate-speech.svelte.ts b/packages/ai-svelte/src/create-generate-speech.svelte.ts index e41300655..bb405de01 100644 --- a/packages/ai-svelte/src/create-generate-speech.svelte.ts +++ b/packages/ai-svelte/src/create-generate-speech.svelte.ts @@ -9,6 +9,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, SpeechGenerateInput, } from '@tanstack/ai-client' @@ -96,9 +97,12 @@ export interface CreateGenerateSpeechReturn extends Omit< * ``` */ export function createGenerateSpeech( - options: Omit & { + options: Omit< + CreateGenerateSpeechOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TTSResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): CreateGenerateSpeechReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index d39d8207c..433667374 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -7,6 +7,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -38,11 +39,24 @@ export interface CreateGenerateVideoOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -132,9 +146,12 @@ export interface CreateGenerateVideoReturn { // parameter is typed as `VideoGenerateResult` and `result` narrows to the // transform's return. See issue #848. export function createGenerateVideo( - options: Omit & { + options: Omit< + CreateGenerateVideoOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: VideoGenerateResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): CreateGenerateVideoReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index 0e61eccc4..208cf3f02 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -8,6 +8,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, @@ -41,11 +42,24 @@ export interface CreateGenerationOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -145,9 +159,12 @@ export function createGeneration< TResult, TTransformed = void, >( - options: Omit, 'onResult'> & { + options: Omit< + CreateGenerationOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): CreateGenerationReturn< InferGenerationOutputFromReturn, TInput diff --git a/packages/ai-svelte/src/create-summarize.svelte.ts b/packages/ai-svelte/src/create-summarize.svelte.ts index dde442282..1cd112804 100644 --- a/packages/ai-svelte/src/create-summarize.svelte.ts +++ b/packages/ai-svelte/src/create-summarize.svelte.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, SummarizeGenerateInput, } from '@tanstack/ai-client' @@ -102,9 +103,12 @@ export interface CreateSummarizeReturn< * ``` */ export function createSummarize( - options: Omit & { + options: Omit< + CreateSummarizeOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: SummarizationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): CreateSummarizeReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-svelte/src/create-transcription.svelte.ts b/packages/ai-svelte/src/create-transcription.svelte.ts index 9403eb7a9..6d3314ad2 100644 --- a/packages/ai-svelte/src/create-transcription.svelte.ts +++ b/packages/ai-svelte/src/create-transcription.svelte.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, TranscriptionGenerateInput, } from '@tanstack/ai-client' @@ -111,9 +112,12 @@ export interface CreateTranscriptionReturn< * ``` */ export function createTranscription( - options: Omit & { + options: Omit< + CreateTranscriptionOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TranscriptionResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): CreateTranscriptionReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-svelte/tests/create-generation.test.ts b/packages/ai-svelte/tests/create-generation.test.ts index 7216daa74..9b2b717c2 100644 --- a/packages/ai-svelte/tests/create-generation.test.ts +++ b/packages/ai-svelte/tests/create-generation.test.ts @@ -259,6 +259,7 @@ describe('createGeneration', () => { const getItem = vi.fn(() => snapshot) const gen = createGeneration({ id: 'no-auto-fire', + threadId: 'no-auto-fire', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: snapshot, @@ -289,6 +290,7 @@ describe('createGeneration', () => { const gen = createGeneration({ id: 'hydrate-me', + threadId: 'hydrate-me', connection: createMockConnectionAdapter(), persistence, }) @@ -320,6 +322,7 @@ describe('createGeneration', () => { const gen = createGeneration({ id: 'complete-me', + threadId: 'complete-me', connection: createMockConnectionAdapter(), persistence, }) @@ -344,6 +347,7 @@ describe('createGeneration', () => { const gen = createGeneration({ id: 'reset-me', + threadId: 'reset-me', connection: createMockConnectionAdapter(), persistence, }) @@ -518,6 +522,7 @@ describe('createGenerateImage', () => { const gen = createGenerateImage({ id: 'img-hydrate', + threadId: 'img-hydrate', connection: createMockConnectionAdapter(), persistence, }) @@ -896,6 +901,7 @@ describe('createGenerateVideo', () => { const getItem = vi.fn(() => videoResumeSnapshot) const gen = createGenerateVideo({ id: 'video-no-auto-fire', + threadId: 'video-no-auto-fire', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: videoResumeSnapshot, diff --git a/packages/ai-vue/src/use-generate-audio.ts b/packages/ai-vue/src/use-generate-audio.ts index 3a325f5f7..433cc9597 100644 --- a/packages/ai-vue/src/use-generate-audio.ts +++ b/packages/ai-vue/src/use-generate-audio.ts @@ -11,6 +11,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' import type { DeepReadonly, ShallowRef } from 'vue' @@ -97,9 +98,12 @@ export interface UseGenerateAudioReturn< * ``` */ export function useGenerateAudio( - options: Omit & { + options: Omit< + UseGenerateAudioOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: AudioGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateAudioReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-vue/src/use-generate-image.ts b/packages/ai-vue/src/use-generate-image.ts index 339f0666f..f2177a1c3 100644 --- a/packages/ai-vue/src/use-generate-image.ts +++ b/packages/ai-vue/src/use-generate-image.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, ImageGenerateInput, InferGenerationOutputFromReturn, } from '@tanstack/ai-client' @@ -107,9 +108,12 @@ export interface UseGenerateImageReturn< * ``` */ export function useGenerateImage( - options: Omit & { + options: Omit< + UseGenerateImageOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: ImageGenerationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateImageReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-vue/src/use-generate-speech.ts b/packages/ai-vue/src/use-generate-speech.ts index 33734afd1..e20c8f539 100644 --- a/packages/ai-vue/src/use-generate-speech.ts +++ b/packages/ai-vue/src/use-generate-speech.ts @@ -9,6 +9,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, SpeechGenerateInput, } from '@tanstack/ai-client' @@ -99,9 +100,12 @@ export interface UseGenerateSpeechReturn extends Omit< * ``` */ export function useGenerateSpeech( - options: Omit & { + options: Omit< + UseGenerateSpeechOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TTSResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateSpeechReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 67e4c51de..7571fa370 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -15,6 +15,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationResumeSnapshot, GenerationResumeState, InferGenerationOutputFromReturn, @@ -47,11 +48,24 @@ export interface UseGenerateVideoOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -138,9 +152,12 @@ export interface UseGenerateVideoReturn { // parameter is typed as `VideoGenerateResult` and `result` narrows to the // transform's return. See issue #848. export function useGenerateVideo( - options: Omit & { + options: Omit< + UseGenerateVideoOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: VideoGenerateResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerateVideoReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index e15114862..35435befe 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -16,6 +16,7 @@ import type { GenerationClientState, GenerationFetcher, GenerationPersistence, + GenerationPersistenceOptions, GenerationRestoredResult, GenerationResumeSnapshot, GenerationResumeState, @@ -50,11 +51,24 @@ export interface UseGenerationOptions { * for its `threadId` from the server (needs a connection with a * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. * - a storage adapter: client-driven — the lightweight snapshot is cached under - * `generation:` as a run streams and read back on mount. Media bytes are + * `generation:` as a run streams and read back on mount. Media bytes are * never stored. */ persistence?: boolean | GenerationPersistence - /** Thread id for this generation, stable across reloads. Server-driven mode (`persistence: true`) hydrates the last generation under this key. Falls back to `id`. */ + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The hook starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this in both + * modes, so derive it from your own domain and keep it identical across + * reloads (e.g. `` `video-${videoId}-start-frame` ``). It is also sent as the + * AG-UI thread id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations, where + * it falls back to `id` purely to satisfy the wire. + */ threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot @@ -142,9 +156,12 @@ export function useGeneration< TResult, TTransformed = void, >( - options: Omit, 'onResult'> & { + options: Omit< + UseGenerationOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseGenerationReturn< InferGenerationOutputFromReturn, TInput diff --git a/packages/ai-vue/src/use-summarize.ts b/packages/ai-vue/src/use-summarize.ts index d00a32e9b..4fb84d89b 100644 --- a/packages/ai-vue/src/use-summarize.ts +++ b/packages/ai-vue/src/use-summarize.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, SummarizeGenerateInput, } from '@tanstack/ai-client' @@ -103,9 +104,12 @@ export interface UseSummarizeReturn extends Omit< * ``` */ export function useSummarize( - options: Omit & { + options: Omit< + UseSummarizeOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: SummarizationResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseSummarizeReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-vue/src/use-transcription.ts b/packages/ai-vue/src/use-transcription.ts index 6bfae4c91..cd71ecb8c 100644 --- a/packages/ai-vue/src/use-transcription.ts +++ b/packages/ai-vue/src/use-transcription.ts @@ -10,6 +10,7 @@ import type { ConnectConnectionAdapter, GenerationClientState, GenerationFetcher, + GenerationPersistenceOptions, InferGenerationOutputFromReturn, TranscriptionGenerateInput, } from '@tanstack/ai-client' @@ -111,9 +112,12 @@ export interface UseTranscriptionReturn< * ``` */ export function useTranscription( - options: Omit & { + options: Omit< + UseTranscriptionOptions, + 'onResult' | 'persistence' | 'threadId' + > & { onResult?: (result: TranscriptionResult) => TTransformed - }, + } & GenerationPersistenceOptions, ): UseTranscriptionReturn< InferGenerationOutputFromReturn > { diff --git a/packages/ai-vue/tests/use-generation.test.ts b/packages/ai-vue/tests/use-generation.test.ts index dd30c72e0..9013303b2 100644 --- a/packages/ai-vue/tests/use-generation.test.ts +++ b/packages/ai-vue/tests/use-generation.test.ts @@ -271,6 +271,7 @@ describe('useGeneration', () => { const { result } = renderHook(() => useGeneration({ id: 'no-auto-fire', + threadId: 'no-auto-fire', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: snapshot, @@ -359,6 +360,7 @@ describe('useGeneration', () => { const { result } = renderHook(() => useGeneration({ id: 'hydrated', + threadId: 'hydrated', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, }), @@ -388,6 +390,7 @@ describe('useGeneration', () => { const { result } = renderHook(() => useGeneration({ id: 'running-hydrate', + threadId: 'running-hydrate', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, }), @@ -603,6 +606,7 @@ describe('useGenerateImage', () => { const { result } = renderHook(() => useGenerateImage({ id: 'img-hydrate', + threadId: 'img-hydrate', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, }), @@ -1036,6 +1040,7 @@ describe('useGenerateVideo', () => { const { result } = renderHook(() => useGenerateVideo({ id: 'video-no-auto-fire', + threadId: 'video-no-auto-fire', connection: adapter, persistence: { getItem, setItem: vi.fn(), removeItem: vi.fn() }, initialResumeSnapshot: videoResumeSnapshot, From 2358c1d7613d9546ab37a9581b1a13554c57bead Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:48:08 +1000 Subject: [PATCH 37/66] fix(persistence): fail loudly when a threadId lookup needs findLatestForThread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../src/reconstruct-generation.ts | 11 ++++ .../tests/reconstruct-generation.test.ts | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/ai-persistence/src/reconstruct-generation.ts b/packages/ai-persistence/src/reconstruct-generation.ts index e96fce2c0..dd8abb838 100644 --- a/packages/ai-persistence/src/reconstruct-generation.ts +++ b/packages/ai-persistence/src/reconstruct-generation.ts @@ -170,6 +170,17 @@ export async function reconstructGeneration( } } + // `findLatestForThread` is optional on the store, so calling it through `?.` + // would turn "this adapter cannot do thread lookups" into an indistinguishable + // "no run found" — leaving `persistence: true` silently restoring nothing + // forever. Fail loudly instead, and only on the path that actually needs it + // (a `?runId=` lookup never does). + if (!runId && !runStore.findLatestForThread) { + throw new Error( + 'reconstructGeneration resolved a request by threadId, but stores.generationRuns does not implement findLatestForThread. Implement it, or have the client request a specific ?runId=.', + ) + } + const run = runId ? await runStore.get(runId) : ((await runStore.findLatestForThread?.(threadId)) ?? null) diff --git a/packages/ai-persistence/tests/reconstruct-generation.test.ts b/packages/ai-persistence/tests/reconstruct-generation.test.ts index 84f2f4070..48d9af07b 100644 --- a/packages/ai-persistence/tests/reconstruct-generation.test.ts +++ b/packages/ai-persistence/tests/reconstruct-generation.test.ts @@ -7,6 +7,22 @@ async function body(response: Response): Promise { return (await response.json()) as ReconstructedGeneration } +/** + * A GenerationRunStore with only the three REQUIRED methods — no optional + * `findLatestForThread`. Built by hand rather than by spreading the memory + * store, whose methods live on the prototype and would be lost. + */ +function minimalRunStore( + runs: ReturnType['stores']['generationRuns'], +) { + return { + createOrResume: (input: Parameters[0]) => + runs.createOrResume(input), + update: (...args: Parameters) => runs.update(...args), + get: (runId: string) => runs.get(runId), + } +} + describe('reconstructGeneration', () => { it('maps a completed job to a resume snapshot', async () => { const persistence = memoryPersistence() @@ -135,4 +151,53 @@ describe('reconstructGeneration', () => { expect(response.status).toBe(403) expect(await response.json()).toEqual({ error: 'Forbidden' }) }) + + it('throws when a threadId lookup needs findLatestForThread and the store lacks it', async () => { + const base = memoryPersistence() + const persistence = { + ...base, + stores: { + ...base.stores, + generationRuns: minimalRunStore(base.stores.generationRuns), + }, + } + + // Without this guard the optional-call would yield `undefined ?? null`, i.e. + // an ordinary "no run found" — leaving `persistence: true` silently + // restoring nothing against an adapter that cannot support it. + await expect( + reconstructGeneration( + persistence, + new Request('http://example.test/api/generation?threadId=thread-1'), + ), + ).rejects.toThrow(/does not implement findLatestForThread/) + }) + + it('still resolves a runId lookup when the store lacks findLatestForThread', async () => { + const base = memoryPersistence() + await base.stores.generationRuns.createOrResume({ + runId: 'job-by-id', + threadId: 'thread-1', + activity: 'image', + provider: 'p', + model: 'm', + startedAt: 1, + }) + const persistence = { + ...base, + stores: { + ...base.stores, + generationRuns: minimalRunStore(base.stores.generationRuns), + }, + } + + // The method is only needed for the thread path — an explicit runId must + // keep working on a minimal adapter. + const response = await reconstructGeneration( + persistence, + new Request('http://example.test/api/generation?runId=job-by-id'), + ) + expect(response.status).toBe(200) + expect((await body(response)).resumeSnapshot).not.toBeNull() + }) }) From 14b8d008c4f7e6a30b0adeb1cef1bd1cceedf153 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:58:05 +1000 Subject: [PATCH 38/66] feat(persistence): storageKey for blob paths, and blobKey on the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated bytes were written to a hardcoded `artifacts//` 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. --- .changeset/generation-persistence.md | 10 ++- docs/persistence/generation-persistence.md | 4 +- docs/persistence/keep-generated-files.md | 30 ++++++++ .../src/routes/generation-hooks.tsx | 6 ++ .../src/routes/generations.audio.tsx | 2 + .../src/routes/generations.image.tsx | 3 + .../src/routes/generations.speech.tsx | 3 + .../src/routes/generations.summarize.tsx | 3 + .../src/routes/generations.transcription.tsx | 3 + .../src/routes/generations.video.tsx | 3 + .../skills/ai-persistence/SKILL.md | 10 +++ packages/ai-persistence/src/index.ts | 7 +- packages/ai-persistence/src/middleware.ts | 69 +++++++++++++++---- packages/ai-persistence/src/retrieve.ts | 25 +++++-- packages/ai-persistence/src/types.ts | 10 +++ .../tests/generation-artifacts.test.ts | 59 ++++++++++++++++ .../ai-core/client-persistence/SKILL.md | 21 ++++-- .../skills/ai-core/media-generation/SKILL.md | 10 ++- .../e2e/src/routes/generation-persistence.tsx | 4 +- 19 files changed, 250 insertions(+), 32 deletions(-) diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index b27c8c495..cb81b0096 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -13,12 +13,18 @@ Add generation persistence, mirroring chat: media generation runs survive a reload or dropped connection, restoring transparently into the normal hook fields, with optional durable storage of the generated bytes. -**Generation run store (server).** `withGenerationPersistence` records each run in a dedicated `generationRuns` (`GenerationRunStore`) store, keyed by the run's own `runId` (the same AG-UI run id the client sends), with `threadId` only an optional link — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `generationRuns` store, and `defineGenerationRunStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. +**Generation run store (server).** `withGenerationPersistence` records each run in a dedicated `generationRuns` (`GenerationRunStore`) store, keyed by the run's own `runId` (the same AG-UI run id the client sends), with `threadId` the run's scope — it no longer overloads the chat `RunStore`. The record holds the activity/provider/model, lifecycle status, result metadata, and (when byte storage is on) the durable artifact refs. `memoryPersistence()` ships an in-memory `generationRuns` store, and `defineGenerationRunStore` / `defineArtifactStore` / `defineBlobStore` type a custom store inline the way `defineMessageStore` / `defineRunStore` already do. **Server-side load (`reconstructGeneration`).** A new `reconstructGeneration(persistence, request, options?)` server helper — the generation parallel of `reconstructChat` — reads a `?runId=` (or `?threadId=`) from the request, authorizes it via an `authorize` callback, and returns `{ resumeSnapshot, activeRun }` JSON so a server-authoritative client restores the last run on mount. Requires the `generationRuns` store. `authorize` is optional at the type level for single-user and prototype routes, but any multi-user deployment must pass it: the run and thread ids arrive from the caller, so identity has to be derived from server-side session state and ownership checked before the helper reads persistence. The same applies to a route that serves artifact bytes by id. **Media byte storage (server).** When the backend also provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store, `withGenerationPersistence` writes each generated file's bytes to the blob store (key `artifacts//`), records an `ArtifactRecord`, and attaches `PersistedArtifactRef`s to the result and the run record. A new `artifactUrl` option stamps a durable app-origin serve URL onto each ref (a new `PersistedArtifactRef.url`) and rewrites the live result's media URL to it, so live and restored results both render media from your own origin instead of the provider's expiring link. Extraction is customizable via `extractArtifacts` / `nameArtifact`; `retrieveArtifact` / `retrieveBlob` (and the shared `artifactBlobKey`) serve the bytes back. Prompt media referenced by **URL** is not downloaded: the URL is caller-supplied, so fetching it server-side would be an SSRF vector, and the bytes are redundant. Opt in per-app with `allowInputUrl` (a predicate, so the check can't be skipped). Every artifact fetch is limited to `http:`/`https:`, timed out (`artifactFetchTimeoutMs`, default 30s) and size-capped (`maxArtifactBytes`, default 100 MiB); input fetches additionally block loopback/private/link-local hosts and refuse redirects. `artifactFetch` injects the `fetch` used, for routing downloads through an egress-restricted proxy. `memoryPersistence()` ships in-memory `artifacts`/`blobs` stores; the generation activities gained `threadId` / `runId` options. `@tanstack/ai-utils` adds `base64ToUint8Array`. -**Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take the same `persistence` option chat does: `true` (server-driven — cache nothing, hydrate the last run for a stable `threadId` on mount) or a storage adapter (client-driven — write a lightweight snapshot under `generation:` as the run streams and read it back on mount). Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and keeps `resumeState` for the in-flight run identity — there is no `resumeSnapshot` / `pendingArtifacts` / `resultArtifacts` hook field. If a run is still generating when the connection drops or the page reloads, the client re-attaches to it and finishes it in place (via the connection's `joinRun` durability replay), exactly like `useChat`. 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. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. +**Client (transparent restore).** Generation hooks (`useGenerateImage`, `useGenerateVideo`, `useGenerateAudio`, `useGenerateSpeech`, `useGeneration`, `useSummarize`, `useTranscription`, and their Solid/Vue/Svelte/Angular equivalents) take the same `persistence` option chat does: `true` (server-driven — cache nothing, hydrate the last run for a stable `threadId` on mount) or a storage adapter (client-driven — write a lightweight snapshot under `generation:` as the run streams and read it back on mount). Restore is **invisible**: it repaints the normal `result` / `status` / `error` fields as if the run had just finished, and keeps `resumeState` for the in-flight run identity — there is no `resumeSnapshot` / `pendingArtifacts` / `resultArtifacts` hook field. If a run is still generating when the connection drops or the page reloads, the client re-attaches to it and finishes it in place (via the connection's `joinRun` durability replay), exactly like `useChat`. 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. The snapshot never holds the generated bytes and never restarts provider work — generation still only begins on `generate(...)`. + +**`threadId` is required whenever `persistence` is set**, in both modes, enforced at the type level. It is the generation's _scope_ — a stable, app-chosen name for the slot successive runs fill (`product-123-hero`, `video-9-start-frame`) — not a link to a chat conversation, so a workflow generating media outside any conversation names it just as naturally. It stays optional for ephemeral generations, so existing call sites that do not opt into persistence are unaffected. Persistence keys on it in both modes; `id` is the devtools/instance label only and never keys storage. Previously the key fell back to `id` and then to a generated id, which silently wrote a different slot on every reload — restoring nothing while orphaning the last record. + +**Choose where bytes land.** `withGenerationPersistence`'s new `storageKey` option maps each artifact to its blob-store key, so generated media can live in your own folder structure instead of the default `artifacts//`. Server-side only — a browser-supplied key would be a path-traversal and cross-tenant-write vector. The resolved key is recorded on the new `ArtifactRecord.blobKey` (it is no longer derivable once arbitrary) and reads resolve through `resolveArtifactBlobKey`; records written before the field existed fall back to the default convention, so it is a non-breaking addition. + +`reconstructGeneration` now throws, rather than silently returning no run, when a `?threadId=` lookup is made against a `generationRuns` store that does not implement the optional `findLatestForThread`. A `?runId=` lookup never needs it and still works on a minimal adapter. The `persistence` option reuses the same `ChatStorageAdapter` contract as chat, so the shared `localStoragePersistence` / `sessionStoragePersistence` / `indexedDBPersistence` factories work for generations too with no type argument (a bare call is correct on either side). Untrusted snapshots are validated with the new `parseGenerationResumeSnapshot`. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 0c045d356..5cee024a2 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -207,7 +207,9 @@ const snapshots = localStoragePersistence({ keyPrefix: 'my-app:' }) function HeroImageGenerator() { const image = useGenerateImage({ - id: 'hero-image', + // The scope this generation fills. Required by `persistence`, and the key + // the snapshot is stored under — so keep it stable across reloads. + threadId: 'hero-image', connection: fetchServerSentEvents('/api/generate/image'), persistence: snapshots, }) diff --git a/docs/persistence/keep-generated-files.md b/docs/persistence/keep-generated-files.md index 95deb9514..40729092e 100644 --- a/docs/persistence/keep-generated-files.md +++ b/docs/persistence/keep-generated-files.md @@ -112,6 +112,36 @@ for production. Control what gets captured with `withGenerationPersistence`'s `extractArtifacts` (return your own descriptors) and `nameArtifact` (name each file) options. +## Choose where the bytes land + +By default an artifact's bytes are written under +`artifacts//`. Pass `storageKey` to put them in your own +folder structure instead — useful when the bucket is shared with the rest of +your app, or when you want media grouped by the thing it belongs to rather than +by the run that produced it: + +```ts group=generation-bytes +const storageKeyOptions = withGenerationPersistence(persistence, { + storageKey: ({ runId, artifactId, role, name }) => + `products/${role}/${runId}-${artifactId}-${name}`, +}) +``` + +Two things worth knowing: + +**The resolved key is recorded on the artifact.** Once the path is arbitrary it +can no longer be recomputed from the record, so it is stored as +`ArtifactRecord.blobKey` and reads resolve through it. Records written before +this existed fall back to the default convention, so adding `storageKey` to an +app with existing artifacts does not orphan them — but it does mean the default +convention can never be changed retroactively. + +**Returning a non-unique key overwrites.** Include `artifactId`, or something +equally unique, unless overwriting is what you want. + +This is server-side only, deliberately. A key supplied by the browser would be a +path-traversal and cross-tenant-write vector. + ## Prompt media referenced by URL What gets stored is the **generated output**. When a provider returns an diff --git a/examples/ts-react-chat/src/routes/generation-hooks.tsx b/examples/ts-react-chat/src/routes/generation-hooks.tsx index c5cab72bf..f42264766 100644 --- a/examples/ts-react-chat/src/routes/generation-hooks.tsx +++ b/examples/ts-react-chat/src/routes/generation-hooks.tsx @@ -155,36 +155,42 @@ function GenerationHooksPage() { const image = useGenerateImage({ id: 'generation-hooks:useGenerateImage', + threadId: 'generation-hooks:useGenerateImage', connection: imageConnection, persistence: generationPersistence, }) const audio = useGenerateAudio({ id: 'generation-hooks:useGenerateAudio', + threadId: 'generation-hooks:useGenerateAudio', connection: audioConnection, persistence: generationPersistence, }) const speech = useGenerateSpeech({ id: 'generation-hooks:useGenerateSpeech', + threadId: 'generation-hooks:useGenerateSpeech', connection: speechConnection, persistence: generationPersistence, }) const transcription = useTranscription({ id: 'generation-hooks:useTranscription', + threadId: 'generation-hooks:useTranscription', connection: transcriptionConnection, persistence: generationPersistence, }) const summarize = useSummarize({ id: 'generation-hooks:useSummarize', + threadId: 'generation-hooks:useSummarize', connection: summarizeConnection, persistence: generationPersistence, }) const video = useGenerateVideo({ id: 'generation-hooks:useGenerateVideo', + threadId: 'generation-hooks:useGenerateVideo', connection: videoConnection, persistence: generationPersistence, }) diff --git a/examples/ts-react-chat/src/routes/generations.audio.tsx b/examples/ts-react-chat/src/routes/generations.audio.tsx index 643c12fc6..5ce17525c 100644 --- a/examples/ts-react-chat/src/routes/generations.audio.tsx +++ b/examples/ts-react-chat/src/routes/generations.audio.tsx @@ -79,6 +79,7 @@ function AudioGenerationForm({ if (mode === 'hooks') { return { id: `audio:${mode}:${config.id}`, + threadId: `audio:${mode}:${config.id}`, connection: fetchServerSentEvents('/api/generate/audio'), body: { provider: config.id, model: selectedModel }, persistence: audioPersistence, @@ -87,6 +88,7 @@ function AudioGenerationForm({ } return { id: `audio:${mode}:${config.id}`, + threadId: `audio:${mode}:${config.id}`, fetcher: (input: { prompt: string; duration?: number }) => generateAudioFn({ data: { ...input, provider: config.id, model: selectedModel }, diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index 71deb520c..bd929abd3 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -20,6 +20,7 @@ function StreamingImageGeneration() { const hookReturn = useGenerateImage({ id: 'image:streaming', + threadId: 'image:streaming', connection: fetchServerSentEvents('/api/generate/image'), persistence: imagePersistence, }) @@ -41,6 +42,7 @@ function DirectImageGeneration() { const hookReturn = useGenerateImage({ id: 'image:direct', + threadId: 'image:direct', fetcher: (input) => generateImageFn({ data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, @@ -65,6 +67,7 @@ function ServerFnImageGeneration() { const hookReturn = useGenerateImage({ id: 'image:server-fn', + threadId: 'image:server-fn', fetcher: (input) => generateImageStreamFn({ data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, diff --git a/examples/ts-react-chat/src/routes/generations.speech.tsx b/examples/ts-react-chat/src/routes/generations.speech.tsx index 7631b5768..01c5f8e4b 100644 --- a/examples/ts-react-chat/src/routes/generations.speech.tsx +++ b/examples/ts-react-chat/src/routes/generations.speech.tsx @@ -53,6 +53,7 @@ function SpeechGenerationForm({ if (mode === 'streaming') { return { id: `speech:${mode}:${config.id}`, + threadId: `speech:${mode}:${config.id}`, connection: fetchServerSentEvents('/api/generate/speech'), body: { provider: config.id }, persistence: speechPersistence, @@ -62,6 +63,7 @@ function SpeechGenerationForm({ if (mode === 'direct') { return { id: `speech:${mode}:${config.id}`, + threadId: `speech:${mode}:${config.id}`, fetcher: (input: { text: string; voice?: string }) => generateSpeechFn({ data: { ...input, provider: config.id }, @@ -72,6 +74,7 @@ function SpeechGenerationForm({ } return { id: `speech:${mode}:${config.id}`, + threadId: `speech:${mode}:${config.id}`, fetcher: (input: { text: string; voice?: string }) => generateSpeechStreamFn({ data: { ...input, provider: config.id }, diff --git a/examples/ts-react-chat/src/routes/generations.summarize.tsx b/examples/ts-react-chat/src/routes/generations.summarize.tsx index c8627a9b0..f0fa38384 100644 --- a/examples/ts-react-chat/src/routes/generations.summarize.tsx +++ b/examples/ts-react-chat/src/routes/generations.summarize.tsx @@ -51,6 +51,7 @@ function StreamingSummarize() { // route reads `model` from `body.data` to pick the openaiSummarize variant. const hookReturn = useSummarize({ id: 'summarize:streaming', + threadId: 'summarize:streaming', connection: fetchServerSentEvents('/api/summarize'), body: { model }, persistence: summarizePersistence, @@ -86,6 +87,7 @@ function DirectSummarize() { // server-fn resolves. const hookReturn = useSummarize({ id: 'summarize:direct', + threadId: 'summarize:direct', fetcher: (input) => summarizeFn({ data: { ...input, model } }), persistence: summarizePersistence, }) @@ -115,6 +117,7 @@ function ServerFnSummarize() { // routes chunks through `onChunk` exactly like the connect-adapter path. const hookReturn = useSummarize({ id: 'summarize:server-fn', + threadId: 'summarize:server-fn', fetcher: (input) => summarizeStreamFn({ data: { ...input, model } }), persistence: summarizePersistence, onChunk: (chunk) => diff --git a/examples/ts-react-chat/src/routes/generations.transcription.tsx b/examples/ts-react-chat/src/routes/generations.transcription.tsx index b31590fc7..4034021ab 100644 --- a/examples/ts-react-chat/src/routes/generations.transcription.tsx +++ b/examples/ts-react-chat/src/routes/generations.transcription.tsx @@ -28,6 +28,7 @@ function TranscriptionForm({ if (mode === 'streaming') { return { id: `transcription:${mode}:${config.id}`, + threadId: `transcription:${mode}:${config.id}`, connection: fetchServerSentEvents('/api/transcribe'), body: { provider: config.id }, persistence: transcriptionPersistence, @@ -36,6 +37,7 @@ function TranscriptionForm({ if (mode === 'direct') { return { id: `transcription:${mode}:${config.id}`, + threadId: `transcription:${mode}:${config.id}`, fetcher: (input: TranscriptionGenerateInput) => transcribeFn({ data: { @@ -51,6 +53,7 @@ function TranscriptionForm({ } return { id: `transcription:${mode}:${config.id}`, + threadId: `transcription:${mode}:${config.id}`, fetcher: (input: TranscriptionGenerateInput) => transcribeStreamFn({ data: { diff --git a/examples/ts-react-chat/src/routes/generations.video.tsx b/examples/ts-react-chat/src/routes/generations.video.tsx index f269f4684..e50de1c52 100644 --- a/examples/ts-react-chat/src/routes/generations.video.tsx +++ b/examples/ts-react-chat/src/routes/generations.video.tsx @@ -16,6 +16,7 @@ function StreamingVideoGeneration() { const hookReturn = useGenerateVideo({ id: 'video:streaming', + threadId: 'video:streaming', connection: fetchServerSentEvents('/api/generate/video'), persistence: videoPersistence, }) @@ -30,6 +31,7 @@ function DirectVideoGeneration() { const hookReturn = useGenerateVideo({ id: 'video:direct', + threadId: 'video:direct', fetcher: (input) => generateVideoFn({ data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, @@ -47,6 +49,7 @@ function ServerFnVideoGeneration() { const hookReturn = useGenerateVideo({ id: 'video:server-fn', + threadId: 'video:server-fn', fetcher: (input) => generateVideoStreamFn({ data: { ...input, prompt: resolveMediaPrompt(input.prompt).text }, diff --git a/packages/ai-persistence/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md index ea3dfb329..ff6c5de57 100644 --- a/packages/ai-persistence/skills/ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -62,6 +62,16 @@ a generation is only an optional _link_ to a chat, never the job's identity. To build the R2/D1-backed byte stores for a Worker, see **ai-persistence/build-cloudflare-artifact-store**. +**Where bytes land.** Default blob key is `artifacts//`. Pass +`storageKey` to `withGenerationPersistence` for your own folder structure — it +receives `{ artifactId, runId, threadId, role, activity, path, mimeType, name }` +and returns the key. Server-side only (a browser-supplied key is path traversal + +cross-tenant writes). The resolved key is recorded on `ArtifactRecord.blobKey` +because it is no longer derivable; read through `resolveArtifactBlobKey(record)`, +never by recomputing. Records predating `blobKey` fall back to the default +convention — which is why that convention can never be changed retroactively. A +non-unique key overwrites, so include `artifactId` unless that is intended. + **Byte storage stores generated output, not prompt URLs.** Provider result URLs expire, so they are downloaded and kept. Prompt media sent as base64 (`source: { type: 'data' }`) is stored too. Prompt media sent as a **URL** is diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index 5d1eead13..ce2579aa0 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -78,7 +78,12 @@ export type { } from './reconstruct-generation' // Server helpers: retrieve a persisted generation artifact + its bytes -export { retrieveArtifact, retrieveBlob, artifactBlobKey } from './retrieve' +export { + retrieveArtifact, + retrieveBlob, + artifactBlobKey, + resolveArtifactBlobKey, +} from './retrieve' // Reference in-memory implementation export { memoryPersistence } from './memory' diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 0a52b8ed2..bf55b0c4c 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -59,6 +59,34 @@ export interface WithPersistenceOptions { * Return `undefined` to leave a ref without a durable URL. */ artifactUrl?: (ref: PersistedArtifactRef) => string | undefined + /** + * Choose the blob-store key each artifact's bytes are written under, so + * generated media can land in your own folder structure rather than the + * default `artifacts//`. + * + * ```ts + * storageKey: ({ runId, artifactId, mimeType }) => + * `video/${videoId}/frames/${runId}-${artifactId}.png` + * ``` + * + * Server-side only, and deliberately so: a key supplied by the browser would + * be a path-traversal and cross-tenant-write vector. + * + * The resolved key is recorded on `ArtifactRecord.blobKey`, because once the + * path is arbitrary a reader can no longer recompute it. Returning a + * non-unique key overwrites — include `artifactId` (or something equally + * unique) unless you intend that. + */ + storageKey?: (input: { + artifactId: string + runId: string + threadId: string + role: PersistedArtifactRole + activity: PersistedArtifactActivity + path: string + mimeType: string + name: string + }) => string /** * Opt in to fetching prompt media referenced by URL (`role: 'input'`). * @@ -907,7 +935,31 @@ async function persistGenerationArtifacts( // no blob, no record, no ref — the rest of the run is unaffected. if (!resolved) continue const { body, size, mimeType, sourceUrl } = resolved - const key = artifactBlobKey({ runId, artifactId }) + // Resolved before the blob write so `storageKey` can build a path from the + // final filename (extensions, slugs) rather than guessing at one. + const name = + opts?.nameArtifact?.({ + descriptor: { ...descriptor, mimeType }, + activity, + provider: ctx.provider, + model: ctx.model, + threadId, + runId, + index, + }) ?? + descriptor.name ?? + defaultArtifactName({ ...descriptor, mimeType }, activity, index) + const key = + opts?.storageKey?.({ + artifactId, + runId, + threadId, + role: descriptor.role, + activity, + path: descriptor.path, + mimeType, + name, + }) ?? artifactBlobKey({ runId, artifactId }) const stored = await persistence.stores.blobs.put(key, body, { contentType: mimeType, customMetadata: { @@ -922,22 +974,13 @@ async function persistGenerationArtifacts( // reports the real byte length once it has drained the stream. const resolvedSize = size || stored.size || 0 const createdAtMs = Date.now() - const name = - opts?.nameArtifact?.({ - descriptor: { ...descriptor, mimeType }, - activity, - provider: ctx.provider, - model: ctx.model, - threadId, - runId, - index, - }) ?? - descriptor.name ?? - defaultArtifactName({ ...descriptor, mimeType }, activity, index) const record: ArtifactRecord = { artifactId, runId, threadId, + // Always recorded: with a custom `storageKey` the path is no longer + // derivable from the record, so the reader has to be told where it went. + blobKey: key, name, mimeType, size: resolvedSize, diff --git a/packages/ai-persistence/src/retrieve.ts b/packages/ai-persistence/src/retrieve.ts index 0984e3ab8..9b5d8faa8 100644 --- a/packages/ai-persistence/src/retrieve.ts +++ b/packages/ai-persistence/src/retrieve.ts @@ -1,9 +1,12 @@ import type { AIPersistence, ArtifactRecord, BlobObject } from './types' /** - * The blob-store key a generation artifact's bytes are stored under. - * `withGenerationPersistence` writes bytes to this key; `retrieveBlob` reads - * from it. Keep the two in lockstep by using this helper on both sides. + * The DEFAULT blob-store key a generation artifact's bytes are stored under, + * used when `withGenerationPersistence` is given no `storageKey` mapper. + * + * Prefer {@link resolveArtifactBlobKey} for reads: a record written with a + * custom `storageKey` carries its real key in `blobKey`, and recomputing the + * default would look in the wrong place. */ export function artifactBlobKey( ref: Pick, @@ -11,6 +14,18 @@ export function artifactBlobKey( return `artifacts/${ref.runId}/${ref.artifactId}` } +/** + * The blob-store key to read an artifact's bytes from: the key recorded when it + * was written, falling back to the default convention for records written + * before `blobKey` existed. + * + * The fallback is what makes `blobKey` a non-breaking addition — and why the + * default convention can never be changed retroactively without one. + */ +export function resolveArtifactBlobKey(record: ArtifactRecord): string { + return record.blobKey ?? artifactBlobKey(record) +} + /** * Look up a persisted generation artifact's metadata by id. Returns `null` when * the persistence has no `artifacts` store or no record matches — so a serve @@ -40,6 +55,8 @@ export async function retrieveBlob( : artifact if (!record) return null - const blob = await persistence.stores.blobs?.get(artifactBlobKey(record)) + const blob = await persistence.stores.blobs?.get( + resolveArtifactBlobKey(record), + ) return blob ?? null } diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index 18ceec6ed..baac6aa1c 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -422,6 +422,16 @@ export interface ArtifactRecord { artifactId: string runId: string threadId: string + /** + * The blob-store key these bytes actually live under. + * + * Optional for backwards compatibility: records written before this existed + * resolve via the default `artifacts//` convention. New + * records always carry it, which is what lets `storageKey` put bytes anywhere + * — a reader can no longer recompute the path, so it has to be remembered. + * Use `resolveArtifactBlobKey(record)` rather than reading it directly. + */ + blobKey?: string name: string mimeType: string size: number diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index bbc4db067..bab03807a 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -209,6 +209,65 @@ describe('withGenerationPersistence generation artifacts', () => { expect(await retrieveBlob(persistence, 'missing')).toBeNull() }) + it('writes bytes under a custom storageKey and reads them back via blobKey', async () => { + const persistence = memoryPersistence() + + const result = await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-storage-key', + runId: 'run-storage-key', + middleware: [ + withGenerationPersistence(persistence, { + storageKey: ({ runId, artifactId, role }) => + `my-app/videos/hero/${role}-${runId}-${artifactId}.png`, + }), + ], + }) + + const artifactId = result.artifacts![0]!.artifactId + const expectedKey = `my-app/videos/hero/output-run-storage-key-${artifactId}.png` + + // The bytes land where the mapper said, NOT under the default convention. + await expect( + persistence.stores.blobs!.get(expectedKey).then((b) => b?.text()), + ).resolves.toBe('output-image') + expect( + await persistence.stores.blobs!.get( + `artifacts/run-storage-key/${artifactId}`, + ), + ).toBeNull() + + // The record remembers the real key, so reads resolve without recomputing. + const record = await retrieveArtifact(persistence, artifactId) + expect(record?.blobKey).toBe(expectedKey) + await expect( + (await retrieveBlob(persistence, artifactId))?.text(), + ).resolves.toBe('output-image') + }) + + it('resolves a record written before blobKey existed via the default convention', async () => { + const persistence = memoryPersistence() + + await generateImage({ + adapter: imageAdapter(), + prompt: 'make an image', + threadId: 'thread-legacy', + runId: 'run-legacy', + middleware: [withGenerationPersistence(persistence)], + }) + const stored = (await persistence.stores.artifacts!.list('run-legacy'))[0]! + + // Simulate a row saved before the column existed: no blobKey at all. + const { blobKey: _dropped, ...legacy } = stored + await persistence.stores.artifacts!.save(legacy) + + // Still readable — the fallback is what makes blobKey a non-breaking add. + await expect( + (await retrieveBlob(persistence, legacy.artifactId))?.text(), + ).resolves.toBe('output-image') + }) + it('persists non-image media outputs', async () => { const persistence = memoryPersistence() diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index 81c3d9510..fa28d00bb 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -9,8 +9,9 @@ description: > durability. Use for SPA reload durability — NOT server history alone. Also covers generation hooks (useGenerateImage etc.), same two modes as chat: client-driven (adapter) persists a lightweight resume snapshot under - generation:; server-driven (persistence: true + threadId) hydrates the last - generation from the server on mount, nothing cached. + generation: (threadId is REQUIRED with persistence); server-driven + (persistence: true) hydrates the last generation from the server on mount, + nothing cached. No extra package: the adapters ship in the framework packages. type: sub-skill library: tanstack-ai @@ -135,7 +136,7 @@ The hook return is exactly `generate` / `result` / `isLoading` / `error` / ```tsx const image = useGenerateImage({ - id: 'hero-image', // stable — the storage key is `generation:` + threadId: 'hero-image', // REQUIRED by persistence — key is `generation:` connection: fetchServerSentEvents('/api/generate/image'), persistence: localStoragePersistence(), // bare — no type argument }) @@ -144,15 +145,21 @@ const image = useGenerateImage({ // WHILE a run is streaming. ``` -- The lightweight snapshot is cached in the browser under `generation:` as a - run streams, and read back on mount. +- The lightweight snapshot is cached in the browser under `generation:` + as a run streams, and read back on mount. +- **`threadId` is REQUIRED whenever `persistence` is set** — it is a type error + to omit it, in BOTH modes. It is the generation's _scope_: a stable, + app-chosen name for the slot successive runs fill (`product-123-hero`, + `video-9-start-frame`), NOT a link to a chat conversation. Never suggest + falling back to `id`: `id` is the devtools/instance label only and never keys + storage. - `localStoragePersistence()` (and the session / IndexedDB factories) take **no** type argument here — a bare call defaults to the generation snapshot shape. - Hydration is automatic on mount and validated (`parseGenerationResumeSnapshot`); an explicit `initialResumeSnapshot` seed skips it. - The `generation:` key segment means a chat and a generation client can share - an id and an adapter without colliding. + a threadId and an adapter without colliding. - `result` needs byte storage to come back. A client snapshot never holds the bytes, so on its own a reload restores `status` and `error` while `result` stays `null` (see byte storage below). @@ -161,7 +168,7 @@ const image = useGenerateImage({ ```tsx const image = useGenerateImage({ - threadId, // stable — the key the last generation is hydrated under (falls back to id) + threadId, // REQUIRED — the scope the last generation is hydrated under connection: fetchServerSentEvents('/api/generate/image'), persistence: true, }) diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index d9c44f00d..fc9d63e44 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -621,9 +621,13 @@ export async function GET(req: Request) { On the client, the generation hooks mirror chat's two persistence modes: `persistence: ` (client-driven, caches a lightweight snapshot under -`generation:`) or `persistence: true` + a stable `threadId` (server-driven — -hydrates the last generation for the thread on mount via the connection's -`hydrateGeneration` handler, backed by a `reconstructGeneration` GET route). The +`generation:`) or `persistence: true` (server-driven — hydrates the +last generation for the thread on mount via the connection's `hydrateGeneration` +handler, backed by a `reconstructGeneration` GET route). **Both modes require a +stable `threadId`** — it is a type error to set `persistence` without one. It is +the generation's scope (the slot successive runs fill, e.g. +`video-9-start-frame`), not a link to a chat; `id` is the devtools label only and +never keys storage. The hooks are transparent (like `useChat`): a reload repaints `status` / `result` / `error`, not a separate `resumeSnapshot`. Neither mode stores media bytes on the client — but because the `artifactUrl` above stamps a durable URL onto each ref diff --git a/testing/e2e/src/routes/generation-persistence.tsx b/testing/e2e/src/routes/generation-persistence.tsx index f5fd4b511..47779713f 100644 --- a/testing/e2e/src/routes/generation-persistence.tsx +++ b/testing/e2e/src/routes/generation-persistence.tsx @@ -27,7 +27,9 @@ export const Route = createFileRoute('/generation-persistence')({ function GenerationPersistencePage() { const image = useGenerateImage({ - id: 'generation-persistence', + // The scope, and the storage key: `generation:generation-persistence`. + // Required by `persistence`, so the spec's STORAGE_KEY stays stable. + threadId: 'generation-persistence', connection, persistence: snapshots, }) From 4c0a9b4c7b44c4f8f649a57313f0e1a711ba912f Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:20:35 +1000 Subject: [PATCH 39/66] feat(persistence): server-side generation persistence in the example; require store methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/persistence/build-your-own-adapter.md | 13 +- .../src/lib/generation-persistence.ts | 12 +- .../src/lib/generation-server-store.ts | 24 ++++ .../src/routes/api.generate.image.ts | 90 ++++++++++++- .../src/routes/api.interrupts.test.ts | 123 ------------------ .../src/routes/generations.image.tsx | 25 +++- .../build-cloudflare-artifact-store/SKILL.md | 4 +- .../src/reconstruct-generation.ts | 13 +- packages/ai-persistence/src/types.ts | 34 +++-- .../tests/persistence-fixtures.ts | 6 + .../tests/reconstruct-generation.test.ts | 65 --------- 11 files changed, 174 insertions(+), 235 deletions(-) create mode 100644 examples/ts-react-chat/src/lib/generation-server-store.ts delete mode 100644 examples/ts-react-chat/src/routes/api.interrupts.test.ts diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index 4db297fcf..5a14afd95 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -83,7 +83,7 @@ erDiagram RUN ||--o{ INTERRUPT : "run_id — a run may pause on interrupts" MESSAGES ||..o{ GENERATION_RUN : "thread_id — optional link" GENERATION_RUN ||--o{ ARTIFACT : "run_id — a run produces artifacts" - ARTIFACT ||--|| BLOB : "key — the bytes" + ARTIFACT ||--|| BLOB : "blob_key — the bytes" MESSAGES { string thread_id PK @@ -112,6 +112,7 @@ erDiagram ARTIFACT { string artifact_id PK string run_id + string blob_key "where the bytes live" string mime_type int size } @@ -729,7 +730,8 @@ function createGenerationRunStore(db: DatabaseSync) { `ArtifactStore` holds one metadata row per generated file — its `runId`, `mimeType`, `size`, and a `createdAt`. The bytes live in the blob store below. `save` is an upsert, `list(runId)` returns every artifact for a run (`[]` when -none). `delete` / `deleteForRun` are optional but cheap to add. +none). `delete` / `deleteForRun` are required — retention and erasure are the +point of storing media durably, and they mirror `BlobStore.delete`. ```ts import { DatabaseSync } from 'node:sqlite' @@ -1312,10 +1314,11 @@ interface ArtifactRecord { artifactId: string runId: string threadId: string + blobKey?: string // where the bytes live; absent on pre-blobKey records name: string mimeType: string size: number - sourceUrl?: string + sourceUrl?: string // where the bytes were fetched FROM (provenance) createdAt: number // epoch ms } @@ -1323,8 +1326,8 @@ interface ArtifactStore { save(record: ArtifactRecord): Promise get(artifactId: string): Promise list(runId: string): Promise> // [] when the run has none - delete?(artifactId: string): Promise - deleteForRun?(runId: string): Promise + delete(artifactId: string): Promise + deleteForRun(runId: string): Promise } ``` diff --git a/examples/ts-react-chat/src/lib/generation-persistence.ts b/examples/ts-react-chat/src/lib/generation-persistence.ts index 09b95e3a7..fbc52edf4 100644 --- a/examples/ts-react-chat/src/lib/generation-persistence.ts +++ b/examples/ts-react-chat/src/lib/generation-persistence.ts @@ -5,10 +5,14 @@ import type { GenerationPersistence } from '@tanstack/ai-client' * Shared generation persistence for the example app. * * Every generation route wires its hooks through this adapter. The client - * namespaces its record under `generation:`, so one adapter serves - * every hook as long as each hook passes a stable `id` — each hook's last - * run (status, result metadata, error — never media bytes) then survives a - * full page reload exactly as the library intends. + * namespaces its record under `generation:`, so one adapter serves + * every hook as long as each passes a stable `threadId` — required whenever + * `persistence` is set. Each hook's last run (status, result metadata, error — + * never media bytes) then survives a full page reload. + * + * This is the CLIENT-driven half. `/api/generate/image` also runs the + * server-driven half (`withGenerationPersistence` + `reconstructGeneration`); + * see `generations.image.tsx` for a hook using `persistence: true` instead. */ export const generationPersistence: GenerationPersistence = localStoragePersistence({ keyPrefix: 'example:' }) diff --git a/examples/ts-react-chat/src/lib/generation-server-store.ts b/examples/ts-react-chat/src/lib/generation-server-store.ts new file mode 100644 index 000000000..e028788c4 --- /dev/null +++ b/examples/ts-react-chat/src/lib/generation-server-store.ts @@ -0,0 +1,24 @@ +import { memoryPersistence } from '@tanstack/ai-persistence' + +/** + * Server-side generation persistence for the example app — the counterpart to + * `generation-persistence.ts`, which is the client-driven half. + * + * `memoryPersistence()` ships all three stores this needs: `generationRuns` + * (the run record `reconstructGeneration` reads back) plus `artifacts` + `blobs` + * (the generated bytes, so a restored `result` can actually render its image + * rather than coming back `null`). + * + * Module-level on purpose: the POST that records a run and the GET that reads + * it back are separate requests, so they have to share one instance. In-memory + * means it resets when the dev server restarts — fine for a demo, and exactly + * what the docs recommend for development. Point the stores at a durable + * backend for production; `sqlite-persistence.ts` in this folder shows the + * shape for the chat stores. + */ +export const generationServerPersistence = memoryPersistence() + +/** Serve URL for a stored artifact — must match the GET route below it. */ +export function artifactServeUrl(artifactId: string): string { + return `/api/generate/image?artifact=${encodeURIComponent(artifactId)}` +} diff --git a/examples/ts-react-chat/src/routes/api.generate.image.ts b/examples/ts-react-chat/src/routes/api.generate.image.ts index 8c13700f3..95219bebf 100644 --- a/examples/ts-react-chat/src/routes/api.generate.image.ts +++ b/examples/ts-react-chat/src/routes/api.generate.image.ts @@ -1,24 +1,100 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' import { grokImage } from '@tanstack/ai-grok' +import { + reconstructGeneration, + retrieveArtifact, + retrieveBlob, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import { + artifactServeUrl, + generationServerPersistence as persistence, +} from '../lib/generation-server-store' +/** + * Image generation with SERVER-side persistence — the other half of the + * client-driven adapter in `generation-persistence.ts`. + * + * `withGenerationPersistence` records each run in `stores.generationRuns` and, + * because this backend also has `artifacts` + `blobs`, copies the generated + * bytes out of the provider's expiring URL into our own store. `artifactUrl` + * then stamps an app-origin serve URL onto every ref and rewrites the live + * result to it — so both the live and the restored image render from here. + * + * The GET does double duty, which is why it branches: + * - `?artifact=` serves stored bytes (the URL `artifactUrl` produced). + * - otherwise `reconstructGeneration` answers a `?threadId=` mount hydration, + * which is what `persistence: true` on the client calls. + */ export const Route = createFileRoute('/api/generate/image')({ server: { handlers: { POST: async ({ request }) => { - const body = await request.json() - const { prompt, size, model, numberOfImages } = body.data + // Carries `threadId` / `runId` off the AG-UI envelope as well as the + // input, so the run record is filed under the scope the client will + // later hydrate by. + const { input, threadId, runId } = await generationParamsFromRequest( + 'image', + request, + ) + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } const stream = generateImage({ - adapter: grokImage(model ?? 'grok-imagine-image'), - prompt, - size, - numberOfImages, + adapter: grokImage('grok-imagine-image'), + prompt: input.prompt, + // `size` is deliberately not forwarded: the generic image input types + // it as `string`, while each adapter narrows it to its own union, and + // this page's UI never sends one. + ...(input.numberOfImages + ? { numberOfImages: input.numberOfImages } + : {}), + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), stream: true, + middleware: [ + withGenerationPersistence(persistence, { + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) return toServerSentEventsResponse(stream) }, + + GET: async ({ request }) => { + const artifactId = new URL(request.url).searchParams.get('artifact') + + if (artifactId) { + const artifact = await retrieveArtifact(persistence, artifactId) + if (!artifact) return new Response('not found', { status: 404 }) + + // A real multi-user app MUST authorize here before serving: the id + // comes from the caller, and `ArtifactRecord` carries the `threadId` + // to check it against. This demo is single-user, so there is no + // session to check. + const blob = await retrieveBlob(persistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + }, + }) + } + + // Mount hydration for `persistence: true`: resolves the latest run for + // `?threadId=` and returns `{ resumeSnapshot, activeRun }`. Pass + // `authorize` here in a multi-user app. + return await reconstructGeneration(persistence, request) + }, }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.interrupts.test.ts b/examples/ts-react-chat/src/routes/api.interrupts.test.ts deleted file mode 100644 index f8b30d74a..000000000 --- a/examples/ts-react-chat/src/routes/api.interrupts.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { EventType } from '@tanstack/ai' -import { - createFeedingMiddleware, - feedingInterruptId, - resolveToolChoice, -} from './api.interrupts' -import type { - ChatMiddlewareConfig, - ChatMiddlewareContext, - StreamChunk, -} from '@tanstack/ai' - -function makeCtx( - overrides: Partial, -): ChatMiddlewareContext { - return { - requestId: 'req', - streamId: 'stream', - runId: 'run-1', - threadId: 'thread-1', - phase: 'init', - iteration: 0, - chunkIndex: 0, - abort: () => {}, - defer: () => {}, - context: undefined, - ...overrides, - } as unknown as ChatMiddlewareContext -} - -describe('resolveToolChoice', () => { - // Regression: forcing a tool on the continuation made the model re-call the - // just-approved tool instead of answering, leaving an empty reply. - it('never forces a tool on a continuation (resume)', () => { - expect( - resolveToolChoice({ isResume: true, forceTool: 'admitRescue' }), - ).toBeUndefined() - expect(resolveToolChoice({ isResume: true, generic: true })).toBeUndefined() - }) - - it('forces the requested tool on the first turn', () => { - expect( - resolveToolChoice({ isResume: false, forceTool: 'admitRescue' }), - ).toEqual({ type: 'function', name: 'admitRescue' }) - }) - - it('forbids tools for the generic scenario', () => { - expect(resolveToolChoice({ isResume: false, generic: true })).toBe('none') - }) - - it('defaults to auto (undefined) with no hints', () => { - expect(resolveToolChoice({ isResume: false })).toBeUndefined() - }) -}) - -describe('feeding interrupt correlation', () => { - // Regression: the interrupt id was built from the provider chunk.runId - // (e.g. `openai-...`), but the client resumes with the request runId as - // parentRunId, so the ids never matched and resume failed as - // `unknown-interrupt`. It must key off ctx.runId. - it('keys the interrupt id off the request run id, not the provider chunk id', () => { - const middleware = createFeedingMiddleware() - const chunk = { - type: EventType.RUN_FINISHED, - runId: 'openai-provider-9', - threadId: 'thread-1', - timestamp: 0, - outcome: { type: 'success' }, - } as unknown as StreamChunk - - const result = middleware.onChunk?.(makeCtx({ runId: 'req-1' }), chunk) - const outcome = ( - result as { outcome?: { interrupts?: Array<{ id: string }> } } | undefined - )?.outcome - expect(outcome?.interrupts?.[0]?.id).toBe(feedingInterruptId('req-1')) - expect(outcome?.interrupts?.[0]?.id).not.toContain('openai-provider-9') - }) - - it('accepts a resume that references feeding_', () => { - const middleware = createFeedingMiddleware() - const config = { - messages: [{ role: 'user', content: 'set a feeding schedule' }], - resume: [ - { - interruptId: feedingInterruptId('req-1'), - status: 'resolved', - payload: { mealsPerDay: 2, diet: 'mice and berries' }, - }, - ], - } as unknown as ChatMiddlewareConfig - - const patch = middleware.onConfig?.( - makeCtx({ phase: 'init', parentRunId: 'req-1' }), - config, - ) - const messages = (patch as { messages?: Array } | undefined) - ?.messages - // original message plus the appended confirmation prompt - expect(messages?.length).toBe(2) - }) - - it('rejects a resume that references a different run id', () => { - const middleware = createFeedingMiddleware() - const config = { - messages: [], - resume: [ - { - interruptId: feedingInterruptId('openai-provider-9'), - status: 'resolved', - payload: { mealsPerDay: 2, diet: 'mice and berries' }, - }, - ], - } as unknown as ChatMiddlewareConfig - - expect(() => - middleware.onConfig?.( - makeCtx({ phase: 'init', parentRunId: 'req-1' }), - config, - ), - ).toThrow(/must resolve only/) - }) -}) diff --git a/examples/ts-react-chat/src/routes/generations.image.tsx b/examples/ts-react-chat/src/routes/generations.image.tsx index bd929abd3..09dc26e56 100644 --- a/examples/ts-react-chat/src/routes/generations.image.tsx +++ b/examples/ts-react-chat/src/routes/generations.image.tsx @@ -7,11 +7,19 @@ import { resolveMediaPrompt } from '@tanstack/ai' import { generateImageFn, generateImageStreamFn } from '../lib/server-fns' import { generationPersistence } from '../lib/generation-persistence' -// Every variant persists its lightweight resume snapshot (run identity, -// status, errors, result metadata — never image bytes). The client namespaces -// its record under `generation:` and reads it back on mount, repainting -// the hook's normal `status` / `result` / `error` fields so the last run's -// outcome survives a full page reload. +// This page shows BOTH persistence modes, each on the transport that supports +// it — the mode is not a separate tab, it follows from how the run is sent. +// +// - **Streaming** uses `persistence: true` (server-driven). The browser caches +// nothing; the server holds the run record AND the bytes, so a reload restores +// the image itself. Restore is a `GET` round-trip, which is why it needs a +// `connection` — see `StreamingImageGeneration`. +// - **Direct** / **Server Fn** go through TanStack server functions, which have +// no `GET` hydration path, so `persistence: true` cannot work there. They use +// the client adapter below: a lightweight resume snapshot (run identity, +// status, errors, result metadata — never image bytes) written to +// localStorage under `generation:` and read back on mount. Because +// the bytes are never cached, `result` returns without its image. const imagePersistence = generationPersistence function StreamingImageGeneration() { @@ -20,9 +28,14 @@ function StreamingImageGeneration() { const hookReturn = useGenerateImage({ id: 'image:streaming', + // Required by `persistence`, and the scope the server files the run under. threadId: 'image:streaming', connection: fetchServerSentEvents('/api/generate/image'), - persistence: imagePersistence, + // Server-driven: the browser caches nothing. On mount the hook issues a + // `GET /api/generate/image?threadId=…`, answered by `reconstructGeneration` + // from the `generationRuns` store. Only a `connection` can do this — the + // fetcher-based variants below have no GET path, so they use the adapter. + persistence: true, }) return ( diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index 89e8e6c5c..4d95834d9 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -46,8 +46,8 @@ interface ArtifactStore { save(record: ArtifactRecord): Promise // insert or overwrite get(artifactId: string): Promise list(runId: string): Promise> // [] when none - delete?(artifactId: string): Promise // OPTIONAL - deleteForRun?(runId: string): Promise // OPTIONAL + delete(artifactId: string): Promise + deleteForRun(runId: string): Promise } ``` diff --git a/packages/ai-persistence/src/reconstruct-generation.ts b/packages/ai-persistence/src/reconstruct-generation.ts index dd8abb838..b47c2d8de 100644 --- a/packages/ai-persistence/src/reconstruct-generation.ts +++ b/packages/ai-persistence/src/reconstruct-generation.ts @@ -170,20 +170,9 @@ export async function reconstructGeneration( } } - // `findLatestForThread` is optional on the store, so calling it through `?.` - // would turn "this adapter cannot do thread lookups" into an indistinguishable - // "no run found" — leaving `persistence: true` silently restoring nothing - // forever. Fail loudly instead, and only on the path that actually needs it - // (a `?runId=` lookup never does). - if (!runId && !runStore.findLatestForThread) { - throw new Error( - 'reconstructGeneration resolved a request by threadId, but stores.generationRuns does not implement findLatestForThread. Implement it, or have the client request a specific ?runId=.', - ) - } - const run = runId ? await runStore.get(runId) - : ((await runStore.findLatestForThread?.(threadId)) ?? null) + : await runStore.findLatestForThread(threadId) if (!run) { return jsonResponse({ resumeSnapshot: null, activeRun: null }) diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index baac6aa1c..dc5260264 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -243,14 +243,16 @@ export interface GenerationRunStore { /** Return the run record for `runId`, or `null` if none exists. */ get: (runId: string) => Promise /** - * The most recent run linked to `threadId`, or `null`. OPTIONAL — callers - * feature-detect it (`store.findLatestForThread?.(threadId)`). Lets a - * server-authoritative client hydrate the last generation for a thread by the - * stable thread id, without handling a run id. + * The most recent run linked to `threadId`, or `null`. + * + * REQUIRED, per the store-contract rule at the top of this file: a + * server-authoritative client hydrates by the stable thread id on every + * mount, so an adapter without this would be indistinguishable from one that + * legitimately has no run — `persistence: true` would silently restore + * nothing, forever. `null` is the correct answer only when the thread really + * has no runs. The chat parallel is {@link RunStore.findActiveRun}. */ - findLatestForThread?: ( - threadId: string, - ) => Promise + findLatestForThread: (threadId: string) => Promise } /** Lifecycle status of a human-in-the-loop interrupt. */ @@ -447,10 +449,20 @@ export interface ArtifactStore { get: (artifactId: string) => Promise /** All artifacts for a run. Returns `[]` when the run has none. */ list: (runId: string) => Promise> - /** OPTIONAL: delete a single artifact by id. */ - delete?: (artifactId: string) => Promise - /** OPTIONAL: delete every artifact belonging to `runId`. */ - deleteForRun?: (runId: string) => Promise + /** + * Delete a single artifact by id. A no-op if absent, mirroring + * {@link BlobStore.delete} — the two are written and deleted as a pair, so + * their contracts match. + */ + delete: (artifactId: string) => Promise + /** + * Delete every artifact belonging to `runId`. A no-op when the run has none. + * + * Required rather than feature-detected: retention and erasure are the point + * of storing media durably, and an adapter silently lacking deletion is + * indistinguishable from one where there was nothing to delete. + */ + deleteForRun: (runId: string) => Promise } /** diff --git a/packages/ai-persistence/tests/persistence-fixtures.ts b/packages/ai-persistence/tests/persistence-fixtures.ts index 8bba11375..a6cc63809 100644 --- a/packages/ai-persistence/tests/persistence-fixtures.ts +++ b/packages/ai-persistence/tests/persistence-fixtures.ts @@ -74,6 +74,12 @@ export function createGenerationRunStore(): GenerationRunStore { return Promise.resolve() }, get: (runId) => Promise.resolve(generationRuns.get(runId) ?? null), + findLatestForThread: (threadId) => { + const linked = [...generationRuns.values()] + .filter((run) => run.threadId === threadId) + .sort((a, b) => b.startedAt - a.startedAt) + return Promise.resolve(linked[0] ?? null) + }, } } diff --git a/packages/ai-persistence/tests/reconstruct-generation.test.ts b/packages/ai-persistence/tests/reconstruct-generation.test.ts index 48d9af07b..84f2f4070 100644 --- a/packages/ai-persistence/tests/reconstruct-generation.test.ts +++ b/packages/ai-persistence/tests/reconstruct-generation.test.ts @@ -7,22 +7,6 @@ async function body(response: Response): Promise { return (await response.json()) as ReconstructedGeneration } -/** - * A GenerationRunStore with only the three REQUIRED methods — no optional - * `findLatestForThread`. Built by hand rather than by spreading the memory - * store, whose methods live on the prototype and would be lost. - */ -function minimalRunStore( - runs: ReturnType['stores']['generationRuns'], -) { - return { - createOrResume: (input: Parameters[0]) => - runs.createOrResume(input), - update: (...args: Parameters) => runs.update(...args), - get: (runId: string) => runs.get(runId), - } -} - describe('reconstructGeneration', () => { it('maps a completed job to a resume snapshot', async () => { const persistence = memoryPersistence() @@ -151,53 +135,4 @@ describe('reconstructGeneration', () => { expect(response.status).toBe(403) expect(await response.json()).toEqual({ error: 'Forbidden' }) }) - - it('throws when a threadId lookup needs findLatestForThread and the store lacks it', async () => { - const base = memoryPersistence() - const persistence = { - ...base, - stores: { - ...base.stores, - generationRuns: minimalRunStore(base.stores.generationRuns), - }, - } - - // Without this guard the optional-call would yield `undefined ?? null`, i.e. - // an ordinary "no run found" — leaving `persistence: true` silently - // restoring nothing against an adapter that cannot support it. - await expect( - reconstructGeneration( - persistence, - new Request('http://example.test/api/generation?threadId=thread-1'), - ), - ).rejects.toThrow(/does not implement findLatestForThread/) - }) - - it('still resolves a runId lookup when the store lacks findLatestForThread', async () => { - const base = memoryPersistence() - await base.stores.generationRuns.createOrResume({ - runId: 'job-by-id', - threadId: 'thread-1', - activity: 'image', - provider: 'p', - model: 'm', - startedAt: 1, - }) - const persistence = { - ...base, - stores: { - ...base.stores, - generationRuns: minimalRunStore(base.stores.generationRuns), - }, - } - - // The method is only needed for the thread path — an explicit runId must - // keep working on a minimal adapter. - const response = await reconstructGeneration( - persistence, - new Request('http://example.test/api/generation?runId=job-by-id'), - ) - expect(response.status).toBe(200) - expect((await body(response)).resumeSnapshot).not.toBeNull() - }) }) From 08ab3f726993f9641b3e54dfd8b3e11f14639f64 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:23:06 +1000 Subject: [PATCH 40/66] fix(example): keep generation persistence across HMR re-evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/lib/generation-server-store.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/ts-react-chat/src/lib/generation-server-store.ts b/examples/ts-react-chat/src/lib/generation-server-store.ts index e028788c4..3c3d039ac 100644 --- a/examples/ts-react-chat/src/lib/generation-server-store.ts +++ b/examples/ts-react-chat/src/lib/generation-server-store.ts @@ -16,7 +16,18 @@ import { memoryPersistence } from '@tanstack/ai-persistence' * backend for production; `sqlite-persistence.ts` in this folder shows the * shape for the chat stores. */ -export const generationServerPersistence = memoryPersistence() +// Stashed on `globalThis` rather than held in a module binding: Vite re-evaluates +// this module on HMR, which would hand back a fresh (empty) set of Maps while the +// browser is still holding artifact URLs from the previous instance. Those URLs +// would then 404 and every generated image would break on the next file save. +// Reusing one instance across re-evaluations keeps a dev session coherent. +const HMR_KEY = Symbol.for('tanstack-ai-example/generation-server-persistence') +const globalStore = globalThis as typeof globalThis & { + [HMR_KEY]?: ReturnType +} + +export const generationServerPersistence = (globalStore[HMR_KEY] ??= + memoryPersistence()) /** Serve URL for a stored artifact — must match the GET route below it. */ export function artifactServeUrl(artifactId: string): string { From ec23ae31471f81c93239909055a46daae8f8353e Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:25:31 +1000 Subject: [PATCH 41/66] feat(persistence): sqlite generation stores + conformance coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/conformance-generation-stores.md | 11 + docs/persistence/build-your-own-adapter.md | 77 ++- .../src/lib/generation-server-store.ts | 45 +- .../src/lib/sqlite-persistence.test.ts | 5 +- .../src/lib/sqlite-persistence.ts | 534 +++++++++++++++++- .../src/routes/api.generate.image.ts | 8 +- .../build-cloudflare-adapter/SKILL.md | 11 +- .../build-cloudflare-artifact-store/SKILL.md | 65 ++- .../build-custom-adapter/SKILL.md | 12 +- .../build-drizzle-adapter/SKILL.md | 11 +- .../build-prisma-adapter/SKILL.md | 9 +- .../skills/ai-persistence/stores/SKILL.md | 8 +- .../ai-persistence/src/testkit/conformance.ts | 430 +++++++++++++- 13 files changed, 1116 insertions(+), 110 deletions(-) create mode 100644 .changeset/conformance-generation-stores.md diff --git a/.changeset/conformance-generation-stores.md b/.changeset/conformance-generation-stores.md new file mode 100644 index 000000000..7953e0b9b --- /dev/null +++ b/.changeset/conformance-generation-stores.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai-persistence': minor +--- + +Extend the shared conformance testkit to the generation stores. + +`runPersistenceConformance` now exercises `generationRuns`, `artifacts`, and `blobs` alongside the four chat state stores, so a hand-rolled generation backend is held to the same gate as a chat one: `createOrResume` idempotency and `findLatestForThread` (latest by `startedAt`, thread-scoped, terminal runs included) on the run store; upsert `save`, `list(runId)` ordering, and `delete` / `deleteForRun` scoping on the artifact store; and byte/metadata round-trips, overwrite, silent absent-key `delete`, and `list` prefix + cursor paging on the blob store. Two invariants that were easy to get wrong and are now checked: `list`'s `prefix` matches **literally and case-sensitively** (a SQL backend using `LIKE` fails on both counts, since SQLite's `LIKE` is case-insensitive for ASCII and treats `%` / `_` as wildcards), and cursor paging visits every key exactly once. + +Because the suite fails loudly on a store that is absent without being declared, an adapter that provides only the chat state stores now passes `skip: ['generationRuns', 'artifacts', 'blobs']`. + +`examples/ts-react-chat`'s self-contained `node:sqlite` adapter implements all seven stores and runs the full suite; its server-side generation route is backed by that adapter, so generated images survive a dev-server restart. diff --git a/docs/persistence/build-your-own-adapter.md b/docs/persistence/build-your-own-adapter.md index 5a14afd95..4de467d60 100644 --- a/docs/persistence/build-your-own-adapter.md +++ b/docs/persistence/build-your-own-adapter.md @@ -565,10 +565,11 @@ CREATE TABLE IF NOT EXISTS artifacts ( artifact_id text PRIMARY KEY NOT NULL, run_id text NOT NULL, thread_id text NOT NULL, + blob_key text, name text NOT NULL, mime_type text NOT NULL, size integer NOT NULL, - external_url text, + source_url text, created_at integer NOT NULL ); CREATE TABLE IF NOT EXISTS blobs ( @@ -733,6 +734,12 @@ function createGenerationRunStore(db: DatabaseSync) { none). `delete` / `deleteForRun` are required — retention and erasure are the point of storing media durably, and they mirror `BlobStore.delete`. +Persist `blobKey` verbatim. It records where these bytes actually went, and a +`storageKey` mapper can put them anywhere, so a reader cannot recompute the +path — `resolveArtifactBlobKey(record)` falls back to the default convention +only for rows written before the column existed. Drop it and every artifact +stored under a custom key becomes unreadable. + ```ts import { DatabaseSync } from 'node:sqlite' import { defineArtifactStore } from '@tanstack/ai-persistence' @@ -743,11 +750,12 @@ function mapArtifact(row: Record): ArtifactRecord { artifactId: String(row.artifact_id), runId: String(row.run_id), threadId: String(row.thread_id), + ...(typeof row.blob_key === 'string' ? { blobKey: row.blob_key } : {}), name: String(row.name), mimeType: String(row.mime_type), size: Number(row.size), - ...(typeof row.external_url === 'string' - ? { sourceUrl: row.external_url } + ...(typeof row.source_url === 'string' + ? { sourceUrl: row.source_url } : {}), createdAt: Number(row.created_at), } @@ -756,13 +764,13 @@ function mapArtifact(row: Record): ArtifactRecord { function createArtifactStore(db: DatabaseSync) { const upsert = db.prepare( `INSERT INTO artifacts - (artifact_id, run_id, thread_id, name, mime_type, size, external_url, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (artifact_id, run_id, thread_id, blob_key, name, mime_type, size, source_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(artifact_id) DO UPDATE SET run_id = excluded.run_id, thread_id = excluded.thread_id, - name = excluded.name, mime_type = excluded.mime_type, - size = excluded.size, external_url = excluded.external_url, - created_at = excluded.created_at`, + blob_key = excluded.blob_key, name = excluded.name, + mime_type = excluded.mime_type, size = excluded.size, + source_url = excluded.source_url, created_at = excluded.created_at`, ) const selectOne = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') const byRun = db.prepare( @@ -774,6 +782,7 @@ function createArtifactStore(db: DatabaseSync) { record.artifactId, record.runId, record.threadId, + record.blobKey ?? null, record.name, record.mimeType, record.size, @@ -939,14 +948,13 @@ function createBlobStore(db: DatabaseSync) { }, async list(options) { if (options?.limit === 0) return { objects: [], truncated: false } - // Escape LIKE metacharacters so `prefix` matches literally, then page with - // a keyset cursor (keys strictly greater than the last returned key). - const escaped = (options?.prefix ?? '').replace( - /[\\%_]/g, - (ch) => `\\${ch}`, - ) - const params: Array = [`${escaped}%`] - let where = "key LIKE ? ESCAPE '\\'" + // Match the prefix with `substr(...) = ?` rather than LIKE: SQLite's LIKE + // is case-INsensitive for ASCII and treats `%`/`_` as wildcards, while the + // contract says a prefix matches literally and case-sensitively. Then page + // with a keyset cursor (keys strictly greater than the last one returned). + const prefix = options?.prefix ?? '' + const params: Array = [prefix, prefix] + let where = 'substr(key, 1, length(?)) = ?' if (options?.cursor !== undefined) { where += ' AND key > ?' params.push(options.cursor) @@ -1067,8 +1075,20 @@ runPersistenceConformance('my sqlite adapter', () => ) ``` -The adapter above provides all four stores, so there is nothing to declare. A -partial adapter lists what it deliberately omits: +The suite covers all seven stores — the four chat state stores and the three +generation stores from the section above — so an adapter lists whatever it +deliberately omits. A chat-only adapter skips the generation half: + +```ts +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { chatOnlyPersistence } from './chat-only' + +runPersistenceConformance('chat-only adapter', () => chatOnlyPersistence(), { + skip: ['generationRuns', 'artifacts', 'blobs'], +}) +``` + +and a transcript-only one skips more: ```ts import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' @@ -1077,15 +1097,24 @@ import { transcriptOnlyPersistence } from './transcript-only' runPersistenceConformance( 'transcript-only adapter', () => transcriptOnlyPersistence(), - { skip: ['runs', 'interrupts', 'metadata'] }, + { + skip: [ + 'runs', + 'interrupts', + 'metadata', + 'generationRuns', + 'artifacts', + 'blobs', + ], + }, ) ``` -`skip` accepts only the four state store keys. A store that is absent and not -listed fails the suite loudly, so you cannot ship a half-wired adapter by -accident. When this is green, your adapter is a drop-in for `withPersistence`. -The `examples/ts-react-chat` app runs exactly this test against its SQLite -backend. +`skip` accepts only store keys. A store that is absent and not listed fails the +suite loudly, so you cannot ship a half-wired adapter by accident. When this is +green, your adapter is a drop-in for `withPersistence` (and, with the generation +stores, `withGenerationPersistence`). The `examples/ts-react-chat` app runs +exactly this test against its SQLite backend, which provides all seven. ## Let your coding agent write it diff --git a/examples/ts-react-chat/src/lib/generation-server-store.ts b/examples/ts-react-chat/src/lib/generation-server-store.ts index 3c3d039ac..6cabcd886 100644 --- a/examples/ts-react-chat/src/lib/generation-server-store.ts +++ b/examples/ts-react-chat/src/lib/generation-server-store.ts @@ -1,34 +1,35 @@ -import { memoryPersistence } from '@tanstack/ai-persistence' +import { sqlitePersistence } from './sqlite-persistence' + +let instance: ReturnType | undefined /** * Server-side generation persistence for the example app — the counterpart to * `generation-persistence.ts`, which is the client-driven half. * - * `memoryPersistence()` ships all three stores this needs: `generationRuns` - * (the run record `reconstructGeneration` reads back) plus `artifacts` + `blobs` - * (the generated bytes, so a restored `result` can actually render its image - * rather than coming back `null`). + * Backed by the same self-contained `node:sqlite` adapter the persistent-chat + * demo uses (`./sqlite-persistence`), in its own database file. It supplies the + * three stores generation needs: `generationRuns` (the run record + * `reconstructGeneration` reads back) plus `artifacts` + `blobs` (the generated + * bytes, so a restored `result` can actually render its image rather than + * coming back `null`). + * + * Durable on purpose. The POST that records a run and the GET that serves its + * bytes are separate requests, and an in-memory store would also lose every + * artifact whenever Vite re-evaluates this module on HMR — leaving artifact + * URLs already on screen to 404 mid-session. On disk, none of that applies: + * generated images survive an edit, a restart, and a second worker. `.data/` is + * gitignored. * - * Module-level on purpose: the POST that records a run and the GET that reads - * it back are separate requests, so they have to share one instance. In-memory - * means it resets when the dev server restarts — fine for a demo, and exactly - * what the docs recommend for development. Point the stores at a durable - * backend for production; `sqlite-persistence.ts` in this folder shows the - * shape for the chat stores. + * Lazily opened so importing this module never opens the database in a browser + * bundle. */ -// Stashed on `globalThis` rather than held in a module binding: Vite re-evaluates -// this module on HMR, which would hand back a fresh (empty) set of Maps while the -// browser is still holding artifact URLs from the previous instance. Those URLs -// would then 404 and every generated image would break on the next file save. -// Reusing one instance across re-evaluations keeps a dev session coherent. -const HMR_KEY = Symbol.for('tanstack-ai-example/generation-server-persistence') -const globalStore = globalThis as typeof globalThis & { - [HMR_KEY]?: ReturnType +export function generationServerPersistence() { + return (instance ??= sqlitePersistence({ + url: './.data/generation.db', + migrate: true, + })) } -export const generationServerPersistence = (globalStore[HMR_KEY] ??= - memoryPersistence()) - /** Serve URL for a stored artifact — must match the GET route below it. */ export function artifactServeUrl(artifactId: string): string { return `/api/generate/image?artifact=${encodeURIComponent(artifactId)}` diff --git a/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts b/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts index 3b37f2065..09bf551b4 100644 --- a/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts +++ b/examples/ts-react-chat/src/lib/sqlite-persistence.test.ts @@ -7,8 +7,9 @@ import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { sqlitePersistence } from './sqlite-persistence' -// All four state stores are provided, so nothing is skipped. (Locks are not a -// state store and the suite does not cover them — this backend has no +// All seven stores are provided — the four chat state stores plus +// `generationRuns` + `artifacts` + `blobs` — so nothing is skipped. (Locks are +// not a store and the suite does not cover them — this backend has no // distributed lock primitive, which is a separate `withLocks` concern.) runPersistenceConformance('ts-react-chat example (node:sqlite)', () => sqlitePersistence({ url: ':memory:', migrate: true }), diff --git a/examples/ts-react-chat/src/lib/sqlite-persistence.ts b/examples/ts-react-chat/src/lib/sqlite-persistence.ts index 74f1a1198..bc1d7a8a5 100644 --- a/examples/ts-react-chat/src/lib/sqlite-persistence.ts +++ b/examples/ts-react-chat/src/lib/sqlite-persistence.ts @@ -4,9 +4,11 @@ * `node:sqlite` driver. No ORM, no extra dependencies — this is the whole thing. * * It exists as a worked demonstration of "rolling your own" concrete persistence - * on the core: the four store interfaces (`MessageStore`, `RunStore`, - * `InterruptStore`, `MetadataStore`) are implemented here against raw SQL, and - * the result is a standard `AIPersistence` you hand to `withPersistence(...)`. + * on the core: the four chat state stores (`MessageStore`, `RunStore`, + * `InterruptStore`, `MetadataStore`) and the three generation stores + * (`GenerationRunStore`, `ArtifactStore`, `BlobStore`) are implemented here + * against raw SQL, and the result is a standard `AIPersistence` you hand to + * `withPersistence(...)` and `withGenerationPersistence(...)`. * * The store semantics mirror the reference in-memory backend shipped in * `@tanstack/ai-persistence` (`memory.ts`) exactly — the shared conformance @@ -22,17 +24,39 @@ import { fileURLToPath } from 'node:url' import { DatabaseSync } from 'node:sqlite' import { defineAIPersistence, + defineArtifactStore, + defineBlobStore, + defineGenerationRunStore, defineInterruptStore, defineMessageStore, defineMetadataStore, defineRunStore, } from '@tanstack/ai-persistence' -import type { ModelMessage, TokenUsage } from '@tanstack/ai' import type { - ChatPersistence, + ModelMessage, + PersistedArtifactRef, + TokenUsage, +} from '@tanstack/ai' +import type { + AIPersistence, + ArtifactRecord, + ArtifactStore, + BlobBody, + BlobListOptions, + BlobListPage, + BlobObject, + BlobRecord, + BlobStore, + GenerationRunRecord, + GenerationRunStatus, + GenerationRunStore, InterruptRecord, + InterruptStore, + MessageStore, + MetadataStore, RunRecord, RunStatus, + RunStore, } from '@tanstack/ai-persistence' // --------------------------------------------------------------------------- @@ -75,6 +99,47 @@ CREATE TABLE IF NOT EXISTS metadata ( value_json text NOT NULL, PRIMARY KEY (scope, key) ); +CREATE TABLE IF NOT EXISTS generation_runs ( + run_id text PRIMARY KEY NOT NULL, + thread_id text, + activity text NOT NULL, + provider text NOT NULL, + model text NOT NULL, + status text NOT NULL, + started_at integer NOT NULL, + finished_at integer, + error_json text, + result_json text, + artifacts_json text, + usage_json text +); +-- findLatestForThread orders by started_at within a thread. +CREATE INDEX IF NOT EXISTS generation_runs_thread_started + ON generation_runs (thread_id, started_at DESC); +CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id text PRIMARY KEY NOT NULL, + run_id text NOT NULL, + thread_id text NOT NULL, + blob_key text, + name text NOT NULL, + mime_type text NOT NULL, + size integer NOT NULL, + source_url text, + created_at integer NOT NULL +); +CREATE INDEX IF NOT EXISTS artifacts_run ON artifacts (run_id); +-- The bytes themselves. \`body\` is a BLOB column, so this file IS the object +-- store; a production adapter would keep metadata here and put bytes in S3/R2. +CREATE TABLE IF NOT EXISTS blobs ( + key text PRIMARY KEY NOT NULL, + body blob NOT NULL, + size integer NOT NULL, + etag text NOT NULL, + content_type text, + custom_metadata_json text, + created_at integer NOT NULL, + updated_at integer NOT NULL +); ` // Row shapes as SQLite hands them back (JSON columns are still text here). @@ -103,6 +168,41 @@ interface InterruptRow { interface MetadataRow { value_json: string } +interface GenerationRunRow { + run_id: string + thread_id: string | null + activity: string + provider: string + model: string + status: string + started_at: number + finished_at: number | null + error_json: string | null + result_json: string | null + artifacts_json: string | null + usage_json: string | null +} +interface ArtifactRow { + artifact_id: string + run_id: string + thread_id: string + blob_key: string | null + name: string + mime_type: string + size: number + source_url: string | null + created_at: number +} +interface BlobRow { + key: string + body: Uint8Array + size: number + etag: string + content_type: string | null + custom_metadata_json: string | null + created_at: number + updated_at: number +} function parseJson(text: string): T { return JSON.parse(text) as T @@ -365,6 +465,391 @@ function createMetadataStore(db: DatabaseSync) { }) } +// --------------------------------------------------------------------------- +// GenerationRunStore — the generation counterpart to RunStore. +// --------------------------------------------------------------------------- +// +// Keyed by its own `runId` (the id the generation activity mints), NOT by a +// conversation: a generation has no thread of its own, so `thread_id` is a +// nullable *link* back to the chat that triggered it. That asymmetry is the +// whole reason this is a separate table from `runs` rather than a status column +// on it. +function mapGenerationRun(row: GenerationRunRow): GenerationRunRecord { + return { + runId: row.run_id, + ...(row.thread_id != null ? { threadId: row.thread_id } : {}), + activity: row.activity, + provider: row.provider, + model: row.model, + status: row.status as GenerationRunStatus, + startedAt: row.started_at, + ...(row.finished_at != null ? { finishedAt: row.finished_at } : {}), + ...(row.error_json != null + ? { error: parseJson(row.error_json) } + : {}), + ...(row.result_json != null + ? { result: parseJson(row.result_json) } + : {}), + ...(row.artifacts_json != null + ? { + artifacts: parseJson>(row.artifacts_json), + } + : {}), + ...(row.usage_json != null + ? { usage: parseJson(row.usage_json) } + : {}), + } +} + +function createGenerationRunStore(db: DatabaseSync) { + const selectStmt = db.prepare( + 'SELECT * FROM generation_runs WHERE run_id = ?', + ) + const insertStmt = db.prepare( + `INSERT INTO generation_runs + (run_id, thread_id, activity, provider, model, status, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING`, + ) + const latestStmt = db.prepare( + `SELECT * FROM generation_runs WHERE thread_id = ? + ORDER BY started_at DESC LIMIT 1`, + ) + return defineGenerationRunStore({ + createOrResume(input) { + // INVARIANT (idempotency): same as the chat RunStore — a second call for + // a runId returns the stored record and mutates nothing. + const existing = selectStmt.get(input.runId) as + | GenerationRunRow + | undefined + if (existing) return Promise.resolve(mapGenerationRun(existing)) + const status: GenerationRunStatus = input.status ?? 'running' + insertStmt.run( + input.runId, + input.threadId ?? null, + input.activity, + input.provider, + input.model, + status, + input.startedAt, + ) + const created = selectStmt.get(input.runId) as + | GenerationRunRow + | undefined + return Promise.resolve( + created + ? mapGenerationRun(created) + : { + runId: input.runId, + activity: input.activity, + provider: input.provider, + model: input.model, + status, + startedAt: input.startedAt, + ...(input.threadId !== undefined + ? { threadId: input.threadId } + : {}), + }, + ) + }, + update(runId, patch) { + // Same dynamic-SET shape as the chat RunStore; `error`, `result`, + // `artifacts` and `usage` are JSON columns rather than scalars. + const sets: Array = [] + const params: Array = [] + if (patch.status !== undefined) { + sets.push('status = ?') + params.push(patch.status) + } + if (patch.finishedAt !== undefined) { + sets.push('finished_at = ?') + params.push(patch.finishedAt) + } + if (patch.error !== undefined) { + sets.push('error_json = ?') + params.push(JSON.stringify(patch.error)) + } + if (patch.result !== undefined) { + sets.push('result_json = ?') + params.push(JSON.stringify(patch.result)) + } + if (patch.artifacts !== undefined) { + sets.push('artifacts_json = ?') + params.push(JSON.stringify(patch.artifacts)) + } + if (patch.usage !== undefined) { + sets.push('usage_json = ?') + params.push(JSON.stringify(patch.usage)) + } + if (sets.length === 0) return Promise.resolve() + params.push(runId) + db.prepare( + `UPDATE generation_runs SET ${sets.join(', ')} WHERE run_id = ?`, + ).run(...params) + return Promise.resolve() + }, + get(runId) { + const row = selectStmt.get(runId) as GenerationRunRow | undefined + return Promise.resolve(row ? mapGenerationRun(row) : null) + }, + // The most recent run linked to a thread. `reconstructGeneration` calls + // this so a `persistence: true` client hydrates the last generation for its + // thread from the stable thread id alone, with no run id to hand. + findLatestForThread(threadId) { + const row = latestStmt.get(threadId) as GenerationRunRow | undefined + return Promise.resolve(row ? mapGenerationRun(row) : null) + }, + }) +} + +// --------------------------------------------------------------------------- +// ArtifactStore — one metadata row per generated file. +// --------------------------------------------------------------------------- +function mapArtifact(row: ArtifactRow): ArtifactRecord { + return { + artifactId: row.artifact_id, + runId: row.run_id, + threadId: row.thread_id, + ...(row.blob_key != null ? { blobKey: row.blob_key } : {}), + name: row.name, + mimeType: row.mime_type, + size: row.size, + ...(row.source_url != null ? { sourceUrl: row.source_url } : {}), + createdAt: row.created_at, + } +} + +function createArtifactStore(db: DatabaseSync) { + const upsertStmt = db.prepare( + `INSERT INTO artifacts + (artifact_id, run_id, thread_id, blob_key, name, mime_type, size, source_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(artifact_id) DO UPDATE SET + run_id = excluded.run_id, thread_id = excluded.thread_id, + blob_key = excluded.blob_key, name = excluded.name, + mime_type = excluded.mime_type, size = excluded.size, + source_url = excluded.source_url, created_at = excluded.created_at`, + ) + const selectStmt = db.prepare('SELECT * FROM artifacts WHERE artifact_id = ?') + const byRunStmt = db.prepare( + 'SELECT * FROM artifacts WHERE run_id = ? ORDER BY created_at ASC', + ) + const deleteStmt = db.prepare('DELETE FROM artifacts WHERE artifact_id = ?') + const deleteForRunStmt = db.prepare('DELETE FROM artifacts WHERE run_id = ?') + return defineArtifactStore({ + save(record) { + // Upsert, not insert-if-absent: unlike an interrupt, re-saving an + // artifact is a correction of its own metadata, never a state rollback. + upsertStmt.run( + record.artifactId, + record.runId, + record.threadId, + record.blobKey ?? null, + record.name, + record.mimeType, + record.size, + record.sourceUrl ?? null, + record.createdAt, + ) + return Promise.resolve() + }, + get(artifactId) { + const row = selectStmt.get(artifactId) as ArtifactRow | undefined + return Promise.resolve(row ? mapArtifact(row) : null) + }, + list(runId) { + const rows: Array = byRunStmt.all(runId) + return Promise.resolve((rows as Array).map(mapArtifact)) + }, + delete(artifactId) { + deleteStmt.run(artifactId) + return Promise.resolve() + }, + deleteForRun(runId) { + deleteForRunStmt.run(runId) + return Promise.resolve() + }, + }) +} + +// --------------------------------------------------------------------------- +// BlobStore — the bytes, in a BLOB column. +// --------------------------------------------------------------------------- +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder() + +async function bytesFromBlobBody(body: BlobBody): Promise { + if (typeof body === 'string') return textEncoder.encode(body) + if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)) + if (ArrayBuffer.isView(body)) { + return new Uint8Array( + body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + ) + } + if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer()) + + // ReadableStream: drain it into one buffer. + const reader = body.getReader() + const chunks: Array = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +function mapBlobRecord(row: BlobRow): BlobRecord { + return { + key: row.key, + size: row.size, + etag: row.etag, + ...(row.content_type != null ? { contentType: row.content_type } : {}), + ...(row.custom_metadata_json != null + ? { + customMetadata: parseJson>( + row.custom_metadata_json, + ), + } + : {}), + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function blobObject(record: BlobRecord, bytes: Uint8Array): BlobObject { + return { + ...record, + body: new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice()) + controller.close() + }, + }), + arrayBuffer() { + const copy = new ArrayBuffer(bytes.byteLength) + new Uint8Array(copy).set(bytes) + return Promise.resolve(copy) + }, + text: () => Promise.resolve(textDecoder.decode(bytes)), + } +} + +function createBlobStore(db: DatabaseSync) { + const upsertStmt = db.prepare( + `INSERT INTO blobs + (key, body, size, etag, content_type, custom_metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + body = excluded.body, size = excluded.size, etag = excluded.etag, + content_type = excluded.content_type, + custom_metadata_json = excluded.custom_metadata_json, + updated_at = excluded.updated_at`, + ) + const selectStmt = db.prepare('SELECT * FROM blobs WHERE key = ?') + const createdAtStmt = db.prepare('SELECT created_at FROM blobs WHERE key = ?') + const deleteStmt = db.prepare('DELETE FROM blobs WHERE key = ?') + // Prefix match via `substr(...) = ?` rather than LIKE: SQLite's LIKE is + // case-INsensitive for ASCII and treats `%`/`_` as wildcards, and the + // contract says a prefix matches literally and case-sensitively. Letting + // SQLite compute `length(?)` keeps the character count in SQLite's terms + // instead of JS UTF-16 code units. + const PREFIX_WHERE = 'substr(key, 1, length(?)) = ?' + // Etags only have to change when the bytes do; a per-put counter keeps them + // distinct even for two writes inside the same millisecond. + let putSequence = 0 + + return defineBlobStore({ + async put(key, body, options) { + const bytes = await bytesFromBlobBody(body) + const now = Date.now() + const prior = createdAtStmt.get(key) as { created_at: number } | undefined + // First write stamps createdAt; an overwrite keeps it and moves updatedAt. + const createdAt = prior?.created_at ?? now + const etag = `${now}-${putSequence++}` + const contentType = + options?.contentType ?? (body instanceof Blob ? body.type : '') + const record: BlobRecord = { + key, + size: bytes.byteLength, + etag, + ...(contentType ? { contentType } : {}), + ...(options?.customMetadata + ? { customMetadata: { ...options.customMetadata } } + : {}), + createdAt, + updatedAt: now, + } + upsertStmt.run( + key, + bytes, + record.size ?? bytes.byteLength, + etag, + record.contentType ?? null, + record.customMetadata ? JSON.stringify(record.customMetadata) : null, + createdAt, + now, + ) + return record + }, + get(key) { + const row = selectStmt.get(key) as BlobRow | undefined + return Promise.resolve( + row ? blobObject(mapBlobRecord(row), row.body) : null, + ) + }, + head(key) { + const row = selectStmt.get(key) as BlobRow | undefined + return Promise.resolve(row ? mapBlobRecord(row) : null) + }, + delete(key) { + deleteStmt.run(key) + return Promise.resolve() + }, + list(options?: BlobListOptions): Promise { + if (options?.limit === 0) { + return Promise.resolve({ objects: [], truncated: false }) + } + const prefix = options?.prefix ?? '' + const params: Array = [prefix, prefix] + let where = PREFIX_WHERE + if (options?.cursor !== undefined) { + // Keyset paging: strictly-following keys, in the same byte order as the + // sort, so a full scan visits every key exactly once. + where += ' AND key > ?' + params.push(options.cursor) + } + let sql = `SELECT * FROM blobs WHERE ${where} ORDER BY key ASC` + const limit = options?.limit + // One extra row detects truncation without a second COUNT query. + if (limit !== undefined) { + sql += ' LIMIT ?' + params.push(limit + 1) + } + const selected: Array = db.prepare(sql).all(...params) + const rows = (selected as Array).map(mapBlobRecord) + if (limit !== undefined && rows.length > limit) { + const objects = rows.slice(0, limit) + const cursor = objects.at(-1)?.key + return Promise.resolve({ + objects, + truncated: true, + ...(cursor !== undefined ? { cursor } : {}), + }) + } + return Promise.resolve({ objects: rows, truncated: false }) + }, + }) +} + export interface SqlitePersistenceOptions { /** `:memory:`, a filesystem path, or a `file:`-prefixed filesystem path. */ url: string @@ -373,20 +858,38 @@ export interface SqlitePersistenceOptions { } /** - * Build a `ChatPersistence` over a `node:sqlite` database. The returned object - * also exposes `close()` to release the file handle. + * Every store this backend provides, spelled out. * - * Provides all four state stores (`messages`, `runs`, `interrupts`, - * `metadata`). Cross-worker coordination is a separate seam — a `LockStore` - * wired with `withLocks`, not a fifth entry in `stores`. + * Parameterize `AIPersistence` rather than leaving it bare: the unparameterized + * type is the all-optional store bag, and `withPersistence` / + * `withGenerationPersistence` reject it because `stores.messages` / + * `stores.generationRuns` are possibly `undefined`. Spelling out all seven is + * what makes one backend serve both middlewares — there is no packaged named + * shape for "chat + generation" because the combination is a product choice. + */ +export type SqliteAIPersistence = AIPersistence<{ + messages: MessageStore + runs: RunStore + interrupts: InterruptStore + metadata: MetadataStore + generationRuns: GenerationRunStore + artifacts: ArtifactStore + blobs: BlobStore +}> + +/** + * Build an `AIPersistence` over a `node:sqlite` database. The returned object + * also exposes `close()` to release the file handle. * - * Annotate the return as `ChatPersistence`, not bare `AIPersistence`: the - * unparameterized type is the all-optional store bag, and `withPersistence` - * rejects it because `stores.messages` is possibly `undefined`. + * Provides all four state stores (`messages`, `runs`, `interrupts`, `metadata`) + * AND all three generation stores (`generationRuns`, `artifacts`, `blobs`), so + * the same instance backs `withPersistence` and `withGenerationPersistence`. + * Cross-worker coordination is a separate seam — a `LockStore` wired with + * `withLocks`, not an eighth entry in `stores`. */ export function sqlitePersistence( options: SqlitePersistenceOptions, -): ChatPersistence & { close: () => void } { +): SqliteAIPersistence & { close: () => void } { const filename = normalizeSqliteUrl(options.url) ensureParentDirectory(filename) const db = new DatabaseSync(filename) @@ -403,6 +906,9 @@ export function sqlitePersistence( runs: createRunStore(db), interrupts: createInterruptStore(db), metadata: createMetadataStore(db), + generationRuns: createGenerationRunStore(db), + artifacts: createArtifactStore(db), + blobs: createBlobStore(db), }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.image.ts b/examples/ts-react-chat/src/routes/api.generate.image.ts index 95219bebf..cbf2f2a4c 100644 --- a/examples/ts-react-chat/src/routes/api.generate.image.ts +++ b/examples/ts-react-chat/src/routes/api.generate.image.ts @@ -13,7 +13,7 @@ import { } from '@tanstack/ai-persistence' import { artifactServeUrl, - generationServerPersistence as persistence, + generationServerPersistence, } from '../lib/generation-server-store' /** @@ -26,6 +26,9 @@ import { * then stamps an app-origin serve URL onto every ref and rewrites the live * result to it — so both the live and the restored image render from here. * + * The stores are SQLite-backed, so a generated image is still there after a + * dev-server restart — reload the page and it renders from the database. + * * The GET does double duty, which is why it branches: * - `?artifact=` serves stored bytes (the URL `artifactUrl` produced). * - otherwise `reconstructGeneration` answers a `?threadId=` mount hydration, @@ -59,7 +62,7 @@ export const Route = createFileRoute('/api/generate/image')({ ...(runId ? { runId } : {}), stream: true, middleware: [ - withGenerationPersistence(persistence, { + withGenerationPersistence(generationServerPersistence(), { artifactUrl: (ref) => artifactServeUrl(ref.artifactId), }), ], @@ -69,6 +72,7 @@ export const Route = createFileRoute('/api/generate/image')({ }, GET: async ({ request }) => { + const persistence = generationServerPersistence() const artifactId = new URL(request.url).searchParams.get('artifact') if (artifactId) { diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md index d91f569ef..c6b5eafe7 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-adapter/SKILL.md @@ -236,12 +236,17 @@ import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { env } from 'cloudflare:test' import { chatPersistence } from '../src/lib/chat-persistence' -runPersistenceConformance('app-d1', () => chatPersistence(env.AI_STATE)) +runPersistenceConformance('app-d1', () => chatPersistence(env.AI_STATE), { + skip: ['generationRuns', 'artifacts', 'blobs'], +}) ``` Run it against a Miniflare D1 binding with the migration applied, reset between -runs. All four state stores are provided, so pass no `skip` — and `skip` never -accepts `'locks'`: the suite covers state only. +runs. All four state stores are provided; the suite also covers the three +generation stores, so a chat-only adapter declares them skipped (drop the `skip` +once you add the R2-backed set from +**ai-persistence/build-cloudflare-artifact-store**). `skip` never accepts +`'locks'`, which is not a store. The lock store needs its **own** tests, because nothing in the conformance suite touches it. Cover at minimum: two concurrent `withLock` calls on the same key diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index 4d95834d9..37d8e28e5 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -164,10 +164,11 @@ CREATE TABLE IF NOT EXISTS generation_artifacts ( artifact_id text PRIMARY KEY NOT NULL, run_id text NOT NULL, thread_id text NOT NULL, + blob_key text, name text NOT NULL, mime_type text NOT NULL, size integer NOT NULL, - external_url text, + source_url text, created_at integer NOT NULL ); CREATE INDEX IF NOT EXISTS generation_artifacts_run ON generation_artifacts (run_id); @@ -181,10 +182,11 @@ interface ArtifactRow { artifact_id: string run_id: string thread_id: string + blob_key: string | null name: string mime_type: string size: number - external_url: string | null + source_url: string | null created_at: number } @@ -193,10 +195,11 @@ function fromRow(row: ArtifactRow): ArtifactRecord { artifactId: row.artifact_id, runId: row.run_id, threadId: row.thread_id, + ...(row.blob_key != null ? { blobKey: row.blob_key } : {}), name: row.name, mimeType: row.mime_type, size: row.size, - ...(row.external_url != null ? { sourceUrl: row.external_url } : {}), + ...(row.source_url != null ? { sourceUrl: row.source_url } : {}), createdAt: row.created_at, } } @@ -208,18 +211,19 @@ export function d1ArtifactStore(db: D1Database) { await db .prepare( `INSERT INTO generation_artifacts - (artifact_id, run_id, thread_id, name, mime_type, size, external_url, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (artifact_id, run_id, thread_id, blob_key, name, mime_type, size, source_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(artifact_id) DO UPDATE SET run_id = excluded.run_id, thread_id = excluded.thread_id, - name = excluded.name, mime_type = excluded.mime_type, - size = excluded.size, external_url = excluded.external_url, - created_at = excluded.created_at`, + blob_key = excluded.blob_key, name = excluded.name, + mime_type = excluded.mime_type, size = excluded.size, + source_url = excluded.source_url, created_at = excluded.created_at`, ) .bind( record.artifactId, record.runId, record.threadId, + record.blobKey ?? null, record.name, record.mimeType, record.size, @@ -262,8 +266,12 @@ export function d1ArtifactStore(db: D1Database) { } ``` -Omitting `external_url` from the record when the column is `NULL` keeps records -comparing cleanly against the reference in-memory store. **KV alternative:** if +Omitting `source_url` / `blob_key` from the record when the column is `NULL` +keeps records comparing cleanly against the reference in-memory store. Persist +`blob_key` verbatim: a `storageKey` mapper can put the bytes anywhere, so a +reader cannot recompute the path — `resolveArtifactBlobKey(record)` falls back +to the default convention only for rows written before the column existed. +**KV alternative:** if you have no D1, back `save`/`get` with `KV.put(artifactId, JSON.stringify(record))` / `KV.get(artifactId, 'json')`, and maintain a `run:` index key (a JSON array of artifact ids) for `list` — KV has no query, so `list` needs that @@ -388,22 +396,37 @@ For each: `contentType` and `customMetadata` ride the SDK's own metadata fields; ## Verify -The shared `runPersistenceConformance` testkit currently covers the four **state** -stores only (`messages`, `runs`, `interrupts`, `metadata`) — it does **not** -exercise `blobs`, `artifacts`, or `generationRuns`. So the byte stores need their **own** -tests. Run them against a Miniflare R2 + D1 binding with the migration applied, -reset between runs (see **ai-persistence/build-cloudflare-adapter** for the -`cloudflare:test` harness pattern). Assert against the reference in-memory stores -from `memoryPersistence()` as the oracle, covering at minimum: +The shared `runPersistenceConformance` testkit covers all seven stores, including +`generationRuns`, `artifacts`, and `blobs` — point it at your factory rather than +hand-writing these assertions: + +```ts ignore +import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' +import { env } from 'cloudflare:test' +import { generationPersistence } from '../src/lib/generation-persistence' + +// A generation-only Worker declares the chat state stores it does not provide. +runPersistenceConformance('app-r2', () => generationPersistence(env), { + skip: ['messages', 'runs', 'interrupts', 'metadata'], +}) +``` + +Run it against a Miniflare R2 + D1 binding with the migration applied, reset +between runs (see **ai-persistence/build-cloudflare-adapter** for the +`cloudflare:test` harness pattern). It exercises, among the rest: - `put` then `get` round-trips bytes and metadata; `get`/`head` return `null` for a missing key; `delete` is a silent no-op on an absent key. - `put` overwrites an existing key (and its `contentType`/`customMetadata`). -- `list` filters by `prefix`, returns ascending keys, pages correctly through the - `cursor` when `truncated`, and returns an empty untruncated page for `limit: 0`. +- `list` filters by `prefix` **literally and case-sensitively**, returns + ascending keys, pages through the `cursor` when `truncated` without gaps or + repeats, and returns an empty untruncated page for `limit: 0`. - The `ArtifactStore`: `save` is insert-or-overwrite, `get` returns `null` when - absent, `list(runId)` returns `[]` for an unknown run, and (if implemented) - `deleteForRun` removes exactly that run's rows. + absent, `list(runId)` returns `[]` for an unknown run, and `delete` / + `deleteForRun` remove exactly the expected rows. +- The `GenerationRunStore`: `createOrResume` idempotency, no-op `update` on an + unknown id, and `findLatestForThread` returning the most recently started + linked run (terminal ones included). An end-to-end check is the strongest signal: run `generateImage` through `withGenerationPersistence(generationPersistence(env))`, then confirm the blob diff --git a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md index 74e17bd13..9a44cd304 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-custom-adapter/SKILL.md @@ -267,10 +267,12 @@ idempotency bug and stuck approvals in production. import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' -runPersistenceConformance('app-custom', () => chatPersistence) +runPersistenceConformance('app-custom', () => chatPersistence, { + skip: ['generationRuns', 'artifacts', 'blobs'], +}) ``` -Point it at a throwaway database and reset between runs. Declare intentional -omissions with `skip: ['metadata']` — it accepts only -`'messages' | 'runs' | 'interrupts' | 'metadata'`, never `'locks'`, which is not -a state store. +Point it at a throwaway database and reset between runs. The suite covers all +seven stores, so declare every intentional omission — a chat adapter skips the +generation half above, and adds e.g. `'metadata'` if it drops that too. `skip` +never accepts `'locks'`, which is not a store. diff --git a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md index 8f5382984..b22735ef2 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-drizzle-adapter/SKILL.md @@ -433,13 +433,16 @@ route** — derive the user from the session, never trust a client-supplied id. import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' -runPersistenceConformance('app-drizzle', () => chatPersistence) +runPersistenceConformance('app-drizzle', () => chatPersistence, { + skip: ['generationRuns', 'artifacts', 'blobs'], +}) ``` Point it at a throwaway database (`:memory:` SQLite, a scratch schema, PGlite) -that has the migration applied, and reset between runs. Every store is -provided, so there is nothing to `skip` — and `skip` never accepts `'locks'`, -which is not a state store. +that has the migration applied, and reset between runs. The suite covers all +seven stores, so a chat adapter declares the generation half it omits; drop the +`skip` once you add those tables. `skip` never accepts `'locks'`, which is not a +store. ## Only if you are publishing this as a package diff --git a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md index b65450c22..a0ed3ce5e 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-prisma-adapter/SKILL.md @@ -406,13 +406,16 @@ route** — derive the user from the session, never trust a client-supplied id. import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit' import { chatPersistence } from '../src/lib/chat-persistence' -runPersistenceConformance('app-prisma', () => chatPersistence) +runPersistenceConformance('app-prisma', () => chatPersistence, { + skip: ['generationRuns', 'artifacts', 'blobs'], +}) ``` Point the client at a throwaway database with the migration applied (a scratch SQLite file is enough) and reset it between runs. All four state stores are -provided, so pass no `skip` — and `skip` never accepts `'locks'`, which is not -a state store. +provided; the suite also covers the three generation stores, so declare those as +skipped until you add them. `skip` never accepts `'locks'`, which is not a +store. ## Only if you are publishing this as a package diff --git a/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md index dc9e0b13d..ad964c510 100644 --- a/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/stores/SKILL.md @@ -235,10 +235,12 @@ import { myPersistence } from '../src/persistence' runPersistenceConformance('my-backend', () => myPersistence()) -// Declare intentional omissions — only the four state stores are valid keys: -// runPersistenceConformance('msgs-only', () => p, { -// skip: ['runs', 'interrupts', 'metadata'], +// Declare intentional omissions. The suite covers all seven stores, so a +// chat-only backend skips the generation half: +// runPersistenceConformance('chat-only', () => p, { +// skip: ['generationRuns', 'artifacts', 'blobs'], // }) +// `skip` never accepts 'locks' — locks are not a store. ``` The testkit is the compatibility gate: round-trips, rich message shapes, diff --git a/packages/ai-persistence/src/testkit/conformance.ts b/packages/ai-persistence/src/testkit/conformance.ts index 25198958a..9acad1c03 100644 --- a/packages/ai-persistence/src/testkit/conformance.ts +++ b/packages/ai-persistence/src/testkit/conformance.ts @@ -1,5 +1,5 @@ /** - * Shared conformance suite for the `AIPersistence` **state** contract. + * Shared conformance suite for the `AIPersistence` store contract. * * Every backend runs this identical suite — the in-memory reference store and * every adapter you write against your own database — so that schema drift or @@ -7,19 +7,42 @@ * store the persistence exposes and is the authoritative compatibility gate for * the store interfaces in `../types.ts`. * - * Locks are not part of this suite — they are a separate coordination concern - * (`LockStore` + `withLocks`), not state stores. + * Covers all seven stores: the four chat state stores (`messages`, `runs`, + * `interrupts`, `metadata`) and the three generation stores (`generationRuns`, + * `artifacts`, `blobs`). Locks are not part of this suite — they are a separate + * coordination concern (`LockStore` + `withLocks`), not a store. * - * SKIPPING: a backend that deliberately omits a state store must declare it in + * SKIPPING: a backend that deliberately omits a store must declare it in * `options.skip`. A store that is absent AND not listed in `skip` fails the - * suite loudly — silent gaps are not allowed. + * suite loudly — silent gaps are not allowed. A chat-only adapter therefore + * passes `skip: ['generationRuns', 'artifacts', 'blobs']`, and a + * generation-only one skips the four state stores. */ import { beforeAll, describe, expect, it } from 'vitest' import type { ModelMessage } from '@tanstack/ai' -import type { AIPersistence, AIPersistenceStores } from '../types' +import type { + AIPersistence, + AIPersistenceStores, + ArtifactRecord, +} from '../types' type MakePersistence = () => Promise | AIPersistence +/** + * Unwrap a value the store contract says must be present. Fails the test with a + * readable message instead of a non-null assertion (banned in this package) or + * an early `return` that would pass silently. + */ +function required( + value: TValue | null | undefined, + what: string, +): TValue { + if (value == null) { + throw new Error(`AIPersistence conformance: expected ${what} to exist`) + } + return value +} + export interface PersistenceConformanceOptions { /** * Store keys this backend intentionally does not provide. Any store that is @@ -31,7 +54,8 @@ export interface PersistenceConformanceOptions { /** * Register a Vitest suite that validates `makePersistence()` against the full - * `AIPersistence` contract. + * `AIPersistence` contract — every store it provides, and none it declares + * skipped. */ export function runPersistenceConformance( name: string, @@ -398,6 +422,398 @@ export function runPersistenceConformance( }) }) + describe('generationRuns', () => { + it('creates, resumes idempotently, updates, and gets', async () => { + const store = resolveStore('generationRuns') + if (!store) return + + expect(await store.get('gen-missing')).toBeNull() + + const created = await store.createOrResume({ + runId: 'gen-1', + activity: 'image', + provider: 'openai', + model: 'gpt-image-1', + startedAt: 1000, + }) + expect(created).toMatchObject({ + runId: 'gen-1', + activity: 'image', + provider: 'openai', + model: 'gpt-image-1', + status: 'running', + startedAt: 1000, + }) + // A generation has no conversation of its own: threadId is an optional + // link, absent (not null/empty) when the caller did not supply one. + expect(created.threadId).toBeUndefined() + + // Idempotent: the stored record comes back untouched by the new input. + const resumed = await store.createOrResume({ + runId: 'gen-1', + activity: 'video', + provider: 'google', + model: 'veo-3', + startedAt: 9999, + threadId: 'thread-late', + }) + expect(resumed).toMatchObject({ + runId: 'gen-1', + activity: 'image', + provider: 'openai', + model: 'gpt-image-1', + startedAt: 1000, + }) + expect(resumed.threadId).toBeUndefined() + + await store.update('gen-1', { + status: 'complete', + finishedAt: 2000, + result: { images: [{ url: 'https://example.com/a.png' }] }, + artifacts: [ + { + role: 'output', + artifactId: 'art-1', + runId: 'gen-1', + threadId: 'thread-gen', + name: 'a.png', + mimeType: 'image/png', + size: 3, + createdAt: '2024-01-01T00:00:00.000Z', + source: { + activity: 'image', + path: 'images.0', + provider: 'openai', + model: 'gpt-image-1', + }, + }, + ], + usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, + }) + const done = await store.get('gen-1') + expect(done).toMatchObject({ + status: 'complete', + finishedAt: 2000, + result: { images: [{ url: 'https://example.com/a.png' }] }, + usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, + }) + expect(done?.artifacts).toHaveLength(1) + expect(done?.artifacts?.[0]).toMatchObject({ + artifactId: 'art-1', + mimeType: 'image/png', + }) + + await store.update('gen-1', { + status: 'error', + error: { message: 'boom', code: 'provider_error' }, + }) + const failed = await store.get('gen-1') + expect(failed?.status).toBe('error') + expect(failed?.error).toEqual({ + message: 'boom', + code: 'provider_error', + }) + + // Patching a missing run is a no-op (does not throw, does not create). + await store.update('gen-absent', { status: 'complete' }) + expect(await store.get('gen-absent')).toBeNull() + }) + + // `findLatestForThread` is REQUIRED: `reconstructGeneration` hydrates a + // server-driven client from the stable thread id alone, so a backend that + // always answers `null` silently restores nothing rather than degrading. + it('findLatestForThread returns the most recently started linked run', async () => { + const store = resolveStore('generationRuns') + if (!store) return + + const thread = 'thread-gen-latest' + expect(await store.findLatestForThread(thread)).toBeNull() + + // Insert out of order so insertion order cannot stand in for startedAt. + await store.createOrResume({ + runId: 'gen-late', + activity: 'image', + provider: 'openai', + model: 'gpt-image-1', + startedAt: 3000, + threadId: thread, + }) + await store.createOrResume({ + runId: 'gen-early', + activity: 'image', + provider: 'openai', + model: 'gpt-image-1', + startedAt: 1000, + threadId: thread, + }) + expect(await store.findLatestForThread(thread)).toMatchObject({ + runId: 'gen-late', + }) + + // Another thread's run is never returned, and unlike findActiveRun a + // TERMINAL run still counts — this is "the latest", not "the active". + await store.createOrResume({ + runId: 'gen-other', + activity: 'image', + provider: 'openai', + model: 'gpt-image-1', + startedAt: 9000, + threadId: 'thread-gen-other', + }) + await store.update('gen-late', { status: 'complete', finishedAt: 3500 }) + expect(await store.findLatestForThread(thread)).toMatchObject({ + runId: 'gen-late', + status: 'complete', + }) + + // A run with no thread link is not attributed to any thread. + expect(await store.findLatestForThread('thread-unlinked')).toBeNull() + }) + }) + + describe('artifacts', () => { + const artifact = ( + overrides: Partial & Pick, + ): ArtifactRecord => ({ + runId: 'run-art', + threadId: 'thread-art', + name: 'image.png', + mimeType: 'image/png', + size: 3, + createdAt: 100, + ...overrides, + }) + + it('saves as an upsert, gets, and lists by run', async () => { + const store = resolveStore('artifacts') + if (!store) return + + expect(await store.get('art-missing')).toBeNull() + expect(await store.list('run-unknown')).toEqual([]) + + await store.save( + artifact({ + artifactId: 'art-a', + blobKey: 'artifacts/run-art/art-a', + createdAt: 100, + }), + ) + await store.save( + artifact({ + artifactId: 'art-b', + sourceUrl: 'https://provider.example/expiring.png', + createdAt: 200, + }), + ) + await store.save( + artifact({ artifactId: 'art-c', runId: 'run-art-other' }), + ) + + expect(await store.get('art-a')).toMatchObject({ + artifactId: 'art-a', + runId: 'run-art', + threadId: 'thread-art', + blobKey: 'artifacts/run-art/art-a', + name: 'image.png', + mimeType: 'image/png', + size: 3, + createdAt: 100, + }) + expect(await store.get('art-b')).toMatchObject({ + sourceUrl: 'https://provider.example/expiring.png', + }) + + expect((await store.list('run-art')).map((r) => r.artifactId)).toEqual([ + 'art-a', + 'art-b', + ]) + + // save() is insert-OR-OVERWRITE: re-saving an id corrects the record. + await store.save( + artifact({ artifactId: 'art-a', name: 'renamed.png', size: 9 }), + ) + const updated = await store.get('art-a') + expect(updated).toMatchObject({ name: 'renamed.png', size: 9 }) + expect(updated?.blobKey).toBeUndefined() + expect((await store.list('run-art')).map((r) => r.artifactId)).toEqual([ + 'art-a', + 'art-b', + ]) + }) + + it('deletes one artifact and every artifact for a run', async () => { + const store = resolveStore('artifacts') + if (!store) return + + await store.save(artifact({ artifactId: 'art-d1', runId: 'run-del' })) + await store.save(artifact({ artifactId: 'art-d2', runId: 'run-del' })) + await store.save( + artifact({ artifactId: 'art-keep', runId: 'run-keep' }), + ) + + await store.delete('art-d1') + expect(await store.get('art-d1')).toBeNull() + expect((await store.list('run-del')).map((r) => r.artifactId)).toEqual([ + 'art-d2', + ]) + + // Deleting an absent id is a silent no-op, mirroring BlobStore.delete. + await store.delete('art-d1') + + await store.deleteForRun('run-del') + expect(await store.list('run-del')).toEqual([]) + expect(await store.get('art-d2')).toBeNull() + // Scoped to the run: another run's artifacts survive. + expect((await store.list('run-keep')).map((r) => r.artifactId)).toEqual( + ['art-keep'], + ) + // deleteForRun on a run with no artifacts is a no-op. + await store.deleteForRun('run-del') + }) + }) + + describe('blobs', () => { + it('round-trips bytes and metadata through put/get/head', async () => { + const store = resolveStore('blobs') + if (!store) return + + expect(await store.get('blob-missing')).toBeNull() + expect(await store.head('blob-missing')).toBeNull() + + const bytes = new Uint8Array([1, 2, 3, 4]) + const put = await store.put('blob/a', bytes, { + contentType: 'image/png', + customMetadata: { runId: 'run-blob' }, + }) + expect(put).toMatchObject({ + key: 'blob/a', + size: 4, + contentType: 'image/png', + customMetadata: { runId: 'run-blob' }, + }) + + const object = required(await store.get('blob/a'), 'blob/a') + expect(new Uint8Array(await object.arrayBuffer())).toEqual(bytes) + expect(object).toMatchObject({ + key: 'blob/a', + size: 4, + contentType: 'image/png', + customMetadata: { runId: 'run-blob' }, + }) + + const head = await store.head('blob/a') + expect(head).toMatchObject({ key: 'blob/a', size: 4 }) + + // A string body encodes as UTF-8 and reads back through text(). + await store.put('blob/text', 'héllo') + const text = required(await store.get('blob/text'), 'blob/text') + expect(await text.text()).toBe('héllo') + + // An ArrayBuffer body is accepted too. + await store.put('blob/buffer', new Uint8Array([9, 9]).buffer) + const buffered = required(await store.get('blob/buffer'), 'blob/buffer') + expect(new Uint8Array(await buffered.arrayBuffer())).toEqual( + new Uint8Array([9, 9]), + ) + }) + + it('overwrites an existing key and deletes silently', async () => { + const store = resolveStore('blobs') + if (!store) return + + const first = await store.put('blob/over', new Uint8Array([1]), { + contentType: 'text/plain', + customMetadata: { v: '1' }, + }) + const second = await store.put('blob/over', new Uint8Array([2, 2, 2]), { + contentType: 'application/octet-stream', + customMetadata: { v: '2' }, + }) + + expect(second).toMatchObject({ + size: 3, + contentType: 'application/octet-stream', + customMetadata: { v: '2' }, + }) + // When a backend exposes etags at all, new bytes get a new one. + if (first.etag !== undefined && second.etag !== undefined) { + expect(second.etag).not.toBe(first.etag) + } + const after = required(await store.get('blob/over'), 'blob/over') + expect(new Uint8Array(await after.arrayBuffer())).toEqual( + new Uint8Array([2, 2, 2]), + ) + + await store.delete('blob/over') + expect(await store.get('blob/over')).toBeNull() + expect(await store.head('blob/over')).toBeNull() + // Deleting an absent key is a no-op, not an error. + await store.delete('blob/over') + }) + + it('lists by literal prefix in ascending key order', async () => { + const store = resolveStore('blobs') + if (!store) return + + // `_` and `%` are LIKE metacharacters: a SQL backend that forgets to + // escape them would match `list-x/…` here. And SQLite's LIKE is + // case-insensitive for ASCII, so `LIST_/` must not match either. + await store.put('list_/b', new Uint8Array([2])) + await store.put('list_/a', new Uint8Array([1])) + await store.put('list_/c', new Uint8Array([3])) + await store.put('list-x/d', new Uint8Array([4])) + await store.put('LIST_/e', new Uint8Array([5])) + + const page = await store.list({ prefix: 'list_/' }) + expect(page.objects.map((o) => o.key)).toEqual([ + 'list_/a', + 'list_/b', + 'list_/c', + ]) + expect(page.truncated).toBeFalsy() + + expect( + (await store.list({ prefix: 'list_/nothing-here' })).objects, + ).toEqual([]) + }) + + it('pages with a cursor and returns an empty page for limit 0', async () => { + const store = resolveStore('blobs') + if (!store) return + + for (const key of ['page/a', 'page/b', 'page/c', 'page/d', 'page/e']) { + await store.put(key, new Uint8Array([1])) + } + + const empty = await store.list({ prefix: 'page/', limit: 0 }) + expect(empty.objects).toEqual([]) + expect(empty.truncated).toBeFalsy() + expect(empty.cursor).toBeUndefined() + + // Walk every key exactly once: each page's cursor resumes strictly + // after the last key it returned. + const seen: Array = [] + let cursor: string | undefined + for (let guard = 0; guard < 10; guard++) { + const result = await store.list({ + prefix: 'page/', + limit: 2, + ...(cursor !== undefined ? { cursor } : {}), + }) + seen.push(...result.objects.map((o) => o.key)) + if (!result.truncated) break + expect(result.cursor).toBe(result.objects.at(-1)?.key) + cursor = result.cursor + } + expect(seen).toEqual(['page/a', 'page/b', 'page/c', 'page/d', 'page/e']) + + // An exact-fit limit is not truncated: no key matches beyond the page. + const exact = await store.list({ prefix: 'page/', limit: 5 }) + expect(exact.objects.map((o) => o.key)).toEqual(seen) + expect(exact.truncated).toBeFalsy() + }) + }) + describe('metadata', () => { it('sets, gets, namespaces, and deletes without composite-key collisions', async () => { const store = resolveStore('metadata') From a94ef8c376d9c67cbe515a94fe91111c9595790a Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:37:20 +1000 Subject: [PATCH 42/66] feat(example): server-side generation persistence on every activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/lib/generation-server-store.ts | 9 +++- examples/ts-react-chat/src/routeTree.gen.ts | 21 ++++++++ .../ts-react-chat/src/routes/api.artifacts.ts | 52 +++++++++++++++++++ .../src/routes/api.generate.audio.ts | 35 ++++++++++++- .../src/routes/api.generate.image.ts | 42 +++------------ .../src/routes/api.generate.speech.ts | 35 ++++++++++++- .../src/routes/api.generate.video.ts | 34 +++++++++++- .../src/routes/api.transcribe.ts | 38 +++++++++++++- 8 files changed, 226 insertions(+), 40 deletions(-) create mode 100644 examples/ts-react-chat/src/routes/api.artifacts.ts diff --git a/examples/ts-react-chat/src/lib/generation-server-store.ts b/examples/ts-react-chat/src/lib/generation-server-store.ts index 6cabcd886..d0db70882 100644 --- a/examples/ts-react-chat/src/lib/generation-server-store.ts +++ b/examples/ts-react-chat/src/lib/generation-server-store.ts @@ -30,7 +30,12 @@ export function generationServerPersistence() { })) } -/** Serve URL for a stored artifact — must match the GET route below it. */ +/** + * Serve URL for a stored artifact — must match `routes/api.artifacts.ts`. + * + * One route serves every activity's media, so each generation route passes this + * as `artifactUrl` and nothing else has to know how bytes are addressed. + */ export function artifactServeUrl(artifactId: string): string { - return `/api/generate/image?artifact=${encodeURIComponent(artifactId)}` + return `/api/artifacts?id=${encodeURIComponent(artifactId)}` } diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 775782871..b84954eff 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -55,6 +55,7 @@ import { Route as ApiInterruptsRouteImport } from './routes/api.interrupts' import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' +import { Route as ApiArtifactsRouteImport } from './routes/api.artifacts' import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index' import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId' import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.video' @@ -295,6 +296,11 @@ const ApiCapabilityDemoRoute = ApiCapabilityDemoRouteImport.update({ path: '/api/capability-demo', getParentRoute: () => rootRouteImport, } as any) +const ApiArtifactsRoute = ApiArtifactsRouteImport.update({ + id: '/api/artifacts', + path: '/api/artifacts', + getParentRoute: () => rootRouteImport, +} as any) const ExampleGuitarsIndexRoute = ExampleGuitarsIndexRouteImport.update({ id: '/example/guitars/', path: '/example/guitars/', @@ -344,6 +350,7 @@ export interface FileRoutesByFullPath { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -398,6 +405,7 @@ export interface FileRoutesByTo { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -453,6 +461,7 @@ export interface FileRoutesById { '/server-fn-chat': typeof ServerFnChatRoute '/threads': typeof ThreadsRoute '/typesafe-tools': typeof TypesafeToolsRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute @@ -509,6 +518,7 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -563,6 +573,7 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -617,6 +628,7 @@ export interface FileRouteTypes { | '/server-fn-chat' | '/threads' | '/typesafe-tools' + | '/api/artifacts' | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' @@ -672,6 +684,7 @@ export interface RootRouteChildren { ServerFnChatRoute: typeof ServerFnChatRoute ThreadsRoute: typeof ThreadsRoute TypesafeToolsRoute: typeof TypesafeToolsRoute + ApiArtifactsRoute: typeof ApiArtifactsRoute ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute @@ -1033,6 +1046,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiCapabilityDemoRouteImport parentRoute: typeof rootRouteImport } + '/api/artifacts': { + id: '/api/artifacts' + path: '/api/artifacts' + fullPath: '/api/artifacts' + preLoaderRoute: typeof ApiArtifactsRouteImport + parentRoute: typeof rootRouteImport + } '/example/guitars/': { id: '/example/guitars/' path: '/example/guitars' @@ -1096,6 +1116,7 @@ const rootRouteChildren: RootRouteChildren = { ServerFnChatRoute: ServerFnChatRoute, ThreadsRoute: ThreadsRoute, TypesafeToolsRoute: TypesafeToolsRoute, + ApiArtifactsRoute: ApiArtifactsRoute, ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, diff --git a/examples/ts-react-chat/src/routes/api.artifacts.ts b/examples/ts-react-chat/src/routes/api.artifacts.ts new file mode 100644 index 000000000..b6770c2be --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.artifacts.ts @@ -0,0 +1,52 @@ +import { createFileRoute } from '@tanstack/react-router' +import { retrieveArtifact, retrieveBlob } from '@tanstack/ai-persistence' +import { generationServerPersistence } from '../lib/generation-server-store' + +/** + * The one route that serves persisted generation media, for every activity. + * + * `withGenerationPersistence` stores each generated file's bytes in the blob + * store and stamps an app-origin URL onto every artifact ref via `artifactUrl` + * — that URL points here. Because artifacts are addressed by their own id and + * carry their own `mimeType`, nothing about serving them is activity-specific: + * an image, a video, a music clip and a speech track all come back through this + * handler. Keeping it in one place is also what keeps the authorization check + * below in one place. + * + * `artifactServeUrl` in `../lib/generation-server-store` builds the URL, so the + * two stay in step. + */ +export const Route = createFileRoute('/api/artifacts')({ + server: { + handlers: { + GET: async ({ request }) => { + const artifactId = new URL(request.url).searchParams.get('id') + if (!artifactId) { + return new Response('missing artifact id', { status: 400 }) + } + + const persistence = generationServerPersistence() + const artifact = await retrieveArtifact(persistence, artifactId) + if (!artifact) return new Response('not found', { status: 404 }) + + // A real multi-user app MUST authorize here before serving: the id + // comes from the caller, and `ArtifactRecord` carries the `threadId` + // to check it against. This demo is single-user, so there is no + // session to check. + const blob = await retrieveBlob(persistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + // Artifact ids are content-addressed by run: the bytes behind an id + // never change, so this is safe to cache hard. `private` because a + // real deployment serves these per-user. + 'cache-control': 'private, max-age=31536000, immutable', + }, + }) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.generate.audio.ts b/examples/ts-react-chat/src/routes/api.generate.audio.ts index ab9820b55..9cc5bfd23 100644 --- a/examples/ts-react-chat/src/routes/api.generate.audio.ts +++ b/examples/ts-react-chat/src/routes/api.generate.audio.ts @@ -1,11 +1,20 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateAudio, toServerSentEventsResponse } from '@tanstack/ai' +import { + generateAudio, + generationParamsFromBody, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { withGenerationPersistence } from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, UnknownProviderError, buildAudioAdapter, } from '../lib/server-audio-adapters' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' const AUDIO_PROVIDER_SCHEMA = z .enum([ @@ -64,6 +73,20 @@ export const Route = createFileRoute('/api/generate/audio')({ const { prompt, duration, provider, model } = parsed.data + // The AG-UI envelope also carries the generation's identity. Persistence + // files the run under it, so a reload hydrates the same slot. + let threadId: string | undefined + let runId: string | undefined + try { + ;({ threadId, runId } = generationParamsFromBody('audio', body)) + } catch (err) { + return jsonError(400, { + error: 'invalid_envelope', + message: + err instanceof Error ? err.message : 'Invalid request envelope', + }) + } + try { const adapter = buildAudioAdapter(provider ?? 'gemini-lyria', model) @@ -72,6 +95,16 @@ export const Route = createFileRoute('/api/generate/audio')({ prompt, duration, stream: true, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + // Copies the generated audio into our blob store and rewrites the + // result to the shared `/api/artifacts` serve URL, so a restored + // run still plays after the provider's link expires. + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) return toServerSentEventsResponse(stream) diff --git a/examples/ts-react-chat/src/routes/api.generate.image.ts b/examples/ts-react-chat/src/routes/api.generate.image.ts index cbf2f2a4c..f6e3305b6 100644 --- a/examples/ts-react-chat/src/routes/api.generate.image.ts +++ b/examples/ts-react-chat/src/routes/api.generate.image.ts @@ -7,8 +7,6 @@ import { import { grokImage } from '@tanstack/ai-grok' import { reconstructGeneration, - retrieveArtifact, - retrieveBlob, withGenerationPersistence, } from '@tanstack/ai-persistence' import { @@ -29,10 +27,9 @@ import { * The stores are SQLite-backed, so a generated image is still there after a * dev-server restart — reload the page and it renders from the database. * - * The GET does double duty, which is why it branches: - * - `?artifact=` serves stored bytes (the URL `artifactUrl` produced). - * - otherwise `reconstructGeneration` answers a `?threadId=` mount hydration, - * which is what `persistence: true` on the client calls. + * The bytes themselves are served by the shared `/api/artifacts` route, which + * every generation activity here shares. This GET only answers the `?threadId=` + * mount hydration that `persistence: true` on the client calls. */ export const Route = createFileRoute('/api/generate/image')({ server: { @@ -71,34 +68,11 @@ export const Route = createFileRoute('/api/generate/image')({ return toServerSentEventsResponse(stream) }, - GET: async ({ request }) => { - const persistence = generationServerPersistence() - const artifactId = new URL(request.url).searchParams.get('artifact') - - if (artifactId) { - const artifact = await retrieveArtifact(persistence, artifactId) - if (!artifact) return new Response('not found', { status: 404 }) - - // A real multi-user app MUST authorize here before serving: the id - // comes from the caller, and `ArtifactRecord` carries the `threadId` - // to check it against. This demo is single-user, so there is no - // session to check. - const blob = await retrieveBlob(persistence, artifact) - if (!blob) return new Response('not found', { status: 404 }) - - return new Response(blob.body ?? (await blob.arrayBuffer()), { - headers: { - 'content-type': artifact.mimeType, - 'content-length': String(artifact.size), - }, - }) - } - - // Mount hydration for `persistence: true`: resolves the latest run for - // `?threadId=` and returns `{ resumeSnapshot, activeRun }`. Pass - // `authorize` here in a multi-user app. - return await reconstructGeneration(persistence, request) - }, + // Mount hydration for `persistence: true`: resolves the latest run for + // `?threadId=` and returns `{ resumeSnapshot, activeRun }`. Pass + // `authorize` here in a multi-user app. + GET: async ({ request }) => + await reconstructGeneration(generationServerPersistence(), request), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.speech.ts b/examples/ts-react-chat/src/routes/api.generate.speech.ts index d0f4240e6..1b71c26c9 100644 --- a/examples/ts-react-chat/src/routes/api.generate.speech.ts +++ b/examples/ts-react-chat/src/routes/api.generate.speech.ts @@ -1,11 +1,20 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateSpeech, toServerSentEventsResponse } from '@tanstack/ai' +import { + generateSpeech, + generationParamsFromBody, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { withGenerationPersistence } from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, UnknownProviderError, buildSpeechAdapter, } from '../lib/server-audio-adapters' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' const SPEECH_PROVIDER_SCHEMA = z .enum(['openai', 'gemini', 'fal', 'grok', 'elevenlabs']) @@ -58,6 +67,20 @@ export const Route = createFileRoute('/api/generate/speech')({ const { text, voice, format, provider } = parsed.data + // The AG-UI envelope also carries the generation's identity. Persistence + // files the run under it, so a reload hydrates the same slot. + let threadId: string | undefined + let runId: string | undefined + try { + ;({ threadId, runId } = generationParamsFromBody('tts', body)) + } catch (err) { + return jsonError(400, { + error: 'invalid_envelope', + message: + err instanceof Error ? err.message : 'Invalid request envelope', + }) + } + try { const adapter = buildSpeechAdapter(provider ?? 'openai') @@ -67,6 +90,16 @@ export const Route = createFileRoute('/api/generate/speech')({ voice, format, stream: true, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + // Copies the synthesized speech into our blob store and rewrites the + // result to the shared `/api/artifacts` serve URL, so a restored run + // still plays after the provider's link expires. + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) return toServerSentEventsResponse(stream) diff --git a/examples/ts-react-chat/src/routes/api.generate.video.ts b/examples/ts-react-chat/src/routes/api.generate.video.ts index d26469fa6..6fb45c7ef 100644 --- a/examples/ts-react-chat/src/routes/api.generate.video.ts +++ b/examples/ts-react-chat/src/routes/api.generate.video.ts @@ -1,13 +1,38 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateVideo, toServerSentEventsResponse } from '@tanstack/ai' +import { + generateVideo, + generationParamsFromBody, + toServerSentEventsResponse, +} from '@tanstack/ai' import { grokVideo } from '@tanstack/ai-grok' +import { withGenerationPersistence } from '@tanstack/ai-persistence' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' +/** + * Video generation with SERVER-side persistence. + * + * Video is where durable bytes matter most: a run takes minutes and the + * provider's result URL expires. `withGenerationPersistence` copies the file + * into our blob store, and `artifactUrl` rewrites the result to the shared + * `/api/artifacts` route — so the video still plays long after the provider + * link is dead. + * + * The adapter arguments are read straight off the envelope's `data`, because + * `size` / `model` are adapter-specific unions that the provider-agnostic video + * input widens to `string`. `generationParamsFromBody` is used for the run's + * IDENTITY only: it carries `threadId` / `runId` off the AG-UI envelope, so the + * run is filed under the scope the client hydrates by. + */ export const Route = createFileRoute('/api/generate/video')({ server: { handlers: { POST: async ({ request }) => { const body = await request.json() const { prompt, size, duration, model } = body.data + const { threadId, runId } = generationParamsFromBody('video', body) const stream = generateVideo({ adapter: grokVideo(model ?? 'grok-imagine-video'), @@ -17,6 +42,13 @@ export const Route = createFileRoute('/api/generate/video')({ stream: true, pollingInterval: 3000, maxDuration: 600_000, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) return toServerSentEventsResponse(stream) diff --git a/examples/ts-react-chat/src/routes/api.transcribe.ts b/examples/ts-react-chat/src/routes/api.transcribe.ts index a26800547..a505fe53e 100644 --- a/examples/ts-react-chat/src/routes/api.transcribe.ts +++ b/examples/ts-react-chat/src/routes/api.transcribe.ts @@ -1,11 +1,20 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateTranscription, toServerSentEventsResponse } from '@tanstack/ai' +import { + generateTranscription, + generationParamsFromBody, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { withGenerationPersistence } from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, UnknownProviderError, buildTranscriptionAdapter, } from '../lib/server-audio-adapters' +import { + artifactServeUrl, + generationServerPersistence, +} from '../lib/generation-server-store' const TRANSCRIPTION_PROVIDER_SCHEMA = z .enum(['openai', 'openai-diarize', 'fal', 'grok', 'elevenlabs']) @@ -64,6 +73,23 @@ export const Route = createFileRoute('/api/transcribe')({ const { audio, language, responseFormat, modelOptions, provider } = parsed.data + // The AG-UI envelope also carries the generation's identity. Persistence + // files the run under it, so a reload hydrates the same slot. + let threadId: string | undefined + let runId: string | undefined + try { + ;({ threadId, runId } = generationParamsFromBody( + 'transcription', + body, + )) + } catch (err) { + return jsonError(400, { + error: 'invalid_envelope', + message: + err instanceof Error ? err.message : 'Invalid request envelope', + }) + } + try { const adapter = buildTranscriptionAdapter(provider ?? 'openai') @@ -74,6 +100,16 @@ export const Route = createFileRoute('/api/transcribe')({ responseFormat, modelOptions, stream: true, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + // Transcription produces text, not media — what gets persisted here + // is the run record plus the INPUT audio as an artifact, so a + // restored run still shows what was transcribed. + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], }) return toServerSentEventsResponse(stream) From 1ac59f180b5b08db7479d5a1e767681e24034158 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:53:16 +1000 Subject: [PATCH 43/66] fix(example): make generation routes resumable so a refresh can rejoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ?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. --- .../src/lib/generation-durability.ts | 117 ++++++++++++++++++ .../src/routes/api.generate.audio.ts | 17 ++- .../src/routes/api.generate.image.ts | 27 ++-- .../src/routes/api.generate.speech.ts | 17 ++- .../src/routes/api.generate.video.ts | 87 ++++++++----- .../src/routes/api.transcribe.ts | 17 ++- 6 files changed, 239 insertions(+), 43 deletions(-) create mode 100644 examples/ts-react-chat/src/lib/generation-durability.ts diff --git a/examples/ts-react-chat/src/lib/generation-durability.ts b/examples/ts-react-chat/src/lib/generation-durability.ts new file mode 100644 index 000000000..1e828a3b5 --- /dev/null +++ b/examples/ts-react-chat/src/lib/generation-durability.ts @@ -0,0 +1,117 @@ +import { + EventType, + memoryStream, + resumeServerSentEventsResponse, +} from '@tanstack/ai' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Delivery durability for the generation routes — the layer that makes a run + * resumable, under the state persistence in `generation-server-store.ts`. + * + * Resumability is automatic on the CLIENT and opt-in on the SERVER. A hook + * using an HTTP connection adapter re-attaches to an in-flight run on mount by + * issuing `GET ?offset=-1&runId=…`; if the route never opted in, that + * request falls through to the SPA's HTML shell and the client fails trying to + * parse it as SSE ("Stream response body read failed"). So every streaming + * generation route here answers it. + * + * Two of the three durability layers live in this file: + * + * - DELIVERY — `memoryStream` logs each chunk so a rejoining client replays it + * instead of re-running the model. Routes opt in by passing the adapter as + * `durability` on their response, and by serving + * {@link replayGenerationIfResuming} from a `GET`. + * - RUN LIFETIME — {@link startDetachedGeneration} unhooks the run from the + * request so a reload cannot kill it. Only worth it when a run is long enough + * that losing it hurts (video), since a detached run keeps billing after the + * user leaves. Short activities let the run die with the request and are + * simply re-run. + * + * `memoryStream` keeps logs in a process-global map: development and + * single-process deployments only. Swap it for `durableStream(request, { + * server })` from `@tanstack/ai-durable-stream` in production; nothing else + * here changes. + */ + +/** Runs whose producer is still appending, so a retried POST attaches instead. */ +const activeProducers = new Set() + +/** Internal base for the synthetic Requests `memoryStream` is keyed by. */ +const DURABILITY_ORIGIN = 'http://generation.internal/' + +/** + * Start `makeStream()` detached from the HTTP request, appending every chunk to + * `runId`'s delivery log. A second call for a run already in flight is a no-op, + * so a retried POST attaches to the existing producer rather than generating + * (and billing) twice. + * + * Pair with {@link tailGenerationResponse}, which streams the log to the caller + * — cancelling that response then cancels a reader, never the producer. + */ +export function startDetachedGeneration( + runId: string, + makeStream: () => AsyncIterable, +): void { + if (activeProducers.has(runId)) return + activeProducers.add(runId) + + // Producer-mode handle, keyed by runId via the X-Run-Id header. + const sink = memoryStream( + new Request(DURABILITY_ORIGIN, { headers: { 'X-Run-Id': runId } }), + ) + + void (async () => { + try { + for await (const chunk of makeStream()) { + await sink.append([chunk]) + } + } catch (error) { + // The run threw before emitting a terminal chunk (the persistence + // middleware's onError already recorded the failure). Append a RUN_ERROR + // so readers unblock instead of waiting on a stream that never finishes. + await sink.append([ + { + type: EventType.RUN_ERROR, + message: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + } as StreamChunk, + ]) + } finally { + await sink.close() + activeProducers.delete(runId) + } + })() +} + +/** + * Stream a detached run to THIS client by tailing its log from the start. + * + * The generous first-chunk deadline is deliberate: this reader races the + * producer the same request just started. The default is tuned short for the + * opposite case — a reload rejoin, where an empty log means the run is gone. + */ +export function tailGenerationResponse(runId: string): Response { + const reader = memoryStream( + new Request( + `${DURABILITY_ORIGIN}?offset=-1&runId=${encodeURIComponent(runId)}`, + ), + { firstChunkDeadlineMs: 10_000 }, + ) + return resumeServerSentEventsResponse({ adapter: reader }) +} + +/** + * The GET half of `joinRun`: replay a run when the request carries a resume + * offset, otherwise `null` so the route can fall through to whatever else its + * GET serves (image also answers `reconstructGeneration` there). + * + * The run id rides the `X-Run-Id` header or `?runId`, and the offset the + * `Last-Event-ID` header or `?offset` — ask the adapter via `resumeFrom()` + * rather than sniffing query params. + */ +export function replayGenerationIfResuming(request: Request): Response | null { + const durability = memoryStream(request) + if (durability.resumeFrom() === null) return null + return resumeServerSentEventsResponse({ adapter: durability }) +} diff --git a/examples/ts-react-chat/src/routes/api.generate.audio.ts b/examples/ts-react-chat/src/routes/api.generate.audio.ts index 9cc5bfd23..e3e1fbf17 100644 --- a/examples/ts-react-chat/src/routes/api.generate.audio.ts +++ b/examples/ts-react-chat/src/routes/api.generate.audio.ts @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { generateAudio, generationParamsFromBody, + memoryStream, toServerSentEventsResponse, } from '@tanstack/ai' import { withGenerationPersistence } from '@tanstack/ai-persistence' @@ -11,6 +12,7 @@ import { UnknownProviderError, buildAudioAdapter, } from '../lib/server-audio-adapters' +import { replayGenerationIfResuming } from '../lib/generation-durability' import { artifactServeUrl, generationServerPersistence, @@ -107,7 +109,13 @@ export const Route = createFileRoute('/api/generate/audio')({ ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: each chunk is logged and id-tagged, so a + // reconnect or a mount-time `joinRun` replays instead of re-running + // the model. The run itself still ends with the request — an audio + // clip is short enough to simply re-run. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) } catch (err) { if (err instanceof InvalidModelOverrideError) { return jsonError(400, { @@ -138,6 +146,13 @@ export const Route = createFileRoute('/api/generate/audio')({ }) } }, + + // `joinRun` replay — re-attach to a run still in flight from a previous + // request. 404 when the run is unknown or its log has aged out, rather + // than the SPA shell the client cannot parse as SSE. + GET: ({ request }) => + replayGenerationIfResuming(request) ?? + new Response('no resumable run', { status: 404 }), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.image.ts b/examples/ts-react-chat/src/routes/api.generate.image.ts index f6e3305b6..e0ae6bafc 100644 --- a/examples/ts-react-chat/src/routes/api.generate.image.ts +++ b/examples/ts-react-chat/src/routes/api.generate.image.ts @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { generateImage, generationParamsFromRequest, + memoryStream, toServerSentEventsResponse, } from '@tanstack/ai' import { grokImage } from '@tanstack/ai-grok' @@ -9,6 +10,7 @@ import { reconstructGeneration, withGenerationPersistence, } from '@tanstack/ai-persistence' +import { replayGenerationIfResuming } from '../lib/generation-durability' import { artifactServeUrl, generationServerPersistence, @@ -28,8 +30,12 @@ import { * dev-server restart — reload the page and it renders from the database. * * The bytes themselves are served by the shared `/api/artifacts` route, which - * every generation activity here shares. This GET only answers the `?threadId=` - * mount hydration that `persistence: true` on the client calls. + * every generation activity here shares. + * + * Chunks are also logged for replay (see `../lib/generation-durability`), so + * the GET does two independent jobs, like the persistent-chat route: a + * `joinRun` delivery replay when the request carries a resume offset, and + * otherwise the `?threadId=` mount hydration that `persistence: true` calls. */ export const Route = createFileRoute('/api/generate/image')({ server: { @@ -65,14 +71,21 @@ export const Route = createFileRoute('/api/generate/image')({ ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: chunks are logged and id-tagged, so a reconnect + // or a mount-time `joinRun` replays instead of re-running the model. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) }, - // Mount hydration for `persistence: true`: resolves the latest run for - // `?threadId=` and returns `{ resumeSnapshot, activeRun }`. Pass - // `authorize` here in a multi-user app. + // Two independent jobs, resolved in order: + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`. Pass `authorize` + // here in a multi-user app. GET: async ({ request }) => - await reconstructGeneration(generationServerPersistence(), request), + replayGenerationIfResuming(request) ?? + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.speech.ts b/examples/ts-react-chat/src/routes/api.generate.speech.ts index 1b71c26c9..487141f4f 100644 --- a/examples/ts-react-chat/src/routes/api.generate.speech.ts +++ b/examples/ts-react-chat/src/routes/api.generate.speech.ts @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { generateSpeech, generationParamsFromBody, + memoryStream, toServerSentEventsResponse, } from '@tanstack/ai' import { withGenerationPersistence } from '@tanstack/ai-persistence' @@ -11,6 +12,7 @@ import { UnknownProviderError, buildSpeechAdapter, } from '../lib/server-audio-adapters' +import { replayGenerationIfResuming } from '../lib/generation-durability' import { artifactServeUrl, generationServerPersistence, @@ -102,7 +104,13 @@ export const Route = createFileRoute('/api/generate/speech')({ ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: chunks are logged and id-tagged, so a + // reconnect or a mount-time `joinRun` replays instead of re-running + // the model. The run still ends with the request — this activity is + // short enough to simply re-run. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) } catch (err) { if (err instanceof InvalidModelOverrideError) { return jsonError(400, { @@ -131,6 +139,13 @@ export const Route = createFileRoute('/api/generate/speech')({ }) } }, + + // `joinRun` replay — re-attach to a run still in flight from a previous + // request. 404 when the run is unknown or its log has aged out, rather + // than the SPA shell the client cannot parse as SSE. + GET: ({ request }) => + replayGenerationIfResuming(request) ?? + new Response('no resumable run', { status: 404 }), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.video.ts b/examples/ts-react-chat/src/routes/api.generate.video.ts index 6fb45c7ef..47891c646 100644 --- a/examples/ts-react-chat/src/routes/api.generate.video.ts +++ b/examples/ts-react-chat/src/routes/api.generate.video.ts @@ -1,58 +1,79 @@ import { createFileRoute } from '@tanstack/react-router' -import { - generateVideo, - generationParamsFromBody, - toServerSentEventsResponse, -} from '@tanstack/ai' +import { generateVideo, generationParamsFromBody } from '@tanstack/ai' import { grokVideo } from '@tanstack/ai-grok' import { withGenerationPersistence } from '@tanstack/ai-persistence' +import { + replayGenerationIfResuming, + startDetachedGeneration, + tailGenerationResponse, +} from '../lib/generation-durability' import { artifactServeUrl, generationServerPersistence, } from '../lib/generation-server-store' /** - * Video generation with SERVER-side persistence. + * Video generation, durable end to end — the activity that needs it most: a run + * takes minutes, so a refresh mid-job is the normal case rather than the edge. * - * Video is where durable bytes matter most: a run takes minutes and the - * provider's result URL expires. `withGenerationPersistence` copies the file - * into our blob store, and `artifactUrl` rewrites the result to the shared - * `/api/artifacts` route — so the video still plays long after the provider - * link is dead. + * - STATE: `withGenerationPersistence` records the run and copies the finished + * video into our blob store, and `artifactUrl` rewrites the result to the + * shared `/api/artifacts` route — so it still plays after the provider's link + * expires. + * - DELIVERY + LIFETIME: the run is detached from this request and its chunks + * go to a replayable log (see `../lib/generation-durability`), so a reload + * neither kills the job nor loses the events emitted while away. * - * The adapter arguments are read straight off the envelope's `data`, because - * `size` / `model` are adapter-specific unions that the provider-agnostic video - * input widens to `string`. `generationParamsFromBody` is used for the run's - * IDENTITY only: it carries `threadId` / `runId` off the AG-UI envelope, so the - * run is filed under the scope the client hydrates by. + * The GET is what the client's `joinRun` calls on mount to tail a run that was + * still going when the page went away. */ export const Route = createFileRoute('/api/generate/video')({ server: { handlers: { POST: async ({ request }) => { const body = await request.json() + // Adapter arguments come straight off the envelope's `data`: `size` and + // `model` are adapter-specific unions that the provider-agnostic video + // input widens to `string`. const { prompt, size, duration, model } = body.data const { threadId, runId } = generationParamsFromBody('video', body) - const stream = generateVideo({ - adapter: grokVideo(model ?? 'grok-imagine-video'), - prompt, - size, - duration, - stream: true, - pollingInterval: 3000, - maxDuration: 600_000, - ...(threadId ? { threadId } : {}), - ...(runId ? { runId } : {}), - middleware: [ - withGenerationPersistence(generationServerPersistence(), { - artifactUrl: (ref) => artifactServeUrl(ref.artifactId), - }), - ], - }) + // Durability is keyed by run id. A client that sends none has no id to + // rejoin with either, so minting one here costs nothing and keeps the + // producer/reader split uniform. + const resolvedRunId = runId ?? crypto.randomUUID() - return toServerSentEventsResponse(stream) + startDetachedGeneration(resolvedRunId, () => + generateVideo({ + adapter: grokVideo(model ?? 'grok-imagine-video'), + prompt, + size, + duration, + stream: true, + pollingInterval: 3000, + maxDuration: 600_000, + runId: resolvedRunId, + ...(threadId ? { threadId } : {}), + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], + // No client abortController: the run owns its own lifetime. + }), + ) + + // Tail the log rather than the model, so cancelling this response (a + // reload) cancels only the reader. + return tailGenerationResponse(resolvedRunId) }, + + // `joinRun` replay — re-attach to a run still in flight from a previous + // request. Returns 404 when the run is unknown or its log has aged out, + // rather than serving the SPA shell the client cannot parse as SSE. + GET: ({ request }) => + replayGenerationIfResuming(request) ?? + new Response('no resumable run', { status: 404 }), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.transcribe.ts b/examples/ts-react-chat/src/routes/api.transcribe.ts index a505fe53e..78c7f5e02 100644 --- a/examples/ts-react-chat/src/routes/api.transcribe.ts +++ b/examples/ts-react-chat/src/routes/api.transcribe.ts @@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { generateTranscription, generationParamsFromBody, + memoryStream, toServerSentEventsResponse, } from '@tanstack/ai' import { withGenerationPersistence } from '@tanstack/ai-persistence' @@ -11,6 +12,7 @@ import { UnknownProviderError, buildTranscriptionAdapter, } from '../lib/server-audio-adapters' +import { replayGenerationIfResuming } from '../lib/generation-durability' import { artifactServeUrl, generationServerPersistence, @@ -112,7 +114,13 @@ export const Route = createFileRoute('/api/transcribe')({ ], }) - return toServerSentEventsResponse(stream) + // Delivery durability: chunks are logged and id-tagged, so a + // reconnect or a mount-time `joinRun` replays instead of re-running + // the model. The run still ends with the request — this activity is + // short enough to simply re-run. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) } catch (err) { if (err instanceof InvalidModelOverrideError) { return jsonError(400, { @@ -141,6 +149,13 @@ export const Route = createFileRoute('/api/transcribe')({ }) } }, + + // `joinRun` replay — re-attach to a run still in flight from a previous + // request. 404 when the run is unknown or its log has aged out, rather + // than the SPA shell the client cannot parse as SSE. + GET: ({ request }) => + replayGenerationIfResuming(request) ?? + new Response('no resumable run', { status: 404 }), }, }, }) From b9e726b32981f2f8ba39a60f6770c5cf42372745 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:59:04 +1000 Subject: [PATCH 44/66] fix(example): let nitro's dev middleware serve /api to subresources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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: `` 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. --- examples/ts-react-chat/vite.config.ts | 44 ++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/examples/ts-react-chat/vite.config.ts b/examples/ts-react-chat/vite.config.ts index f3a754138..725bf151c 100644 --- a/examples/ts-react-chat/vite.config.ts +++ b/examples/ts-react-chat/vite.config.ts @@ -30,6 +30,41 @@ import { devtools } from '@tanstack/devtools-vite' // optimizer has no loader for it and the SSR/nitro builds must not inline it. const SERVER_ONLY_NATIVE = ['dockerode', '@anthropic-ai/sdk', '@ngrok/ngrok'] +// Dev-only workaround for a nitro dev-middleware heuristic. +// +// `nitro/dist/_build/vite.dev.mjs` decides whether a request is a static asset +// (and so must NOT reach the server) from `Sec-Fetch-Dest`: +// +// isAsset = fetchDest && fetchDest !== 'empty' +// ? !/^(?:document|iframe|frame)$/.test(fetchDest) +// : isAssetByExt +// +// Anything that isn't a document — `image`, `audio`, `video`, `script`, `font` +// — is treated as an asset and falls through to vite's static middleware, +// which has no file to serve and 404s with the connect `Cannot GET` page. The +// extension branch is only consulted when the header is absent/`empty`, so +// renaming the route doesn't help. +// +// It only bites routes nitro sees under Start's catch-all `/**` (an explicitly +// registered nitro route short-circuits the check), which is every server route +// here. So `` 404s in dev while the same URL +// fetched from JS returns the bytes — and production is unaffected, since this +// middleware only exists in the vite dev server. +// +// Presenting `empty` for our own API paths routes them back to the server +// without changing what the browser actually sends. +const nitroServeApiToSubresources = { + name: 'nitro-serve-api-to-subresources', + enforce: 'pre', + apply: 'serve', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (req.url?.startsWith('/api/')) req.headers['sec-fetch-dest'] = 'empty' + next() + }) + }, +} as const satisfies import('vite').PluginOption + const config = defineConfig({ optimizeDeps: { exclude: SERVER_ONLY_NATIVE }, // Server-side only fix. @elevenlabs/elevenlabs-js ships a top-level @@ -49,7 +84,14 @@ const config = defineConfig({ // this is a no-op there. build: { rollupOptions: { external: SERVER_ONLY_NATIVE } }, resolve: { tsconfigPaths: true }, - plugins: [devtools(), nitro(), tailwindcss(), tanstackStart(), viteReact()], + plugins: [ + nitroServeApiToSubresources, + devtools(), + nitro(), + tailwindcss(), + tanstackStart(), + viteReact(), + ], }) export default config From 420a9da54145f4bea9f4e98fb20e60e29203b5f5 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:06:00 +1000 Subject: [PATCH 45/66] feat(persistence): server-driven generation persistence over server functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../server-function-generation-persistence.md | 22 ++ docs/chat/connection-adapters.md | 6 + docs/config.json | 2 +- docs/persistence/generation-persistence.md | 110 +++++++++ .../ai-angular/src/inject-generate-audio.ts | 6 +- .../ai-angular/src/inject-generate-video.ts | 18 ++ packages/ai-angular/src/inject-generation.ts | 18 ++ .../tests/inject-generation.test.ts | 23 +- packages/ai-client/src/connection-adapters.ts | 79 ++++++ packages/ai-client/src/generation-client.ts | 77 ++++-- packages/ai-client/src/generation-types.ts | 42 +++- packages/ai-client/src/index.ts | 1 + .../ai-client/src/video-generation-client.ts | 79 ++++-- .../tests/connection-adapters.test.ts | 54 +++++ .../ai-client/tests/generation-client.test.ts | 226 +++++++++++++++++- packages/ai-persistence/src/index.ts | 6 +- .../src/reconstruct-generation.ts | 92 +++++-- .../tests/reconstruct-generation.test.ts | 86 ++++++- packages/ai-react/src/use-generate-audio.ts | 14 ++ packages/ai-react/src/use-generate-image.ts | 14 ++ packages/ai-react/src/use-generate-speech.ts | 14 ++ packages/ai-react/src/use-generate-video.ts | 18 ++ packages/ai-react/src/use-generation.ts | 18 ++ packages/ai-react/src/use-summarize.ts | 14 ++ packages/ai-react/src/use-transcription.ts | 14 ++ .../ai-react/tests/use-generation.test.ts | 14 +- packages/ai-solid/src/use-generate-audio.ts | 6 +- packages/ai-solid/src/use-generate-image.ts | 6 +- packages/ai-solid/src/use-generate-speech.ts | 6 +- packages/ai-solid/src/use-generate-video.ts | 18 ++ packages/ai-solid/src/use-generation.ts | 18 ++ packages/ai-solid/src/use-summarize.ts | 6 +- packages/ai-solid/src/use-transcription.ts | 6 +- .../ai-solid/tests/use-generation.test.ts | 25 +- .../src/create-generate-audio.svelte.ts | 6 +- .../src/create-generate-image.svelte.ts | 6 +- .../src/create-generate-speech.svelte.ts | 6 +- .../src/create-generate-video.svelte.ts | 18 ++ .../ai-svelte/src/create-generation.svelte.ts | 18 ++ .../ai-svelte/src/create-summarize.svelte.ts | 6 +- .../src/create-transcription.svelte.ts | 6 +- .../ai-svelte/tests/create-generation.test.ts | 22 +- packages/ai-vue/src/use-generate-audio.ts | 6 +- packages/ai-vue/src/use-generate-image.ts | 6 +- packages/ai-vue/src/use-generate-speech.ts | 6 +- packages/ai-vue/src/use-generate-video.ts | 18 ++ packages/ai-vue/src/use-generation.ts | 18 ++ packages/ai-vue/src/use-summarize.ts | 6 +- packages/ai-vue/src/use-transcription.ts | 6 +- packages/ai-vue/tests/use-generation.test.ts | 14 +- packages/ai/src/index.ts | 8 +- packages/ai/src/stream-durability.ts | 65 ++++- packages/ai/tests/stream-durability.test.ts | 64 ++++- 53 files changed, 1329 insertions(+), 134 deletions(-) create mode 100644 .changeset/server-function-generation-persistence.md diff --git a/.changeset/server-function-generation-persistence.md b/.changeset/server-function-generation-persistence.md new file mode 100644 index 000000000..74e917129 --- /dev/null +++ b/.changeset/server-function-generation-persistence.md @@ -0,0 +1,22 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-persistence': minor +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +--- + +Make generation persistence work with server functions and direct connections. Server-driven restore (`persistence: true`) previously only worked with the HTTP adapters (`fetchServerSentEvents` / `fetchHttpStream` and their XHR variants), because they are the only connections that implement the optional `hydrateGeneration(threadId)` and `joinRun(runId)` handlers; with `stream()`, `rpcStream()`, or a plain `fetcher` the option silently no-opped, and a stored snapshot still `running` after a reload left the hook stuck on `generating` forever. + +**Handlers on the lightweight adapters (`@tanstack/ai-client`).** `stream()` and `rpcStream()` take an optional second argument, `StreamConnectionHandlers` (`{ hydrate, hydrateGeneration, joinRun }`), spread onto the returned adapter so server-driven persistence works without an HTTP endpoint — each handler is typically a one-line server-function or RPC call. `ConnectConnectionAdapter` also declares the optional chat `hydrate` handler alongside the generation ones. + +**Handlers as generation options (`@tanstack/ai-client`).** `GenerationClientOptions` (and `VideoGenerationClientOptions`, plus every framework hook's generation options) accept optional `hydrateGeneration` / `joinRun` alongside a `fetcher` — or as a fallback when a connection doesn't carry its own. `persistence: true` now hydrates whenever either source exists; the constructor warning only fires when neither does. + +**Interrupted runs no longer stick on `generating` (`@tanstack/ai-client`).** A restored or hydrated snapshot with `status: 'running'` that no `joinRun` handler can tail is repainted as an interrupted error — an interrupted generation cannot be resumed, only re-run — in both `GenerationClient` and `VideoGenerationClient`, for client-driven and server-driven restore alike. + +**Request-free hydration (`@tanstack/ai-persistence`).** New `getGenerationHydration(persistence, id, { by?: 'threadId' | 'runId' })` returns the plain `{ resumeSnapshot, activeRun }` payload straight from the `generationRuns` store, so a server function can back `hydrateGeneration` without fabricating a `Request`. `reconstructGeneration` now delegates to it; `authorize` stays on the `Request`-based function only, so server-function callers gate on their own session before resolving the id. + +**Server-function run replay (`@tanstack/ai`).** `memoryStream` also accepts an explicit `{ runId, offset? }` init instead of a `Request`, and a new `replayRunStream(durability, offset?)` async generator maps a durability `read` (from the start by default) to a bare `StreamChunk` stream — together they let a streaming server function serve `joinRun` for a run id it received as call data. diff --git a/docs/chat/connection-adapters.md b/docs/chat/connection-adapters.md index 96ff035f9..df2373b87 100644 --- a/docs/chat/connection-adapters.md +++ b/docs/chat/connection-adapters.md @@ -246,6 +246,8 @@ The factory receives the conversation messages plus any per-request `data` you p > **Tip:** `stream()` is **request-scoped**. The factory is invoked once per `sendMessage`, the iterable runs to completion, and the connection closes. If you need a single long-lived channel that multiplexes many sends — for example a WebSocket — use [`subscribe` / `send`](#persistent-transports-websockets-and-friends) instead. +`stream()` also takes an optional second argument of persistence handlers — `{ hydrate, hydrateGeneration, joinRun }` — spread onto the adapter, so server-driven persistence (`persistence: true`) works without an HTTP endpoint. Each handler is typically a one-line call into your server: `hydrate` restores a chat thread, `hydrateGeneration` restores a generation's last run, and `joinRun` replays a run still in flight. See [Generation Persistence](../persistence/generation-persistence#server-functions--direct) for the full server-function wiring. + ## Server Functions via `fetcher` When you call into your server with an **async** function — the universal case for a [TanStack Start](https://tanstack.com/start) server function, which always returns a `Promise` — use the top-level `fetcher` option instead of a connection adapter. `fetcher` is a sibling of `connection` (provide exactly one), and it accepts a plain async function. It mirrors the `fetcher` option on the [generation hooks](../media/generation-hooks). The most common shape is a handler that ends with `toServerSentEventsResponse(...)` and resolves to a `Response`: @@ -277,6 +279,8 @@ const { messages, sendMessage } = useChat({ The fetcher receives `{ messages, data, threadId, runId }` plus an `AbortSignal` (triggered by `stop()` or when a send is superseded). Return a `Response` — whose SSE body the chat client parses for you — **or** an `AsyncIterable`, which is yielded directly. If your server function returns the stream itself (instead of wrapping it in a `Response`), the fetcher handles that too. Sync and `Promise`-wrapped returns are both accepted. +> **Tip:** The generation hooks (`useGenerateImage` and siblings) take the same server-function shape a step further: alongside their `fetcher` they accept `hydrateGeneration` and `joinRun` options, so `persistence: true` hydrates and rejoins through server functions with no HTTP route at all. See [Generation Persistence — Server functions / direct](../persistence/generation-persistence#server-functions--direct). + > **Tip:** The choice between `fetcher` and [`stream()`](#server-functions-and-direct-async-iterables) is about **async vs sync**, not `Response`-vs-iterable — both can yield an `AsyncIterable`. `stream()`'s factory must return that iterable **synchronously**, so a server-function call (which returns a `Promise`) won't typecheck there — that's the gap `fetcher` fills ([issue #509](https://github.com/TanStack/ai/issues/509)). Use `stream()` when you can hand back an async iterable synchronously (in-process `chat()`, an RPC client, tests); use `fetcher` for anything you have to `await`. Both normalize to the same request-scoped adapter, so `stop()`/abort, error handling, and tool calls behave identically. ## RPC Streams @@ -295,6 +299,8 @@ const { messages } = useChat({ }); ``` +Like `stream()`, `rpcStream()` takes an optional second argument of persistence handlers (`{ hydrate, hydrateGeneration, joinRun }`) so server-driven persistence works over RPC — each handler is usually a one-line RPC call. + ## Persistent Transports (WebSockets and Friends) A persistent transport — WebSocket, BroadcastChannel, postMessage between iframes, a shared worker — is fundamentally different from request/response. You open the channel **once**, then send and receive over it for the lifetime of the client. `stream()`/`connect()` can't model this cleanly because they assume one async iterable per request. diff --git a/docs/config.json b/docs/config.json index 2801662b9..cccb02afb 100644 --- a/docs/config.json +++ b/docs/config.json @@ -170,7 +170,7 @@ "label": "Connection Adapters", "to": "chat/connection-adapters", "addedAt": "2026-04-15", - "updatedAt": "2026-07-17" + "updatedAt": "2026-07-29" }, { "label": "Thinking & Reasoning", diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 5cee024a2..6c54dd00d 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -191,6 +191,116 @@ sequenceDiagram end ``` +## Server functions / direct + +The HTTP adapters above implement hydration and rejoin for you. With +[TanStack Start](https://tanstack.com/start) server functions (or any direct, +in-process call) there is no `GET` route to hang them on, so you supply the +two handlers yourself — one for mount-time hydration, one for replaying an +in-flight run — and pass them as options alongside the `fetcher` (or to +`stream()` / `rpcStream()`). + +Three server functions cover it: one runs the generation, one answers +hydration with `getGenerationHydration`, and one replays the run's durability +log with `replayRunStream`: + +```ts group=generation-server-functions +// server/image.ts +import { createServerFn } from '@tanstack/react-start' +import { + generateImage, + memoryStream, + replayRunStream, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + getGenerationHydration, + memoryPersistence, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import type { ImageGenerateInput } from '@tanstack/ai-client' + +const persistence = memoryPersistence() + +export const generateImageFn = createServerFn({ method: 'POST' }) + .inputValidator((data: ImageGenerateInput & { threadId: string }) => data) + .handler(({ data: { threadId, ...input } }) => { + if (typeof input.prompt !== 'string') { + throw new Error('This endpoint accepts text image prompts only.') + } + // One run id for both the durability log and the run record, so the + // rejoin below replays exactly the run hydration reports. + const runId = crypto.randomUUID() + const stream = generateImage({ + adapter: openaiImage('gpt-image-2'), + prompt: input.prompt, + threadId, + runId, + stream: true, + // `artifactUrl` is optional — see Keep generated files. + middleware: [ + withGenerationPersistence(persistence, { + artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, + }), + ], + }) + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream({ runId }) }, + }) + }) + +export const getImageHydrationFn = createServerFn({ method: 'GET' }) + .inputValidator((threadId: string) => threadId) + .handler(async ({ data: threadId }) => { + // `getGenerationHydration` does no auth — gate on your session here, the + // way you would pass `authorize` to `reconstructGeneration`. + return getGenerationHydration(persistence, threadId) + }) + +export const joinImageRunFn = createServerFn({ method: 'GET' }) + .inputValidator((runId: string) => runId) + .handler(({ data: runId }) => replayRunStream(memoryStream({ runId }))) +``` + +On the client, pass the two handlers next to the `fetcher`. A reload now +hydrates the last run through `getImageHydrationFn`, and a run still +generating is tailed to completion through `joinImageRunFn`: + +```tsx +import { useGenerateImage } from '@tanstack/ai-react' +import { + generateImageFn, + getImageHydrationFn, + joinImageRunFn, +} from './server/image' + +export function HeroImageGenerator({ threadId }: { threadId: string }) { + const image = useGenerateImage({ + id: 'hero-image', + threadId, + fetcher: (input) => generateImageFn({ data: { ...input, threadId } }), + hydrateGeneration: (id) => getImageHydrationFn({ data: id }), + joinRun: async function* (runId, signal) { + yield* await joinImageRunFn({ data: runId, signal }) + }, + persistence: true, + }) + // The render is identical to the server-driven example above. + return

{image.status}

+} +``` + +A restored run that was still generating but has **no** `joinRun` handler to +tail it surfaces as an interrupted error — it cannot be resumed, only re-run — +instead of hanging on `generating` forever. + +The same handlers fit the lightweight connection adapters directly — +`stream(factory, { hydrateGeneration, joinRun })` and +`rpcStream(call, { hydrateGeneration, joinRun })` — for in-process or RPC +transports; they also accept a chat `hydrate` handler for `useChat`'s +server-driven persistence. + ## Client-driven: a storage adapter Pass a storage adapter instead, and give the hook a stable `id`. The client diff --git a/packages/ai-angular/src/inject-generate-audio.ts b/packages/ai-angular/src/inject-generate-audio.ts index 091291451..0b839647c 100644 --- a/packages/ai-angular/src/inject-generate-audio.ts +++ b/packages/ai-angular/src/inject-generate-audio.ts @@ -26,7 +26,11 @@ export interface InjectGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< InjectGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index 56c507fbc..e5198f8cd 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -64,6 +64,20 @@ export interface InjectGenerateVideoOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] onResult?: (result: VideoGenerateResult) => TOutput | null | void onError?: (error: Error) => void onProgress?: (progress: number, message?: string) => void @@ -133,6 +147,10 @@ export function injectGenerateVideo( ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...options.devtools, diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index d2b56f19e..579362bf8 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -68,6 +68,20 @@ export interface InjectGenerationOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when a result is received. Can optionally return a transformed value. * @@ -170,6 +184,10 @@ export function injectGeneration< ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), ...(options.reconstructResult ? { reconstructResult: options.reconstructResult } : {}), diff --git a/packages/ai-angular/tests/inject-generation.test.ts b/packages/ai-angular/tests/inject-generation.test.ts index 311052759..645eba289 100644 --- a/packages/ai-angular/tests/inject-generation.test.ts +++ b/packages/ai-angular/tests/inject-generation.test.ts @@ -225,14 +225,13 @@ describe('injectGeneration', () => { expect(getItem).toHaveBeenCalledWith('generation:hydrate-me') // Hydration only surfaces state; it never restarts the run. expect(connect).not.toHaveBeenCalled() - // A restored running run repaints `status` to `generating` but never - // auto-tails, and its in-flight identity is exposed as `resumeState`. - expect(result.status()).toBe('generating') + // Without a `joinRun` handler the restored run cannot be tailed, so it + // surfaces as an interrupted error instead of a `generating` status + // that would never settle. + expect(result.error()?.message).toMatch(/interrupted/) + expect(result.status()).toBe('error') expect(result.isLoading()).toBe(false) - expect(result.resumeState()).toEqual({ - threadId: 'thread-stored', - runId: 'run-stored', - }) + expect(result.resumeState()).toBeNull() }) it('clears resume state and removes the persisted record on reset', async () => { @@ -304,11 +303,13 @@ describe('injectGenerateVideo', () => { expect(getItem).toHaveBeenCalledTimes(1) expect(getItem).toHaveBeenCalledWith('generation:video-hydrate') expect(connect).not.toHaveBeenCalled() - // A restored running run repaints `status` to `generating` and exposes its - // in-flight identity as `resumeState`, but never auto-tails. - expect(result.status()).toBe('generating') + // Without a `joinRun` handler the restored run cannot be tailed, so it + // surfaces as an interrupted error instead of a `generating` status + // that would never settle. + expect(result.error()?.message).toMatch(/interrupted/) + expect(result.status()).toBe('error') expect(result.isLoading()).toBe(false) - expect(result.resumeState()).toEqual(videoResumeSnapshot.resumeState) + expect(result.resumeState()).toBeNull() result.reset() await flushPromises() diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 0d9d60e69..57123f662 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -769,6 +769,16 @@ export interface ConnectConnectionAdapter { runId: string, abortSignal?: AbortSignal, ) => AsyncIterable + /** + * Fetch server-driven hydration for a chat `threadId`: the stored transcript + * plus a cursor to an in-flight run and any pending interrupts. The chat + * client calls this itself on mount when `persistence: true` (no loader/prop) + * and repaints it — it never auto-sends. Read-only JSON GET (`?threadId`), so + * it is transport-agnostic. Optional and feature-detected; present on + * `fetchServerSentEvents` / `fetchHttpStream`, and on `stream()` / + * `rpcStream()` when supplied via {@link StreamConnectionHandlers}. + */ + hydrate?: (threadId: string) => Promise } /** @@ -1870,10 +1880,46 @@ export function xhrHttpStream( } } +/** + * Optional persistence handlers for the lightweight adapters (`stream()`, + * `rpcStream()`). These are one-shot, request-scoped calls with no built-in + * GET endpoint or second channel, so hydration and run-rejoin only exist if + * the app supplies them — typically thin wrappers over TanStack Start server + * functions backed by `@tanstack/ai-persistence` (`getGenerationHydration`) + * and a delivery-durability log (`memoryStream` / `replayRunStream`). + * + * Each handler is spread onto the returned adapter only when defined, so + * feature detection (`connection.hydrateGeneration` etc.) keeps working. + */ +export interface StreamConnectionHandlers { + /** + * Server-driven chat hydration for `persistence: true`: the stored + * transcript for `threadId` plus a cursor to an in-flight run. + */ + hydrate?: (threadId: string) => Promise + /** + * Server-driven generation hydration for `persistence: true`: the last + * generation's resume snapshot for `threadId` plus a cursor to a run still + * generating. See {@link ConnectConnectionAdapter.hydrateGeneration}. + */ + hydrateGeneration?: (threadId: string) => Promise + /** + * Re-attach to a run still generating and replay it from the start. See + * {@link ConnectConnectionAdapter.joinRun}. + */ + joinRun?: ( + runId: string, + abortSignal?: AbortSignal, + ) => AsyncIterable +} + /** * Create a direct stream connection adapter (for server functions or direct streams) * * @param streamFactory - A function that returns an async iterable of StreamChunks + * @param handlers - Optional persistence handlers (`hydrate`, + * `hydrateGeneration`, `joinRun`) that let server-driven persistence work + * without an HTTP endpoint — each is usually a one-line server-function call * @returns A connection adapter for direct streams * * @example @@ -1882,6 +1928,15 @@ export function xhrHttpStream( * const connection = stream(() => serverFunction({ messages })); * * const client = new ChatClient({ connection }); + * + * // With generation persistence over server functions + * const connection = stream( + * () => generateImageFn({ data: input }), + * { + * hydrateGeneration: (threadId) => getImageHydrationFn({ data: threadId }), + * joinRun: (runId) => joinImageRunFn({ data: runId }), + * }, + * ); * ``` */ export function stream( @@ -1890,6 +1945,7 @@ export function stream( data?: Record, abortSignal?: AbortSignal, ) => AsyncIterable, + handlers?: StreamConnectionHandlers, ): ConnectConnectionAdapter { return { async *connect(messages, data, abortSignal) { @@ -1897,6 +1953,11 @@ export function stream( // Server-side chat() handles conversion to ModelMessages yield* streamFactory(messages, data, abortSignal) }, + ...(handlers?.hydrate ? { hydrate: handlers.hydrate } : {}), + ...(handlers?.hydrateGeneration + ? { hydrateGeneration: handlers.hydrateGeneration } + : {}), + ...(handlers?.joinRun ? { joinRun: handlers.joinRun } : {}), } } @@ -1978,6 +2039,9 @@ async function* abortableIterable( * Create an RPC stream connection adapter (for RPC-based streaming like Cap'n Web RPC) * * @param rpcCall - A function that accepts messages and returns an async iterable of StreamChunks + * @param handlers - Optional persistence handlers (`hydrate`, + * `hydrateGeneration`, `joinRun`) that let server-driven persistence work + * without an HTTP endpoint — each is usually a one-line RPC call * @returns A connection adapter for RPC streams * * @example @@ -1988,6 +2052,15 @@ async function* abortableIterable( * ); * * const client = new ChatClient({ connection }); + * + * // With generation persistence over RPC + * const connection = rpcStream( + * (messages, data) => api.streamMurfResponse(messages, data), + * { + * hydrateGeneration: (threadId) => api.getGenerationHydration(threadId), + * joinRun: (runId) => api.replayRun(runId), + * }, + * ); * ``` */ export function rpcStream( @@ -1996,6 +2069,7 @@ export function rpcStream( data?: Record, abortSignal?: AbortSignal, ) => AsyncIterable, + handlers?: StreamConnectionHandlers, ): ConnectConnectionAdapter { return { async *connect(messages, data, abortSignal) { @@ -2003,5 +2077,10 @@ export function rpcStream( // Server-side chat() handles conversion to ModelMessages yield* rpcCall(messages, data, abortSignal) }, + ...(handlers?.hydrate ? { hydrate: handlers.hydrate } : {}), + ...(handlers?.hydrateGeneration + ? { hydrateGeneration: handlers.hydrateGeneration } + : {}), + ...(handlers?.joinRun ? { joinRun: handlers.joinRun } : {}), } } diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index 5808e2a4a..d1cc1c6d3 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -97,6 +97,15 @@ export class GenerationClient< > { private readonly connection: ConnectConnectionAdapter | undefined private readonly fetcher: GenerationFetcher | undefined + // Persistence handlers supplied as options (e.g. alongside a `fetcher`), used + // when the connection doesn't carry its own — the connection's handlers take + // precedence when both exist. + private readonly hydrateGenerationHandler: + | ConnectConnectionAdapter['hydrateGeneration'] + | undefined + private readonly joinRunHandler: + | ConnectConnectionAdapter['joinRun'] + | undefined private readonly uniqueId: string private readonly devtoolsMetadata: AIDevtoolsClientMetadata private readonly devtoolsBridge: GenerationDevtoolsBridge @@ -147,6 +156,8 @@ export class GenerationClient< this.persistenceScope = options.threadId this.connection = options.connection this.fetcher = options.fetcher + this.hydrateGenerationHandler = options.hydrateGeneration + this.joinRunHandler = options.joinRun this.body = options.body ?? {} // `persistence` is `false`/omitted (ephemeral), `true` (server-driven: cache // nothing, hydrate the last generation for `threadId` on mount), or a storage @@ -194,14 +205,19 @@ export class GenerationClient< // Server-driven (`persistence: true`): the client caches nothing locally and // re-hydrates the last generation for its stable threadId from the server on - // mount. Best-effort and non-blocking; it never auto-starts a run. - if (this.serverDriven && this.connection?.hydrateGeneration) { + // mount. Best-effort and non-blocking; it never auto-starts a run. The + // hydrate handler comes from the connection, or from the `hydrateGeneration` + // option (e.g. a server-function call) when the transport can't carry one. + if ( + this.serverDriven && + (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) + ) { this.hydrateFromServer() } else if (this.serverDriven) { - // `persistence: true` without a hydrate-capable connection can never - // restore anything — warn rather than silently no-op. + // `persistence: true` without any hydrate source can never restore + // anything — warn rather than silently no-op. console.warn( - '[TanStack AI] `persistence: true` (server-driven) needs a connection that implements `hydrateGeneration` (e.g. `fetchServerSentEvents` / `fetchHttpStream`). With a plain `fetcher` or no connection, nothing is persisted or restored.', + '[TanStack AI] `persistence: true` (server-driven) needs a `hydrateGeneration` handler — either a connection that implements one (e.g. `fetchServerSentEvents` / `fetchHttpStream`, or `stream()` / `rpcStream()` with persistence handlers) or the `hydrateGeneration` option. Without one, nothing is persisted or restored.', ) } } @@ -682,6 +698,42 @@ export class GenerationClient< if (restored !== null) this.setResult(restored) } + /** + * Repaint a restored snapshot (client store or server hydrate) and, when it + * reports a run still in flight, tail that run to completion via `joinRun` + * (from the connection, or the `joinRun` option when the transport can't + * carry one). + * + * A `running` snapshot that no `joinRun` handler can tail is repainted as an + * interrupted error instead of a `generating` status that would never + * settle: an interrupted generation cannot be resumed, only re-run. + */ + private repaintRestoredSnapshot( + snapshot: GenerationResumeSnapshot, + activeRunId?: string, + ): void { + if (snapshot.status !== 'running') { + this.repaintFromSnapshot(snapshot) + return + } + const joinRun = this.connection?.joinRun ?? this.joinRunHandler + const runId = activeRunId ?? snapshot.resumeState?.runId + if (runId && joinRun) { + this.repaintFromSnapshot(snapshot) + this.rejoinInFlight(runId) + return + } + this.repaintFromSnapshot({ + ...snapshot, + resumeState: null, + status: 'error', + error: { + message: + 'The previous generation was interrupted before it finished and cannot be resumed — generate again to retry.', + }, + }) + } + /** * Build the restorable result shape from the snapshot and hand it to the * per-activity `reconstructResult` mapper (injected by the specialized @@ -822,11 +874,8 @@ export class GenerationClient< // Live state wins: adopt the stored snapshot only if nothing has been // observed since construction. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return - this.repaintFromSnapshot(snapshot) // If the stored run was still generating, re-attach and finish it in place. - if (snapshot.status === 'running' && snapshot.resumeState?.runId) { - this.rejoinInFlight(snapshot.resumeState.runId) - } + this.repaintRestoredSnapshot(snapshot) } /** @@ -838,7 +887,8 @@ export class GenerationClient< * the client (hydration then backs off, mirroring the chat client). */ private hydrateFromServer(): void { - const hydrate = this.connection?.hydrateGeneration + const hydrate = + this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler if (!hydrate) return // A send that already started owns the client; don't stomp it. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return @@ -855,11 +905,8 @@ export class GenerationClient< // Re-check: a send may have started while the fetch was in flight. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return - this.repaintFromSnapshot(snapshot) // A run still generating on the server: re-attach and finish it in place. - if (res.activeRun?.runId) { - this.rejoinInFlight(res.activeRun.runId) - } + this.repaintRestoredSnapshot(snapshot, res.activeRun?.runId) })() } @@ -871,7 +918,7 @@ export class GenerationClient< * never stomped, and the same run is only rejoined once. */ private rejoinInFlight(runId: string): void { - const joinRun = this.connection?.joinRun + const joinRun = this.connection?.joinRun ?? this.joinRunHandler if (!joinRun) return if (this.rejoinedRunId === runId) return // A fresh send (or an in-progress rejoin) owns the client. diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index 0230d8082..26d968b58 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -68,8 +68,9 @@ export type GenerationResumeStatus = 'idle' | 'running' | 'complete' | 'error' /** * Map a persisted resume status to the client's live state machine on restore: * complete → success, error → error, running → generating, idle → idle. A - * restored `running` presents as `generating` with `isLoading` false (the client - * never auto-tails a restored run). + * restored `running` only reaches this mapping when a `joinRun` handler can + * tail the run to completion; without one the client rewrites the snapshot to + * `error` (interrupted) before repainting, so it never sticks on `generating`. */ export function clientStateFromResumeStatus( status: GenerationResumeStatus, @@ -165,10 +166,9 @@ export type GenerationPersistence = ChatStorageAdapter * - `false` (default) / omitted: ephemeral. Nothing is written; a reload starts * from empty. * - `true`: server-driven. Nothing is cached in the browser. On mount the client - * hydrates the last generation for its `threadId` from the server (via the - * connection's `hydrateGeneration` handler) and repaints that snapshot — it - * never auto-starts a run. Requires a connection that implements - * `hydrateGeneration`. + * hydrates the last generation for its `threadId` from the server (via a + * `hydrateGeneration` handler — from the connection, or supplied as an + * option) and repaints that snapshot — it never auto-starts a run. * - a {@link GenerationPersistence} adapter: client-driven. The lightweight * resume snapshot is cached in the browser under `generation:` as a * run streams and read back (validated) on mount. @@ -334,9 +334,9 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { * * - Omit or `false`: ephemeral, in-memory only. * - `true`: server-driven. The client caches nothing and, on mount, hydrates - * the last generation for its `threadId` from the server (needs a connection - * with a `hydrateGeneration` handler). It repaints the snapshot but never - * auto-starts a run. + * the last generation for its `threadId` from the server (needs a + * `hydrateGeneration` handler — from the connection, or the option below) + * and repaints that snapshot. It never auto-starts a run. * - a {@link GenerationPersistence} adapter (any {@link ChatStorageAdapter}, * including the shared `localStoragePersistence` / `sessionStoragePersistence` * / `indexedDBPersistence` factories): client-driven. The client writes the @@ -346,6 +346,30 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { */ persistence?: GenerationPersistenceOption + /** + * Server-driven hydration handler, for transports that don't carry one on + * the connection: supply it alongside `fetcher` (or a `stream()` / + * `rpcStream()` connection built without handlers) so `persistence: true` + * can restore the last generation for `threadId` on mount. Typically a + * one-line TanStack Start server-function call backed by + * `getGenerationHydration` from `@tanstack/ai-persistence`. + * + * A connection's own `hydrateGeneration` takes precedence when both exist. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + + /** + * Re-attach handler for a run that is still generating, for transports that + * don't carry one on the connection. The client tails this on mount when a + * restored/hydrated snapshot reports a run in flight, replaying it to + * completion in place. Without it, a restored `running` snapshot surfaces + * as an (interrupted) error — an interrupted generation cannot be resumed, + * only re-run. + * + * A connection's own `joinRun` takes precedence when both exist. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] + /** * Factory that constructs the devtools bridge. Default is a no-op * factory; the real implementation lives in `@tanstack/ai-client/devtools`. diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index f82ff2afb..03915fdcf 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -168,6 +168,7 @@ export { type ReconnectOptions, type ResumableConnectConnectionAdapter, type RunAgentInputContext, + type StreamConnectionHandlers, type SubscribeConnectionAdapter, type XhrConnectionOptions, } from './connection-adapters' diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 2c6e3e5c5..530e22b0f 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -97,6 +97,15 @@ interface VideoCallbacks { */ export class VideoGenerationClient { private readonly connection: ConnectConnectionAdapter | undefined + // Persistence handlers supplied as options (e.g. alongside a `fetcher`), used + // when the connection doesn't carry its own — the connection's handlers take + // precedence when both exist. + private readonly hydrateGenerationHandler: + | ConnectConnectionAdapter['hydrateGeneration'] + | undefined + private readonly joinRunHandler: + | ConnectConnectionAdapter['joinRun'] + | undefined private readonly fetcher: | GenerationFetcher | undefined @@ -145,6 +154,8 @@ export class VideoGenerationClient { this.threadId = options.threadId ?? this.uniqueId this.connection = options.connection this.fetcher = options.fetcher + this.hydrateGenerationHandler = options.hydrateGeneration + this.joinRunHandler = options.joinRun this.body = options.body ?? {} // `persistence` is `false`/omitted (ephemeral), `true` (server-driven: cache // nothing, hydrate the last generation for `threadId` on mount), or a storage @@ -186,14 +197,19 @@ export class VideoGenerationClient { // Server-driven (`persistence: true`): the client caches nothing locally and // re-hydrates the last generation for its stable threadId from the server on - // mount. Best-effort and non-blocking; it never auto-starts a run. - if (this.serverDriven && this.connection?.hydrateGeneration) { + // mount. Best-effort and non-blocking; it never auto-starts a run. The + // hydrate handler comes from the connection, or from the `hydrateGeneration` + // option (e.g. a server-function call) when the transport can't carry one. + if ( + this.serverDriven && + (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) + ) { this.hydrateFromServer() } else if (this.serverDriven) { - // `persistence: true` without a hydrate-capable connection can never - // restore anything — warn rather than silently no-op. + // `persistence: true` without any hydrate source can never restore + // anything — warn rather than silently no-op. console.warn( - '[TanStack AI] `persistence: true` (server-driven) needs a connection that implements `hydrateGeneration` (e.g. `fetchServerSentEvents` / `fetchHttpStream`). With a plain `fetcher` or no connection, nothing is persisted or restored.', + '[TanStack AI] `persistence: true` (server-driven) needs a `hydrateGeneration` handler — either a connection that implements one (e.g. `fetchServerSentEvents` / `fetchHttpStream`, or `stream()` / `rpcStream()` with persistence handlers) or the `hydrateGeneration` option. Without one, nothing is persisted or restored.', ) } } @@ -728,6 +744,42 @@ export class VideoGenerationClient { if (restored !== null) this.setResult(restored) } + /** + * Repaint a restored snapshot (client store or server hydrate) and, when it + * reports a run still in flight, tail that run to completion via `joinRun` + * (from the connection, or the `joinRun` option when the transport can't + * carry one). + * + * A `running` snapshot that no `joinRun` handler can tail is repainted as an + * interrupted error instead of a `generating` status that would never + * settle: an interrupted generation cannot be resumed, only re-run. + */ + private repaintRestoredSnapshot( + snapshot: GenerationResumeSnapshot, + activeRunId?: string, + ): void { + if (snapshot.status !== 'running') { + this.repaintFromSnapshot(snapshot) + return + } + const joinRun = this.connection?.joinRun ?? this.joinRunHandler + const runId = activeRunId ?? snapshot.resumeState?.runId + if (runId && joinRun) { + this.repaintFromSnapshot(snapshot) + this.rejoinInFlight(runId) + return + } + this.repaintFromSnapshot({ + ...snapshot, + resumeState: null, + status: 'error', + error: { + message: + 'The previous generation was interrupted before it finished and cannot be resumed — generate again to retry.', + }, + }) + } + /** * Rebuild a `VideoGenerateResult` from a restored snapshot: the video's bytes * are served from the durable artifact URL, so the restored result renders @@ -853,10 +905,8 @@ export class VideoGenerationClient { // Live state wins: adopt the stored snapshot only if nothing has been // observed since construction. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return - this.repaintFromSnapshot(snapshot) - if (snapshot.status === 'running' && snapshot.resumeState?.runId) { - this.rejoinInFlight(snapshot.resumeState.runId) - } + // If the stored run was still generating, re-attach and finish it in place. + this.repaintRestoredSnapshot(snapshot) } /** @@ -868,7 +918,8 @@ export class VideoGenerationClient { * the client (hydration then backs off, mirroring the chat client). */ private hydrateFromServer(): void { - const hydrate = this.connection?.hydrateGeneration + const hydrate = + this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler if (!hydrate) return // A send that already started owns the client; don't stomp it. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return @@ -885,10 +936,8 @@ export class VideoGenerationClient { // Re-check: a send may have started while the fetch was in flight. if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return - this.repaintFromSnapshot(snapshot) - if (res.activeRun?.runId) { - this.rejoinInFlight(res.activeRun.runId) - } + // A run still generating on the server: re-attach and finish it in place. + this.repaintRestoredSnapshot(snapshot, res.activeRun?.runId) })() } @@ -899,7 +948,7 @@ export class VideoGenerationClient { * `generate()` owns the client and is never stomped; a run is rejoined once. */ private rejoinInFlight(runId: string): void { - const joinRun = this.connection?.joinRun + const joinRun = this.connection?.joinRun ?? this.joinRunHandler if (!joinRun) return if (this.rejoinedRunId === runId) return if (this.isLoading || this.abortController) return diff --git a/packages/ai-client/tests/connection-adapters.test.ts b/packages/ai-client/tests/connection-adapters.test.ts index 5ec8fbd39..021ac81ca 100644 --- a/packages/ai-client/tests/connection-adapters.test.ts +++ b/packages/ai-client/tests/connection-adapters.test.ts @@ -1040,6 +1040,33 @@ describe('connection-adapters', () => { undefined, ) }) + + it('should spread supplied persistence handlers onto the adapter', () => { + const streamFactory = vi.fn().mockImplementation(function* () {}) + const hydrate = vi.fn() + const hydrateGeneration = vi.fn() + const joinRun = vi.fn() + + const adapter = stream(streamFactory, { + hydrate, + hydrateGeneration, + joinRun, + }) + + expect(adapter.hydrate).toBe(hydrate) + expect(adapter.hydrateGeneration).toBe(hydrateGeneration) + expect(adapter.joinRun).toBe(joinRun) + }) + + it('should omit persistence handlers that are not supplied', () => { + const streamFactory = vi.fn().mockImplementation(function* () {}) + + const adapter = stream(streamFactory) + + expect(adapter.hydrate).toBeUndefined() + expect(adapter.hydrateGeneration).toBeUndefined() + expect(adapter.joinRun).toBeUndefined() + }) }) describe('normalizeConnectionAdapter', () => { @@ -1241,5 +1268,32 @@ describe('connection-adapters', () => { undefined, ) }) + + it('should spread supplied persistence handlers onto the adapter', () => { + const rpcCall = vi.fn().mockImplementation(function* () {}) + const hydrate = vi.fn() + const hydrateGeneration = vi.fn() + const joinRun = vi.fn() + + const adapter = rpcStream(rpcCall, { + hydrate, + hydrateGeneration, + joinRun, + }) + + expect(adapter.hydrate).toBe(hydrate) + expect(adapter.hydrateGeneration).toBe(hydrateGeneration) + expect(adapter.joinRun).toBe(joinRun) + }) + + it('should omit persistence handlers that are not supplied', () => { + const rpcCall = vi.fn().mockImplementation(function* () {}) + + const adapter = rpcStream(rpcCall) + + expect(adapter.hydrate).toBeUndefined() + expect(adapter.hydrateGeneration).toBeUndefined() + expect(adapter.joinRun).toBeUndefined() + }) }) }) diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index 3a986685a..fb41c13e8 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -1623,18 +1623,35 @@ describe('GenerationClient', () => { }) it('emits onResumeStateChange with the in-flight identity from a stored running snapshot', async () => { + const runId = 'run-run' const snapshot: GenerationResumeSnapshot = { schemaVersion: 1, - resumeState: { threadId: 'thread-run', runId: 'run-run' }, + resumeState: { threadId: 'thread-run', runId }, status: 'running', } const { persistence } = createMapPersistence({ 'generation:running': snapshot, }) const onResumeStateChange = vi.fn() + // A `joinRun` handler makes the stored run rejoinable: the in-flight + // identity repaints and the client tails the run to completion. + const joinRun = vi.fn(async function* () { + yield { + type: EventType.RUN_STARTED, + runId, + threadId: 'thread-run', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED, + runId, + threadId: 'thread-run', + timestamp: Date.now(), + } satisfies StreamChunk + }) const client = new GenerationClient({ id: 'running', - connection: createMockConnection([]), + connection: { async *connect() {}, joinRun }, persistence, onResumeStateChange, }) @@ -1645,8 +1662,87 @@ describe('GenerationClient', () => { runId: 'run-run', }) }) - // A restored running run presents as `generating` but never auto-tails. - expect(client.getStatus()).toBe('generating') + expect(joinRun).toHaveBeenCalledWith(runId, expect.anything()) + await waitForCondition(() => { + expect(client.getStatus()).toBe('success') + }) + expect(client.getIsLoading()).toBe(false) + }) + + it('repaints a stored running snapshot with no joinRun available as an interrupted error instead of sticking on generating', async () => { + const snapshot: GenerationResumeSnapshot = { + schemaVersion: 1, + resumeState: { threadId: 'thread-run', runId: 'run-run' }, + status: 'running', + } + const { persistence } = createMapPersistence({ + 'generation:running': snapshot, + }) + const onResumeStateChange = vi.fn() + const client = new GenerationClient({ + id: 'running', + connection: createMockConnection([]), + persistence, + onResumeStateChange, + }) + + // An interrupted generation cannot be resumed, only re-run: without a + // `joinRun` handler the restored run surfaces as an error, never a + // `generating` status that would never settle. + await waitForCondition(() => { + expect(client.getStatus()).toBe('error') + }) + expect(client.getIsLoading()).toBe(false) + expect(client.getError()?.message).toMatch(/interrupted/) + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'error', + resumeState: null, + }) + expect(onResumeStateChange).toHaveBeenLastCalledWith(null) + }) + + it('rejoins a stored running snapshot via the joinRun option in fetcher mode', async () => { + const runId = 'run-stored' + const { persistence } = createMapPersistence({ + 'generation:thread-stored': { + schemaVersion: 1, + resumeState: { threadId: 'thread-stored', runId }, + status: 'running', + }, + }) + const joinRun = vi.fn(async function* () { + yield { + type: EventType.RUN_STARTED, + runId, + threadId: 'thread-stored', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.CUSTOM, + name: 'generation:result', + value: { id: 'stored-img' }, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED, + runId, + threadId: 'thread-stored', + timestamp: Date.now(), + } satisfies StreamChunk + }) + + const client = new GenerationClient<{ prompt: string }, unknown>({ + threadId: 'thread-stored', + fetcher: async () => ({}), + persistence, + joinRun, + }) + + await waitForCondition(() => { + expect(client.getStatus()).toBe('success') + }) + expect(joinRun).toHaveBeenCalledWith(runId, expect.anything()) + expect(client.getResult()).toMatchObject({ id: 'stored-img' }) expect(client.getIsLoading()).toBe(false) }) @@ -2175,5 +2271,127 @@ describe('GenerationClient', () => { }) expect(hydrateGeneration).toHaveBeenCalledWith('thread-video') }) + + it('hydrates via the hydrateGeneration option in fetcher mode (no connection)', async () => { + const hydrateGeneration = vi.fn(async () => completedHydration) + const client = new GenerationClient<{ prompt: string }, unknown>({ + threadId: 'thread-server', + fetcher: async () => ({}), + persistence: true, + hydrateGeneration, + }) + + await waitForCondition(() => { + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'complete', + result: { id: 'result-1' }, + }) + }) + expect(hydrateGeneration).toHaveBeenCalledWith('thread-server') + }) + + it('rejoins an in-flight run via the joinRun option when the connection lacks one', async () => { + const runId = 'run-option-1' + const joinRun = vi.fn(async function* () { + yield { + type: EventType.RUN_STARTED, + runId, + threadId: 'thread-option', + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.CUSTOM, + name: 'generation:result', + value: { id: 'option-img' }, + timestamp: Date.now(), + } satisfies StreamChunk + yield { + type: EventType.RUN_FINISHED, + runId, + threadId: 'thread-option', + timestamp: Date.now(), + } satisfies StreamChunk + }) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-option', runId }, + status: 'running' as const, + }, + activeRun: { runId }, + })) + const client = new GenerationClient({ + threadId: 'thread-option', + connection: createMockConnection([]), + persistence: true, + hydrateGeneration, + joinRun, + }) + + await waitForCondition(() => { + expect(client.getStatus()).toBe('success') + expect(client.getResult()).toMatchObject({ id: 'option-img' }) + }) + expect(joinRun).toHaveBeenCalledWith(runId, expect.anything()) + expect(client.getIsLoading()).toBe(false) + }) + + it('warns only when persistence: true has no hydrate source at all', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Fetcher with no handlers anywhere: warns. + new GenerationClient<{ prompt: string }, unknown>({ + threadId: 'thread-warn', + fetcher: async () => ({}), + persistence: true, + }) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('`persistence: true`'), + ) + warn.mockClear() + + // The hydrateGeneration option counts as a hydrate source: no warning. + new GenerationClient<{ prompt: string }, unknown>({ + threadId: 'thread-warn', + fetcher: async () => ({}), + persistence: true, + hydrateGeneration: vi.fn(async () => ({ + resumeSnapshot: null, + activeRun: null, + })), + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('`persistence: true`'), + ) + warn.mockRestore() + }) + + it('repaints a running server snapshot with no joinRun available as an interrupted error', async () => { + const runId = 'run-stranded' + const { connection } = createHydratingConnection({ + resumeSnapshot: { + schemaVersion: 1, + resumeState: { threadId: 'thread-stranded', runId }, + status: 'running', + }, + activeRun: { runId }, + }) + const client = new GenerationClient({ + threadId: 'thread-stranded', + connection, + persistence: true, + }) + + await waitForCondition(() => { + expect(client.getStatus()).toBe('error') + }) + expect(client.getIsLoading()).toBe(false) + expect(client.getError()?.message).toMatch(/interrupted/) + expect(client.getResumeSnapshot()).toMatchObject({ + status: 'error', + resumeState: null, + }) + }) }) }) diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index ce2579aa0..8a1d56b15 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -71,10 +71,14 @@ export { reconstructChat } from './reconstruct' export type { ReconstructChatOptions } from './reconstruct' // Server helper: rehydrate the last generation job for a client load -export { reconstructGeneration } from './reconstruct-generation' +export { + reconstructGeneration, + getGenerationHydration, +} from './reconstruct-generation' export type { ReconstructedGeneration, ReconstructGenerationOptions, + GetGenerationHydrationOptions, } from './reconstruct-generation' // Server helpers: retrieve a persisted generation artifact + its bytes diff --git a/packages/ai-persistence/src/reconstruct-generation.ts b/packages/ai-persistence/src/reconstruct-generation.ts index b47c2d8de..b3e6fa864 100644 --- a/packages/ai-persistence/src/reconstruct-generation.ts +++ b/packages/ai-persistence/src/reconstruct-generation.ts @@ -99,6 +99,74 @@ function jsonResponse(body: ReconstructedGeneration): Response { }) } +export interface GetGenerationHydrationOptions { + /** + * How to interpret `id`: + * - `'runId'` loads exactly that run via `stores.generationRuns.get`. + * - `'threadId'` (default) loads the latest run linked to the thread via + * `stores.generationRuns.findLatestForThread`. + */ + by?: 'threadId' | 'runId' +} + +/** + * The request-free core of {@link reconstructGeneration}: read the last + * generation run for a thread (or a specific run) straight from the + * `generationRuns` store and return the plain `{ resumeSnapshot, activeRun }` + * hydration payload a server-authoritative client adopts on mount. + * + * Use this from a TanStack Start server function (or any direct call) to back + * a client's `hydrateGeneration` handler without fabricating a `Request`: + * + * ```ts + * export const getImageHydrationFn = createServerFn({ method: 'GET' }) + * .inputValidator((threadId: string) => threadId) + * .handler(async ({ data: threadId }) => { + * // Do your own auth here — this helper does not enforce tenancy. + * return getGenerationHydration(persistence, threadId) + * }) + * ``` + * + * ⚠️ Unlike {@link reconstructGeneration} this helper takes **no** `authorize` + * option — there is no `Request` to authorize against. Server-function callers + * must gate the call themselves (session → owned thread/run) before resolving + * the id, or any caller who guesses an id receives the run's status and result + * metadata. + * + * Returns `{ resumeSnapshot: null, activeRun: null }` when `id` is empty or no + * matching run exists, so the caller never has to special-case a first load. + */ +export async function getGenerationHydration( + persistence: AIPersistence, + id: string, + options?: GetGenerationHydrationOptions, +): Promise { + validateReconstructGenerationStores(persistence) + const runStore = persistence.stores.generationRuns + if (!runStore) { + // validateReconstructGenerationStores already throws; this narrows for TS. + throw new Error('getGenerationHydration requires stores.generationRuns.') + } + + if (!id) { + return { resumeSnapshot: null, activeRun: null } + } + + const run = + options?.by === 'runId' + ? await runStore.get(id) + : await runStore.findLatestForThread(id) + + if (!run) { + return { resumeSnapshot: null, activeRun: null } + } + + return { + resumeSnapshot: runToSnapshot(run), + activeRun: run.status === 'running' ? { runId: run.runId } : null, + } +} + /** * Build the JSON `Response` a server-authoritative client hydrates a generation * from on load. Reads a `?runId=` (preferred) or `?threadId=` from the request @@ -136,13 +204,6 @@ export async function reconstructGeneration( request: Request, options?: ReconstructGenerationOptions, ): Promise { - validateReconstructGenerationStores(persistence) - const runStore = persistence.stores.generationRuns - if (!runStore) { - // validateReconstructGenerationStores already throws; this narrows for TS. - throw new Error('reconstructGeneration requires stores.generationRuns.') - } - const params = new URL(request.url).searchParams const runParam = options?.runParam ?? 'runId' const threadParam = options?.param ?? 'threadId' @@ -170,16 +231,9 @@ export async function reconstructGeneration( } } - const run = runId - ? await runStore.get(runId) - : await runStore.findLatestForThread(threadId) - - if (!run) { - return jsonResponse({ resumeSnapshot: null, activeRun: null }) - } - - return jsonResponse({ - resumeSnapshot: runToSnapshot(run), - activeRun: run.status === 'running' ? { runId: run.runId } : null, - }) + return jsonResponse( + await getGenerationHydration(persistence, id, { + by: runId ? 'runId' : 'threadId', + }), + ) } diff --git a/packages/ai-persistence/tests/reconstruct-generation.test.ts b/packages/ai-persistence/tests/reconstruct-generation.test.ts index 84f2f4070..3b8f4ef34 100644 --- a/packages/ai-persistence/tests/reconstruct-generation.test.ts +++ b/packages/ai-persistence/tests/reconstruct-generation.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' import { memoryPersistence } from '../src/memory' -import { reconstructGeneration } from '../src/reconstruct-generation' +import { + getGenerationHydration, + reconstructGeneration, +} from '../src/reconstruct-generation' import type { ReconstructedGeneration } from '../src/reconstruct-generation' async function body(response: Response): Promise { @@ -136,3 +139,84 @@ describe('reconstructGeneration', () => { expect(await response.json()).toEqual({ error: 'Forbidden' }) }) }) + +describe('getGenerationHydration', () => { + it('resolves the latest run for a thread id', async () => { + const persistence = memoryPersistence() + await persistence.stores.generationRuns.createOrResume({ + runId: 'job-old', + threadId: 'thread-1', + activity: 'image', + provider: 'p', + model: 'm', + startedAt: 1000, + }) + await persistence.stores.generationRuns.update('job-old', { + status: 'complete', + finishedAt: 1500, + result: { id: 'old-result' }, + }) + await persistence.stores.generationRuns.createOrResume({ + runId: 'job-new', + threadId: 'thread-1', + activity: 'image', + provider: 'p', + model: 'm', + startedAt: 2000, + }) + await persistence.stores.generationRuns.update('job-new', { + status: 'complete', + finishedAt: 2500, + result: { id: 'new-result' }, + }) + + const hydration = await getGenerationHydration(persistence, 'thread-1') + expect(hydration).toEqual({ + resumeSnapshot: { + schemaVersion: 1, + resumeState: null, + status: 'complete', + result: { id: 'new-result' }, + activity: 'image', + }, + activeRun: null, + }) + }) + + it('resolves a specific run by run id, with an active-run cursor while running', async () => { + const persistence = memoryPersistence() + await persistence.stores.generationRuns.createOrResume({ + runId: 'job-live', + threadId: 'thread-live', + activity: 'video', + provider: 'p', + model: 'm', + startedAt: 5000, + }) + + const hydration = await getGenerationHydration(persistence, 'job-live', { + by: 'runId', + }) + expect(hydration.activeRun).toEqual({ runId: 'job-live' }) + expect(hydration.resumeSnapshot).toMatchObject({ + status: 'running', + resumeState: { runId: 'job-live', threadId: 'thread-live' }, + }) + }) + + it('returns nulls for an empty id or no matching run', async () => { + const persistence = memoryPersistence() + + await expect(getGenerationHydration(persistence, '')).resolves.toEqual({ + resumeSnapshot: null, + activeRun: null, + }) + await expect(getGenerationHydration(persistence, 'nope')).resolves.toEqual({ + resumeSnapshot: null, + activeRun: null, + }) + await expect( + getGenerationHydration(persistence, 'nope', { by: 'runId' }), + ).resolves.toEqual({ resumeSnapshot: null, activeRun: null }) + }) +}) diff --git a/packages/ai-react/src/use-generate-audio.ts b/packages/ai-react/src/use-generate-audio.ts index 41423adc0..4fc1e01bc 100644 --- a/packages/ai-react/src/use-generate-audio.ts +++ b/packages/ai-react/src/use-generate-audio.ts @@ -58,6 +58,20 @@ export interface UseGenerateAudioOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when audio is generated. Can optionally return a transformed value. * diff --git a/packages/ai-react/src/use-generate-image.ts b/packages/ai-react/src/use-generate-image.ts index 5c0788e2b..e50bc8d76 100644 --- a/packages/ai-react/src/use-generate-image.ts +++ b/packages/ai-react/src/use-generate-image.ts @@ -58,6 +58,20 @@ export interface UseGenerateImageOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when images are generated. Can optionally return a transformed value. * diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index c897f3a6c..dcf510e8e 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -57,6 +57,20 @@ export interface UseGenerateSpeechOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when speech is generated. Can optionally return a transformed value. * diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index 94f84c01a..9a940d21f 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -59,6 +59,20 @@ export interface UseGenerateVideoOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when video generation completes. Can optionally return a transformed value. * @@ -188,6 +202,10 @@ export function useGenerateVideo( ...(opts.initialResumeSnapshot !== undefined && { initialResumeSnapshot: opts.initialResumeSnapshot, }), + ...(opts.hydrateGeneration !== undefined && { + hydrateGeneration: opts.hydrateGeneration, + }), + ...(opts.joinRun !== undefined && { joinRun: opts.joinRun }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...opts.devtools, diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 79b219331..ed8980f60 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -64,6 +64,20 @@ export interface UseGenerationOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when a result is received. Can optionally return a transformed value. * @@ -185,6 +199,10 @@ export function useGeneration< ...(opts.initialResumeSnapshot !== undefined && { initialResumeSnapshot: opts.initialResumeSnapshot, }), + ...(opts.hydrateGeneration !== undefined && { + hydrateGeneration: opts.hydrateGeneration, + }), + ...(opts.joinRun !== undefined && { joinRun: opts.joinRun }), ...(opts.reconstructResult ? { reconstructResult: opts.reconstructResult } : {}), diff --git a/packages/ai-react/src/use-summarize.ts b/packages/ai-react/src/use-summarize.ts index d8b56ee74..2fd4dfe3f 100644 --- a/packages/ai-react/src/use-summarize.ts +++ b/packages/ai-react/src/use-summarize.ts @@ -58,6 +58,20 @@ export interface UseSummarizeOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when summarization is complete. Can optionally return a transformed value. * diff --git a/packages/ai-react/src/use-transcription.ts b/packages/ai-react/src/use-transcription.ts index aae548bfc..4513ad94e 100644 --- a/packages/ai-react/src/use-transcription.ts +++ b/packages/ai-react/src/use-transcription.ts @@ -58,6 +58,20 @@ export interface UseTranscriptionOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when transcription is complete. Can optionally return a transformed value. * diff --git a/packages/ai-react/tests/use-generation.test.ts b/packages/ai-react/tests/use-generation.test.ts index c661624f5..02c6f0c68 100644 --- a/packages/ai-react/tests/use-generation.test.ts +++ b/packages/ai-react/tests/use-generation.test.ts @@ -453,7 +453,7 @@ describe('useGeneration', () => { expect(result.current.resumeState).toBeNull() }) - it('repaints resumeState from a stored running snapshot on mount', async () => { + it('repaints a stored running snapshot with no joinRun as an interrupted error on mount', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createGenerationChunks({ id: '1' }), ) @@ -476,13 +476,13 @@ describe('useGeneration', () => { ) await waitFor(() => { - expect(result.current.resumeState).toEqual({ - threadId: 'thread-resume', - runId: 'run-resume', - }) + expect(result.current.error?.message).toMatch(/interrupted/) }) - // A restored running run presents as `generating` but never auto-tails. - expect(result.current.status).toBe('generating') + // Without a `joinRun` handler the restored run cannot be tailed, so it + // surfaces as an interrupted error instead of a `generating` status + // that would never settle. + expect(result.current.resumeState).toBeNull() + expect(result.current.status).toBe('error') expect(result.current.isLoading).toBe(false) expect(connect).not.toHaveBeenCalled() }) diff --git a/packages/ai-solid/src/use-generate-audio.ts b/packages/ai-solid/src/use-generate-audio.ts index 0e99c4cdc..19ca902b0 100644 --- a/packages/ai-solid/src/use-generate-audio.ts +++ b/packages/ai-solid/src/use-generate-audio.ts @@ -25,7 +25,11 @@ export interface UseGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-image.ts b/packages/ai-solid/src/use-generate-image.ts index 03b310693..0b3cb3a57 100644 --- a/packages/ai-solid/src/use-generate-image.ts +++ b/packages/ai-solid/src/use-generate-image.ts @@ -25,7 +25,11 @@ export interface UseGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-speech.ts b/packages/ai-solid/src/use-generate-speech.ts index 0e77f5f8a..2da1f17b1 100644 --- a/packages/ai-solid/src/use-generate-speech.ts +++ b/packages/ai-solid/src/use-generate-speech.ts @@ -22,7 +22,11 @@ import type { Accessor } from 'solid-js' */ export interface UseGenerateSpeechOptions extends Pick< UseGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index e2aa33f90..35627d33c 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -69,6 +69,20 @@ export interface UseGenerateVideoOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when video generation completes. Can optionally return a transformed value. * @@ -200,6 +214,10 @@ export function useGenerateVideo( ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...options.devtools, diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index 8bd066bda..aac09c5c3 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -72,6 +72,20 @@ export interface UseGenerationOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when a result is received. Can optionally return a transformed value. * @@ -196,6 +210,10 @@ export function useGeneration< ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), ...(options.reconstructResult ? { reconstructResult: options.reconstructResult } : {}), diff --git a/packages/ai-solid/src/use-summarize.ts b/packages/ai-solid/src/use-summarize.ts index cf2567244..afd140858 100644 --- a/packages/ai-solid/src/use-summarize.ts +++ b/packages/ai-solid/src/use-summarize.ts @@ -25,7 +25,11 @@ export interface UseSummarizeOptions< TOutput = SummarizationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/src/use-transcription.ts b/packages/ai-solid/src/use-transcription.ts index e4e9be7fc..8ef788359 100644 --- a/packages/ai-solid/src/use-transcription.ts +++ b/packages/ai-solid/src/use-transcription.ts @@ -29,7 +29,11 @@ export interface UseTranscriptionOptions< TranscriptionResult, TOutput >, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-solid/tests/use-generation.test.ts b/packages/ai-solid/tests/use-generation.test.ts index 47c8c277e..26b0c69cb 100644 --- a/packages/ai-solid/tests/use-generation.test.ts +++ b/packages/ai-solid/tests/use-generation.test.ts @@ -1297,7 +1297,7 @@ describe('resume snapshot persistence', () => { expect(result.resumeState()).toBeNull() }) - it('repaints resumeState from a stored running snapshot on mount', async () => { + it('repaints a stored running snapshot with no joinRun as an interrupted error on mount', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createGenerationChunks({ id: '1' }), ) @@ -1321,12 +1321,12 @@ describe('resume snapshot persistence', () => { await flush() - expect(result.resumeState()).toEqual({ - threadId: 'thread-resume', - runId: 'run-resume', - }) - // A restored running run presents as `generating` but never auto-tails. - expect(result.status()).toBe('generating') + // Without a `joinRun` handler the restored run cannot be tailed, so it + // surfaces as an interrupted error instead of a `generating` status + // that would never settle. + expect(result.error()?.message).toMatch(/interrupted/) + expect(result.resumeState()).toBeNull() + expect(result.status()).toBe('error') expect(result.isLoading()).toBe(false) expect(connect).not.toHaveBeenCalled() }) @@ -1434,7 +1434,7 @@ describe('resume snapshot persistence', () => { expect(result.resumeState()).toBeNull() }) - it('repaints resumeState for useGenerateVideo from a stored running snapshot', async () => { + it('repaints a stored running snapshot for useGenerateVideo with no joinRun as an interrupted error', async () => { const stored: GenerationResumeSnapshot = { schemaVersion: 1, resumeState: { threadId: 'thread-stored', runId: 'run-stored' }, @@ -1459,9 +1459,12 @@ describe('resume snapshot persistence', () => { expect(persistence.getItem).toHaveBeenCalledWith( 'generation:video-hydrate-me', ) - expect(result.resumeState()).toEqual(stored.resumeState) - // A restored running run presents as `generating` but never auto-tails. - expect(result.status()).toBe('generating') + // Without a `joinRun` handler the restored run cannot be tailed, so it + // surfaces as an interrupted error instead of a `generating` status + // that would never settle. + expect(result.error()?.message).toMatch(/interrupted/) + expect(result.resumeState()).toBeNull() + expect(result.status()).toBe('error') expect(result.isLoading()).toBe(false) }) diff --git a/packages/ai-svelte/src/create-generate-audio.svelte.ts b/packages/ai-svelte/src/create-generate-audio.svelte.ts index d58dcccf3..b7334378f 100644 --- a/packages/ai-svelte/src/create-generate-audio.svelte.ts +++ b/packages/ai-svelte/src/create-generate-audio.svelte.ts @@ -24,7 +24,11 @@ export interface CreateGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< CreateGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-image.svelte.ts b/packages/ai-svelte/src/create-generate-image.svelte.ts index e151b519b..134ad1ca8 100644 --- a/packages/ai-svelte/src/create-generate-image.svelte.ts +++ b/packages/ai-svelte/src/create-generate-image.svelte.ts @@ -24,7 +24,11 @@ export interface CreateGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< CreateGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-speech.svelte.ts b/packages/ai-svelte/src/create-generate-speech.svelte.ts index bb405de01..56b540a49 100644 --- a/packages/ai-svelte/src/create-generate-speech.svelte.ts +++ b/packages/ai-svelte/src/create-generate-speech.svelte.ts @@ -21,7 +21,11 @@ import type { */ export interface CreateGenerateSpeechOptions extends Pick< CreateGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index 433667374..0034de506 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -60,6 +60,20 @@ export interface CreateGenerateVideoOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when video generation completes. Can optionally return a transformed value. * @@ -189,6 +203,10 @@ export function createGenerateVideo( ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...options.devtools, diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index 208cf3f02..04aeed534 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -63,6 +63,20 @@ export interface CreateGenerationOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when a result is received. Can optionally return a transformed value. * @@ -198,6 +212,10 @@ export function createGeneration< ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), ...(options.reconstructResult ? { reconstructResult: options.reconstructResult } : {}), diff --git a/packages/ai-svelte/src/create-summarize.svelte.ts b/packages/ai-svelte/src/create-summarize.svelte.ts index 1cd112804..c33c43ce8 100644 --- a/packages/ai-svelte/src/create-summarize.svelte.ts +++ b/packages/ai-svelte/src/create-summarize.svelte.ts @@ -24,7 +24,11 @@ export interface CreateSummarizeOptions< TOutput = SummarizationResult, > extends Pick< CreateGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/src/create-transcription.svelte.ts b/packages/ai-svelte/src/create-transcription.svelte.ts index 6d3314ad2..ee6242b0f 100644 --- a/packages/ai-svelte/src/create-transcription.svelte.ts +++ b/packages/ai-svelte/src/create-transcription.svelte.ts @@ -28,7 +28,11 @@ export interface CreateTranscriptionOptions< TranscriptionResult, TOutput >, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-svelte/tests/create-generation.test.ts b/packages/ai-svelte/tests/create-generation.test.ts index 9b2b717c2..966762346 100644 --- a/packages/ai-svelte/tests/create-generation.test.ts +++ b/packages/ai-svelte/tests/create-generation.test.ts @@ -279,7 +279,7 @@ describe('createGeneration', () => { }) describe('resume snapshot persistence', () => { - it('repaints resumeState from a stored running snapshot on creation', async () => { + it('repaints a stored running snapshot with no joinRun as an interrupted error on creation', async () => { const persistence = createMapPersistence({ 'generation:hydrate-me': { schemaVersion: 1, @@ -301,12 +301,12 @@ describe('createGeneration', () => { expect(persistence.getItem).toHaveBeenCalledTimes(1) expect(persistence.getItem).toHaveBeenCalledWith('generation:hydrate-me') - // A restored running run presents as `generating` but never auto-tails. - expect(gen.resumeState).toEqual({ - threadId: 'thread-stored', - runId: 'run-stored', - }) - expect(gen.status).toBe('generating') + // Without a `joinRun` handler the restored run cannot be tailed, so it + // surfaces as an interrupted error instead of a `generating` status + // that would never settle. + expect(gen.error?.message).toMatch(/interrupted/) + expect(gen.resumeState).toBeNull() + expect(gen.status).toBe('error') expect(gen.isLoading).toBe(false) }) @@ -353,10 +353,10 @@ describe('createGeneration', () => { }) await flushAsync() - expect(gen.resumeState).toEqual({ - threadId: 'thread-stored', - runId: 'run-stored', - }) + // The restored running snapshot has no `joinRun` handler, so it + // surfaces as an interrupted error. + expect(gen.error?.message).toMatch(/interrupted/) + expect(gen.resumeState).toBeNull() gen.reset() diff --git a/packages/ai-vue/src/use-generate-audio.ts b/packages/ai-vue/src/use-generate-audio.ts index 433cc9597..1161482f5 100644 --- a/packages/ai-vue/src/use-generate-audio.ts +++ b/packages/ai-vue/src/use-generate-audio.ts @@ -25,7 +25,11 @@ export interface UseGenerateAudioOptions< TOutput = AudioGenerationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-image.ts b/packages/ai-vue/src/use-generate-image.ts index f2177a1c3..c617ea544 100644 --- a/packages/ai-vue/src/use-generate-image.ts +++ b/packages/ai-vue/src/use-generate-image.ts @@ -25,7 +25,11 @@ export interface UseGenerateImageOptions< TOutput = ImageGenerationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-speech.ts b/packages/ai-vue/src/use-generate-speech.ts index e20c8f539..a2bbbd956 100644 --- a/packages/ai-vue/src/use-generate-speech.ts +++ b/packages/ai-vue/src/use-generate-speech.ts @@ -22,7 +22,11 @@ import type { DeepReadonly, ShallowRef } from 'vue' */ export interface UseGenerateSpeechOptions extends Pick< UseGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index 7571fa370..fae59fd87 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -69,6 +69,20 @@ export interface UseGenerateVideoOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when video generation completes. Can optionally return a transformed value. * @@ -192,6 +206,10 @@ export function useGenerateVideo( ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), devtoolsBridgeFactory: createVideoDevtoolsBridge, devtools: { ...options.devtools, diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index 35435befe..625a35c3a 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -72,6 +72,20 @@ export interface UseGenerationOptions { threadId?: string /** Explicit resume-snapshot seed for apps that manage storage themselves; skips automatic hydration from `persistence`. Later run events merge into it. */ initialResumeSnapshot?: GenerationResumeSnapshot + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] /** * Callback when a result is received. Can optionally return a transformed value. * @@ -192,6 +206,10 @@ export function useGeneration< ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), ...(options.reconstructResult ? { reconstructResult: options.reconstructResult } : {}), diff --git a/packages/ai-vue/src/use-summarize.ts b/packages/ai-vue/src/use-summarize.ts index 4fb84d89b..7e8820752 100644 --- a/packages/ai-vue/src/use-summarize.ts +++ b/packages/ai-vue/src/use-summarize.ts @@ -25,7 +25,11 @@ export interface UseSummarizeOptions< TOutput = SummarizationResult, > extends Pick< UseGenerationOptions, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/src/use-transcription.ts b/packages/ai-vue/src/use-transcription.ts index cd71ecb8c..af983e2ac 100644 --- a/packages/ai-vue/src/use-transcription.ts +++ b/packages/ai-vue/src/use-transcription.ts @@ -29,7 +29,11 @@ export interface UseTranscriptionOptions< TranscriptionResult, TOutput >, - 'persistence' | 'threadId' | 'initialResumeSnapshot' + | 'persistence' + | 'threadId' + | 'initialResumeSnapshot' + | 'hydrateGeneration' + | 'joinRun' > { /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ connection?: ConnectConnectionAdapter diff --git a/packages/ai-vue/tests/use-generation.test.ts b/packages/ai-vue/tests/use-generation.test.ts index 9013303b2..a3723db14 100644 --- a/packages/ai-vue/tests/use-generation.test.ts +++ b/packages/ai-vue/tests/use-generation.test.ts @@ -378,7 +378,7 @@ describe('useGeneration', () => { expect(result.resumeState.value).toBeNull() }) - it('repaints resumeState from a stored running snapshot on mount', async () => { + it('repaints a stored running snapshot with no joinRun as an interrupted error on mount', async () => { const { adapter, connect } = createRunContextCaptureAdapter( createGenerationChunks({ id: '1' }), ) @@ -399,12 +399,12 @@ describe('useGeneration', () => { await flushPromises() await nextTick() - expect(result.resumeState.value).toEqual({ - threadId: 'thread-resume', - runId: 'run-resume', - }) - // A restored running run presents as `generating` but never auto-tails. - expect(result.status.value).toBe('generating') + // Without a `joinRun` handler the restored run cannot be tailed, so it + // surfaces as an interrupted error instead of a `generating` status + // that would never settle. + expect(result.error.value?.message).toMatch(/interrupted/) + expect(result.resumeState.value).toBeNull() + expect(result.status.value).toBe('error') expect(result.isLoading.value).toBe(false) expect(connect).not.toHaveBeenCalled() }) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 24ff9bb33..0afa9c4f5 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -114,8 +114,12 @@ export { } from './stream-to-response' // Delivery durability (transport layer) -export { memoryStream } from './stream-durability' -export type { MemoryStreamOptions, StreamDurability } from './stream-durability' +export { memoryStream, replayRunStream } from './stream-durability' +export type { + MemoryStreamInit, + MemoryStreamOptions, + StreamDurability, +} from './stream-durability' // Tool call management export { ToolCallManager } from './activities/chat/tools/tool-calls' diff --git a/packages/ai/src/stream-durability.ts b/packages/ai/src/stream-durability.ts index f2f35573a..9f07d0011 100644 --- a/packages/ai/src/stream-durability.ts +++ b/packages/ai/src/stream-durability.ts @@ -213,21 +213,51 @@ function wakeWaiters(log: MemoryLog): void { for (const wake of waiters) wake() } +/** + * Explicit construction for {@link memoryStream}, for callers that don't have + * the incoming `Request` — e.g. a TanStack Start server function implementing + * a `joinRun` replay for a run id it received as call data: + * + * ```ts + * const durability = memoryStream({ runId }) + * for await (const chunk of replayRunStream(durability)) yield chunk + * ``` + */ +export interface MemoryStreamInit { + /** The run this durability adapter attaches to. */ + runId: string + /** + * Resume offset captured by the consumer (`resumeFrom()` returns it). + * Defaults to `null` (a producer / from-start reader). + */ + offset?: string | null +} + /** * The zero-infrastructure delivery-durability backend. Its versioned cursor is * deliberately private: callers and core only pass the returned string back. * + * Construct from the incoming `Request` (HTTP transports) or from an explicit + * {@link MemoryStreamInit} (server functions / direct calls that already know + * the run id). + * * Logs live in a process-global map, so this backend is for development, tests, * and single-process deployments only. Completed runs are evicted after a grace * window (see {@link COMPLETED_LOG_TTL_MS}); a resume of an evicted or unknown * run fails loudly rather than hanging. */ export function memoryStream( - request: Request, + source: Request | MemoryStreamInit, options: MemoryStreamOptions = {}, ): StreamDurability { - const resumeOffset = readResumeOffset(request) - const runId = resolveMemoryRunId(request, resumeOffset) + const resumeOffset = + source instanceof Request + ? readResumeOffset(source) + : (source.offset ?? null) + const runId = + source instanceof Request + ? resolveMemoryRunId(source, resumeOffset) + : assertValidRunId(source.runId) const firstChunkDeadlineMs = options.firstChunkDeadlineMs ?? DEFAULT_FIRST_CHUNK_DEADLINE_MS @@ -346,3 +376,32 @@ export function memoryStream( }, } } + +/** + * Replay a run's delivery-durability log as a bare stream of chunks, for + * callers that serve a `joinRun` handler without an HTTP `Response` — e.g. a + * TanStack Start server function returning an async iterable: + * + * ```ts + * export const joinImageRunFn = createServerFn({ method: 'GET' }) + * .inputValidator((runId: string) => runId) + * .handler(async function* ({ data: runId }) { + * yield* replayRunStream(memoryStream({ runId })) + * }) + * ``` + * + * Reads from `offset` (default `'-1'` — from the start) and tails until the + * producer closes the log or `signal` aborts, exactly like the HTTP + * `resumeServerSentEventsResponse` path. + */ +export async function* replayRunStream( + durability: StreamDurability, + offset?: TOffset, + signal?: AbortSignal, +): AsyncGenerator { + // '-1' is the from-start replay sentinel every shipped backend honors. + const from = offset ?? ('-1' as TOffset) + for await (const { chunk } of durability.read(from, signal)) { + yield chunk + } +} diff --git a/packages/ai/tests/stream-durability.test.ts b/packages/ai/tests/stream-durability.test.ts index 00016e267..7c3d92d59 100644 --- a/packages/ai/tests/stream-durability.test.ts +++ b/packages/ai/tests/stream-durability.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { memoryStream } from '../src/stream-durability' +import { memoryStream, replayRunStream } from '../src/stream-durability' import { EventType } from '../src/types' import { ev } from './test-utils' import type { StreamChunk } from '../src/types' @@ -400,4 +400,66 @@ describe('memoryStream', () => { ), ).toThrow(/Invalid memory stream offset/) }) + + it('attaches to a run by explicit init, without a Request (server-function join path)', async () => { + const producer = memoryStream({ runId: 'run-init' }) + expect(producer.resumeFrom()).toBeNull() + const offsets = await producer.append([ + ev.textContent('a'), + ev.textContent('b'), + ]) + await producer.close() + + const joiner = memoryStream({ runId: 'run-init' }) + expect(await readLabels(joiner.read('-1'))).toEqual(['a', 'b']) + + const resumer = memoryStream({ runId: 'run-init', offset: offsets[0] }) + expect(resumer.resumeFrom()).toBe(offsets[0]) + expect(await readLabels(resumer.read(offsets[0] ?? ''))).toEqual(['b']) + }) + + it('rejects an invalid explicit run id', () => { + expect(() => memoryStream({ runId: 'evil\ninjected' })).toThrow( + /Invalid runId/, + ) + expect(() => memoryStream({ runId: '' })).toThrow(/Invalid runId/) + }) +}) + +describe('replayRunStream', () => { + it('maps a durability read to a bare chunk stream from the start', async () => { + const producer = memoryStream({ runId: 'run-replay' }) + await producer.append([ + ev.textContent('a'), + ev.textContent('b'), + ev.textContent('c'), + ]) + await producer.close() + + const chunks: Array = [] + for await (const chunk of replayRunStream( + memoryStream({ runId: 'run-replay' }), + )) { + chunks.push(chunk) + } + expect(chunks.map(label)).toEqual(['a', 'b', 'c']) + }) + + it('honors an explicit resume offset', async () => { + const producer = memoryStream({ runId: 'run-replay-offset' }) + const offsets = await producer.append([ + ev.textContent('a'), + ev.textContent('b'), + ]) + await producer.close() + + const labels: Array = [] + for await (const chunk of replayRunStream( + memoryStream({ runId: 'run-replay-offset' }), + offsets[0], + )) { + labels.push(label(chunk)) + } + expect(labels).toEqual(['b']) + }) }) From 02d9ad14de4791093381e9f509d9a891b95d5bbc Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:24:52 +1000 Subject: [PATCH 46/66] fix(ai): apply result transforms and carry identity in streaming generateVideo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../generate-video-result-transforms.md | 11 +++ .../ai/src/activities/generateVideo/index.ts | 56 +++++++++--- .../middlewares/generation-middleware.test.ts | 87 +++++++++++++++++++ 3 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 .changeset/generate-video-result-transforms.md diff --git a/.changeset/generate-video-result-transforms.md b/.changeset/generate-video-result-transforms.md new file mode 100644 index 000000000..13ae714d0 --- /dev/null +++ b/.changeset/generate-video-result-transforms.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai': patch +--- + +Fix `generateVideo` dropping result transforms and run identity, which made a persisted video restore as nothing. + +Streaming video was the only media activity that never called `applyGenerationResultTransforms`, and never put the caller's `threadId` / `runId` on the middleware context. Because `withGenerationPersistence` registers BOTH its artifact capture and its run-record `result` write as result transforms — pushed onto an optional `ctx.resultTransforms` — both silently no-opped. A completed video therefore stored a run record with `status: 'complete'` and nothing else: no result metadata, no artifact refs, no stored bytes, and no thread link (the run was filed under the internal `requestId`). On reload the client found no output artifact and restored nothing. + +Streaming video now applies the transforms to its terminal result before yielding it, so the `generation:result` chunk and the stored run record carry the same URLs — including the durable app-origin URL that `artifactUrl` stamps. It also passes `threadId`, `runId`, and `artifactInputs` into the middleware context, matching `generateImage`. + +`threadId` is now a documented option on `generateVideo` (it previously had none — callers passing one via an object spread type-checked but were silently ignored). When omitted, an id is still minted for the `RUN_STARTED` / `RUN_FINISHED` wire chunks, but the middleware context gets `undefined` rather than the minted value: a fabricated thread id is a slot no client can hydrate by, which is worse than recording no link at all. diff --git a/packages/ai/src/activities/generateVideo/index.ts b/packages/ai/src/activities/generateVideo/index.ts index 90ef98b48..01f03abc8 100644 --- a/packages/ai/src/activities/generateVideo/index.ts +++ b/packages/ai/src/activities/generateVideo/index.ts @@ -11,6 +11,7 @@ import { aiEventClient } from '@tanstack/ai-event-client' import { toRunErrorPayload } from '../error-payload' import { resolveDebugOption } from '../../logger/resolve' import { + applyGenerationResultTransforms, createGenerationContext, runGenerationAbort, runGenerationError, @@ -177,6 +178,16 @@ export type VideoCreateOptions< maxDuration?: number /** Custom run ID (stream mode only) */ runId?: string + /** + * Stable conversation/thread id for correlating this run when persisted. + * + * Also the `threadId` stamped on the emitted `RUN_STARTED` / `RUN_FINISHED` + * chunks; when omitted a throwaway id is minted for those chunks only, and + * the persisted run record carries NO thread link rather than a fabricated + * one. Pass it whenever persistence is on — it is the slot a reloading client + * hydrates by, so a run stored without it can only be fetched by run id. + */ + threadId?: string /** * Enable debug logging. Pass `true` to enable all categories, `false` to * silence everything including errors, or a `DebugConfig` object for granular @@ -412,12 +423,15 @@ async function* runStreamingVideoGeneration< (adapter as { name?: string }).name ?? 'unknown' - const threadId = createId('thread') + // The wire needs a thread id on every RUN_* chunk, so one is minted when the + // caller passes none — matching `streamGenerationResult`, which the other + // activities stream through. + const wireThreadId = options.threadId ?? createId('thread') yield { type: 'RUN_STARTED', runId, - threadId, + threadId: wireThreadId, timestamp: Date.now(), } as StreamChunk @@ -427,6 +441,17 @@ async function* runStreamingVideoGeneration< provider: adapter.name, model, modelOptions, + // Identity has to reach the middleware, not just the chunks: persistence + // keys the run record on these, and without them it falls back to the + // internal `requestId` and records no thread link at all. + // + // Deliberately the CALLER's `threadId`, never `wireThreadId`: a minted id is + // known to nobody, so persisting it would file the run in a slot no client + // could ever hydrate — worse than recording no link, because it looks like + // one. This mirrors `generateImage`. + threadId: options.threadId, + runId, + artifactInputs: { prompt }, createId, }) @@ -491,6 +516,21 @@ async function* runStreamingVideoGeneration< }, ) + // Run the result transforms before anything observes the result, the + // same as every other media activity. This is what lets persistence + // copy the video into a blob store, attach its artifact refs, and + // rewrite `url` to a durable app-origin one — so the chunk below and + // the stored run record carry the SAME urls. Skipping it leaves a + // result whose only url is the provider's expiring link. + const rawResult = { + jobId: jobResult.jobId, + status: 'completed' as const, + url: urlResult.url, + expiresAt: urlResult.expiresAt, + ...(urlResult.usage ? { usage: urlResult.usage } : {}), + } + const result = await applyGenerationResultTransforms(mwCtx, rawResult) + // Fire finish before yielding the terminal chunks: the generation has // succeeded, so a consumer that stops reading after `generation:result` // (without pulling `RUN_FINISHED`) must not trip the abandonment path in @@ -506,20 +546,14 @@ async function* runStreamingVideoGeneration< yield { type: 'CUSTOM', name: 'generation:result', - value: { - jobId: jobResult.jobId, - status: 'completed', - url: urlResult.url, - expiresAt: urlResult.expiresAt, - ...(urlResult.usage ? { usage: urlResult.usage } : {}), - }, + value: result, timestamp: Date.now(), } yield { type: 'RUN_FINISHED', runId, - threadId, + threadId: wireThreadId, finishReason: 'stop', timestamp: Date.now(), } as StreamChunk @@ -550,7 +584,7 @@ async function* runStreamingVideoGeneration< yield { type: 'RUN_ERROR', runId, - threadId, + threadId: wireThreadId, message: payload.message, code: payload.code, error: payload, diff --git a/packages/ai/tests/middlewares/generation-middleware.test.ts b/packages/ai/tests/middlewares/generation-middleware.test.ts index d4a7f197f..6be7fb560 100644 --- a/packages/ai/tests/middlewares/generation-middleware.test.ts +++ b/packages/ai/tests/middlewares/generation-middleware.test.ts @@ -294,6 +294,93 @@ describe('generation middleware — wiring', () => { expect(events.error).toHaveLength(0) }) + // Regression: video was the only media activity that never applied result + // transforms, and never put the caller's threadId/runId on the middleware + // context. Persistence registers artifact capture AND the run-record result + // write as result transforms, so both silently no-opped — a completed video + // stored no result, no artifacts, and no thread link, and therefore restored + // as nothing on reload. + it('generateVideo (streaming) applies result transforms and carries identity', async () => { + const adapter = { + kind: 'video' as const, + name: 'openai', + model: 'sora-2', + createVideoJob: vi.fn(async () => ({ jobId: 'job-1', model: 'sora-2' })), + getVideoStatus: vi.fn(async () => ({ status: 'completed' as const })), + getVideoUrl: vi.fn(async () => ({ url: 'https://provider.test/v.mp4' })), + } + + const seen: Array = [] + const transforming: GenerationMiddleware = { + name: 'transform', + onStart: (ctx) => { + seen.push(ctx) + ctx.resultTransforms?.push((result) => ({ + ...(result as Record), + url: 'https://app.test/api/artifacts?id=a1', + })) + }, + } + + const chunks: Array<{ type: string; name?: string; value?: unknown }> = [] + for await (const chunk of generateVideo({ + adapter: adapter as any, + prompt: 'a cat', + stream: true, + pollingInterval: 1, + threadId: 'video:slot', + runId: 'run-abc', + middleware: [transforming], + })) { + chunks.push(chunk as { type: string; name?: string; value?: unknown }) + } + + // The transform rewrote the terminal result the consumer actually sees. + const terminal = chunks.find((c) => c.name === 'generation:result') + expect(terminal?.value).toMatchObject({ + jobId: 'job-1', + status: 'completed', + url: 'https://app.test/api/artifacts?id=a1', + }) + + // Identity reached the middleware, not just the wire chunks. + expect(seen[0]).toMatchObject({ + activity: 'video', + threadId: 'video:slot', + runId: 'run-abc', + }) + }) + + it('generateVideo (streaming) records no thread link when the caller passes none', async () => { + const adapter = { + kind: 'video' as const, + name: 'openai', + model: 'sora-2', + createVideoJob: vi.fn(async () => ({ jobId: 'job-1', model: 'sora-2' })), + getVideoStatus: vi.fn(async () => ({ status: 'completed' as const })), + getVideoUrl: vi.fn(async () => ({ url: 'https://provider.test/v.mp4' })), + } + + const { middleware, events } = recordingMiddleware() + const chunks: Array<{ type: string; threadId?: string }> = [] + for await (const chunk of generateVideo({ + adapter: adapter as any, + prompt: 'a cat', + stream: true, + pollingInterval: 1, + middleware: [middleware], + })) { + chunks.push(chunk as { type: string; threadId?: string }) + } + + // The wire still needs a thread id on RUN_* chunks... + const started = chunks.find((c) => c.type === 'RUN_STARTED') + expect(typeof started?.threadId).toBe('string') + // ...but the minted one must NOT reach persistence: a fabricated id is a + // slot no client can hydrate, which is worse than recording no link. + expect(events.start[0]!.threadId).toBeUndefined() + }) + it('generateVideo (streaming) fires error when the job fails', async () => { const { middleware, events } = recordingMiddleware() const adapter = { From 740dea2e6ff95d4b88726e8c7d66ae8a1fd85c56 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:36:09 +1000 Subject: [PATCH 47/66] feat(persistence)!: require threadId on withGenerationPersistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...eneration-persistence-requires-threadid.md | 16 +++ docs/persistence/generation-persistence.md | 14 ++- docs/persistence/internals.md | 2 +- docs/persistence/keep-generated-files.md | 14 ++- .../src/routes/api.generate.audio.ts | 12 ++ .../src/routes/api.generate.image.ts | 11 ++ .../src/routes/api.generate.speech.ts | 12 ++ .../src/routes/api.generate.video.ts | 11 ++ .../src/routes/api.transcribe.ts | 12 ++ .../build-cloudflare-artifact-store/SKILL.md | 8 +- .../skills/ai-persistence/server/SKILL.md | 2 +- packages/ai-persistence/src/index.ts | 1 + packages/ai-persistence/src/middleware.ts | 38 +++++- .../ai-persistence/tests/error-abort.test.ts | 12 +- .../tests/generation-artifacts.test.ts | 112 ++++++++++++++---- .../tests/persistence-types.test-d.ts | 20 +++- .../tests/persistence-validation.test.ts | 5 +- .../ai-core/client-persistence/SKILL.md | 3 +- .../skills/ai-core/media-generation/SKILL.md | 1 + .../ai/skills/ai-core/middleware/SKILL.md | 2 +- 20 files changed, 258 insertions(+), 50 deletions(-) create mode 100644 .changeset/generation-persistence-requires-threadid.md diff --git a/.changeset/generation-persistence-requires-threadid.md b/.changeset/generation-persistence-requires-threadid.md new file mode 100644 index 000000000..ef8b3a897 --- /dev/null +++ b/.changeset/generation-persistence-requires-threadid.md @@ -0,0 +1,16 @@ +--- +'@tanstack/ai-persistence': minor +--- + +**Breaking:** `withGenerationPersistence` now requires a `threadId` on its options. + +```diff +- withGenerationPersistence(persistence, { artifactUrl }) ++ withGenerationPersistence(persistence, { threadId, artifactUrl }) +``` + +`threadId` is the generation's **scope** — a stable, app-chosen name for the slot successive runs fill (`product-123-hero`, `video-9-start-frame`), not a link to a chat conversation. It was optional here while being required on the client hooks whenever `persistence` is set, and that asymmetry hid a whole 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 new `WithGenerationPersistenceOptions` type makes that unrepresentable. + +The option is also now 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_STARTED` / `RUN_FINISHED` 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. + +To migrate, pass the same scope you already give the activity and the client hook. A server route that reads `threadId` off the AG-UI envelope should reject a request that carries none, rather than inventing one. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 6c54dd00d..d8b272d56 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -27,7 +27,7 @@ Generation persistence mirrors chat's `persistence` option: empty. Either way the server keeps a **generation job** record: -`withGenerationPersistence(persistence)` needs a `generationRuns` store (a +`withGenerationPersistence(persistence, { threadId })` needs a `generationRuns` store (a `GenerationRunStore` keyed by the run's own `runId`; a generation has no conversation of its own, so `threadId` is only an optional link). `memoryPersistence()` ships one out of the box; see @@ -84,16 +84,23 @@ export async function POST(request: Request) { throw new Error('This endpoint accepts text image prompts only.') } + // Persistence requires the scope, so a request without one cannot be served: + // the run would be filed nowhere the client could hydrate from. + if (threadId === undefined) { + return new Response('`threadId` is required', { status: 400 }) + } + const stream = generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt, - // Link the run to the thread so the GET can find the last run for it. - ...(threadId !== undefined ? { threadId } : {}), + // The same scope the GET below finds the last run for. + threadId, stream: true, // `artifactUrl` makes the restored media render from your own origin. It is // optional — see Keep generated files for the serve route it points at. middleware: [ withGenerationPersistence(persistence, { + threadId, artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, }), ], @@ -241,6 +248,7 @@ export const generateImageFn = createServerFn({ method: 'POST' }) // `artifactUrl` is optional — see Keep generated files. middleware: [ withGenerationPersistence(persistence, { + threadId, artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, }), ], diff --git a/docs/persistence/internals.md b/docs/persistence/internals.md index 897efd15d..f50f2f06e 100644 --- a/docs/persistence/internals.md +++ b/docs/persistence/internals.md @@ -50,7 +50,7 @@ stored transcript is loaded and used. ## Generation middleware lifecycle -`withGenerationPersistence(persistence)` records the job: `onStart` creates or +`withGenerationPersistence(persistence, { threadId })` records the job: `onStart` creates or resumes the run record, `onFinish`, `onError`, and `onAbort` terminalize it, and a result transform captures the terminal result metadata (ids, urls — never media bytes) onto the record. When `artifacts` and `blobs` are both provided it diff --git a/docs/persistence/keep-generated-files.md b/docs/persistence/keep-generated-files.md index 40729092e..3aac54b4f 100644 --- a/docs/persistence/keep-generated-files.md +++ b/docs/persistence/keep-generated-files.md @@ -47,18 +47,28 @@ import { const persistence = memoryPersistence() export async function POST(request: Request) { - const { input } = await generationParamsFromRequest('image', request) + const { input, threadId } = await generationParamsFromRequest( + 'image', + request, + ) if (typeof input.prompt !== 'string') { throw new Error('This endpoint accepts text image prompts only.') } + // Persistence requires the scope these runs are filed under. + if (threadId === undefined) { + return new Response('`threadId` is required', { status: 400 }) + } + const stream = generateImage({ adapter: openaiImage('gpt-image-2'), prompt: input.prompt, + threadId, stream: true, middleware: [ withGenerationPersistence(persistence, { + threadId, // Stamp the durable serve URL (the GET route below) onto every // persisted artifact ref, and rewrite the live result's media field to // it. Both the live and the restored result then render from your own @@ -122,6 +132,7 @@ by the run that produced it: ```ts group=generation-bytes const storageKeyOptions = withGenerationPersistence(persistence, { + threadId: 'product-123-hero', storageKey: ({ runId, artifactId, role, name }) => `products/${role}/${runId}-${artifactId}-${name}`, }) @@ -163,6 +174,7 @@ flag precisely so the check is not optional: ```ts group=generation-bytes const inputUrlOptions = withGenerationPersistence(persistence, { + threadId: 'product-123-hero', allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'), }) ``` diff --git a/examples/ts-react-chat/src/routes/api.generate.audio.ts b/examples/ts-react-chat/src/routes/api.generate.audio.ts index e3e1fbf17..364357901 100644 --- a/examples/ts-react-chat/src/routes/api.generate.audio.ts +++ b/examples/ts-react-chat/src/routes/api.generate.audio.ts @@ -89,6 +89,17 @@ export const Route = createFileRoute('/api/generate/audio')({ }) } + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return jsonError(400, { + error: 'missing_thread_id', + message: + '`threadId` is required — it is the scope this generation is filed under.', + }) + } + try { const adapter = buildAudioAdapter(provider ?? 'gemini-lyria', model) @@ -104,6 +115,7 @@ export const Route = createFileRoute('/api/generate/audio')({ // run still plays after the provider's link expires. middleware: [ withGenerationPersistence(generationServerPersistence(), { + threadId, artifactUrl: (ref) => artifactServeUrl(ref.artifactId), }), ], diff --git a/examples/ts-react-chat/src/routes/api.generate.image.ts b/examples/ts-react-chat/src/routes/api.generate.image.ts index e0ae6bafc..c427c17e8 100644 --- a/examples/ts-react-chat/src/routes/api.generate.image.ts +++ b/examples/ts-react-chat/src/routes/api.generate.image.ts @@ -52,6 +52,16 @@ export const Route = createFileRoute('/api/generate/image')({ throw new Error('This endpoint accepts text image prompts only.') } + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return new Response( + '`threadId` is required — it is the scope this generation is filed under.', + { status: 400 }, + ) + } + const stream = generateImage({ adapter: grokImage('grok-imagine-image'), prompt: input.prompt, @@ -66,6 +76,7 @@ export const Route = createFileRoute('/api/generate/image')({ stream: true, middleware: [ withGenerationPersistence(generationServerPersistence(), { + threadId, artifactUrl: (ref) => artifactServeUrl(ref.artifactId), }), ], diff --git a/examples/ts-react-chat/src/routes/api.generate.speech.ts b/examples/ts-react-chat/src/routes/api.generate.speech.ts index 487141f4f..f325f81ae 100644 --- a/examples/ts-react-chat/src/routes/api.generate.speech.ts +++ b/examples/ts-react-chat/src/routes/api.generate.speech.ts @@ -83,6 +83,17 @@ export const Route = createFileRoute('/api/generate/speech')({ }) } + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return jsonError(400, { + error: 'missing_thread_id', + message: + '`threadId` is required — it is the scope this generation is filed under.', + }) + } + try { const adapter = buildSpeechAdapter(provider ?? 'openai') @@ -99,6 +110,7 @@ export const Route = createFileRoute('/api/generate/speech')({ // still plays after the provider's link expires. middleware: [ withGenerationPersistence(generationServerPersistence(), { + threadId, artifactUrl: (ref) => artifactServeUrl(ref.artifactId), }), ], diff --git a/examples/ts-react-chat/src/routes/api.generate.video.ts b/examples/ts-react-chat/src/routes/api.generate.video.ts index 47891c646..b7bdb3151 100644 --- a/examples/ts-react-chat/src/routes/api.generate.video.ts +++ b/examples/ts-react-chat/src/routes/api.generate.video.ts @@ -38,6 +38,16 @@ export const Route = createFileRoute('/api/generate/video')({ const { prompt, size, duration, model } = body.data const { threadId, runId } = generationParamsFromBody('video', body) + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return new Response( + '`threadId` is required — it is the scope this generation is filed under.', + { status: 400 }, + ) + } + // Durability is keyed by run id. A client that sends none has no id to // rejoin with either, so minting one here costs nothing and keeps the // producer/reader split uniform. @@ -56,6 +66,7 @@ export const Route = createFileRoute('/api/generate/video')({ ...(threadId ? { threadId } : {}), middleware: [ withGenerationPersistence(generationServerPersistence(), { + threadId, artifactUrl: (ref) => artifactServeUrl(ref.artifactId), }), ], diff --git a/examples/ts-react-chat/src/routes/api.transcribe.ts b/examples/ts-react-chat/src/routes/api.transcribe.ts index 78c7f5e02..a92482a0f 100644 --- a/examples/ts-react-chat/src/routes/api.transcribe.ts +++ b/examples/ts-react-chat/src/routes/api.transcribe.ts @@ -92,6 +92,17 @@ export const Route = createFileRoute('/api/transcribe')({ }) } + // Persistence needs the scope named. It is a type error to wire the + // middleware without one, so reject the request rather than inventing + // an id the client could never hydrate by. + if (!threadId) { + return jsonError(400, { + error: 'missing_thread_id', + message: + '`threadId` is required — it is the scope this generation is filed under.', + }) + } + try { const adapter = buildTranscriptionAdapter(provider ?? 'openai') @@ -109,6 +120,7 @@ export const Route = createFileRoute('/api/transcribe')({ // restored run still shows what was transcribed. middleware: [ withGenerationPersistence(generationServerPersistence(), { + threadId, artifactUrl: (ref) => artifactServeUrl(ref.artifactId), }), ], diff --git a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md index 37d8e28e5..84d9e4a4f 100644 --- a/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/build-cloudflare-artifact-store/SKILL.md @@ -5,7 +5,7 @@ description: Use when a Cloudflare Worker needs durable byte storage for TanStac # Cloudflare Artifact + Blob Store -`withGenerationPersistence(persistence)` needs only `stores.generationRuns` to track a +`withGenerationPersistence(persistence, { threadId })` needs only `stores.generationRuns` to track a generation's lifecycle. Add `stores.artifacts` (metadata) **and** `stores.blobs` (the bytes) — both, or neither — and the middleware also persists the generated media: image/audio/TTS/video/transcription bytes land at blob key @@ -331,7 +331,9 @@ export default { prompt, threadId, // optional link recorded on the job + artifacts stream: true, - middleware: [withGenerationPersistence(generationPersistence(env))], + middleware: [ + withGenerationPersistence(generationPersistence(env), { threadId }), + ], }) return toServerSentEventsResponse(stream) }, @@ -429,5 +431,5 @@ between runs (see **ai-persistence/build-cloudflare-adapter** for the linked run (terminal ones included). An end-to-end check is the strongest signal: run `generateImage` through -`withGenerationPersistence(generationPersistence(env))`, then confirm the blob +`withGenerationPersistence(generationPersistence(env), { threadId })`, then confirm the blob exists at `artifacts//` and `retrieveBlob` streams it back. diff --git a/packages/ai-persistence/skills/ai-persistence/server/SKILL.md b/packages/ai-persistence/skills/ai-persistence/server/SKILL.md index 43db62047..aa9c28595 100644 --- a/packages/ai-persistence/skills/ai-persistence/server/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/server/SKILL.md @@ -142,7 +142,7 @@ Returns `{ messages, activeRun, interrupts }`: ## Generation activities -`withGenerationPersistence(persistence)` tracks run records for non-chat +`withGenerationPersistence(persistence, { threadId })` tracks run records for non-chat activities (image, audio, TTS, video, transcription). Do not fake `threadId = requestId` on chat run stores — use the generation helper. diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index 8a1d56b15..944fcc6df 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -61,6 +61,7 @@ export type { export { withPersistence, withGenerationPersistence } from './middleware' export type { WithPersistenceOptions, + WithGenerationPersistenceOptions, GenerationArtifactDescriptor, GenerationArtifactExtractionInput, GenerationArtifactNameInput, diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index bf55b0c4c..f2bc96167 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -128,6 +128,28 @@ export interface WithPersistenceOptions { artifactFetch?: typeof globalThis.fetch } +/** + * Options for {@link withGenerationPersistence} — everything in + * {@link WithPersistenceOptions}, plus the generation's scope, which is + * REQUIRED. + * + * `threadId` is a stable, app-chosen name for the slot successive runs fill + * (`product-123-hero`, `video-9-start-frame`) — not a link to a chat. It is not + * optional here for the same reason it is not optional on the client hooks: a + * run filed under no scope cannot be hydrated by one, so `persistence: true` + * would restore nothing, forever, with no error to explain why. Requiring it at + * the type level makes that unrepresentable rather than a silent runtime + * degradation. + * + * This is the authority for the run record's scope, in preference to whatever + * the activity stamps on its wire chunks — those mint a throwaway id when the + * caller supplies none, and a fabricated scope is worse than none at all. + */ +export interface WithGenerationPersistenceOptions extends WithPersistenceOptions { + /** The stable scope this generation's runs are filed under. */ + threadId: string +} + const DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS = 30_000 const DEFAULT_MAX_ARTIFACT_BYTES = 100 * 1024 * 1024 @@ -890,14 +912,16 @@ async function descriptorBody( async function persistGenerationArtifacts( persistence: AIPersistence, - opts: WithPersistenceOptions | undefined, + opts: WithGenerationPersistenceOptions, ctx: GenerationMiddlewareContext, result: unknown, ): Promise> { const activity = mediaActivity(ctx.activity) if (!activity) return [] - const threadId = ctx.threadId ?? ctx.requestId + // The required option, so an artifact is always filed under the same scope as + // its run record — never the internal requestId, which nothing can look up. + const threadId = opts.threadId const runId = ctx.runId ?? ctx.requestId const extractionInput: GenerationArtifactExtractionInput = { activity, @@ -1457,11 +1481,11 @@ export function withPersistence( */ export function withGenerationPersistence( persistence: AIPersistence & ValidGenerationPersistence, - opts?: WithPersistenceOptions, + opts: WithGenerationPersistenceOptions, ): GenerationMiddleware export function withGenerationPersistence( persistence: AIPersistence, - opts?: WithPersistenceOptions, + opts: WithGenerationPersistenceOptions, ): GenerationMiddleware { validateGenerationPersistenceStores(persistence) const { wantsArtifactPersistence } = resolvePersistencePlan(persistence) @@ -1479,14 +1503,16 @@ export function withGenerationPersistence( async onStart(ctx: GenerationMiddlewareContext) { const runId = runIdOf(ctx) - const threadId = ctx.threadId + // `opts.threadId`, not `ctx.threadId`: the option is required, so the + // scope is always known here, whereas the context's is optional and an + // activity may have minted a throwaway one for its wire chunks. await generationRuns.createOrResume({ runId, activity: ctx.activity, provider: ctx.provider, model: ctx.model, startedAt: Date.now(), - ...(threadId !== undefined ? { threadId } : {}), + threadId: opts.threadId, }) // Extract + persist artifact bytes (media → blobs, metadata → artifacts) diff --git a/packages/ai-persistence/tests/error-abort.test.ts b/packages/ai-persistence/tests/error-abort.test.ts index 4c6ac9b30..706634f08 100644 --- a/packages/ai-persistence/tests/error-abort.test.ts +++ b/packages/ai-persistence/tests/error-abort.test.ts @@ -276,7 +276,7 @@ describe('generation persistence error/abort hooks', () => { requestId = ctx.requestId }, }, - withGenerationPersistence(persistence), + withGenerationPersistence(persistence, { threadId: 'thread-test' }), ], }), ).rejects.toThrow('image boom') @@ -300,7 +300,7 @@ describe('generation persistence error/abort hooks', () => { requestId = ctx.requestId }, }, - withGenerationPersistence(persistence), + withGenerationPersistence(persistence, { threadId: 'thread-test' }), ], }), ).rejects.toBeDefined() @@ -312,7 +312,9 @@ describe('generation persistence error/abort hooks', () => { it('marks the job interrupted on generation abort', async () => { const persistence = memoryPersistence() - const middleware = withGenerationPersistence(persistence) + const middleware = withGenerationPersistence(persistence, { + threadId: 'thread-test', + }) await persistence.stores.generationRuns.createOrResume({ runId: 'req-abort', @@ -337,7 +339,9 @@ describe('generation persistence error/abort hooks', () => { it('coerces a non-Error into the job error message via the onError handler', async () => { const persistence = memoryPersistence() - const middleware = withGenerationPersistence(persistence) + const middleware = withGenerationPersistence(persistence, { + threadId: 'thread-test', + }) await persistence.stores.generationRuns.createOrResume({ runId: 'req-err', activity: 'image', diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index bab03807a..c28d0790d 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -120,7 +120,9 @@ describe('withGenerationPersistence generation artifacts', () => { prompt: 'make an image', threadId: 'thread-image', runId: 'run-image', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-image' }), + ], }) expect(result.artifacts).toHaveLength(1) @@ -164,6 +166,7 @@ describe('withGenerationPersistence generation artifacts', () => { runId: 'run-url', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-url', artifactUrl: (ref) => `/artifacts/${ref.artifactId}`, }), ], @@ -186,7 +189,9 @@ describe('withGenerationPersistence generation artifacts', () => { prompt: 'make an image', threadId: 'thread-retrieve', runId: 'run-retrieve', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-retrieve' }), + ], }) const artifactId = result.artifacts![0]!.artifactId @@ -219,6 +224,7 @@ describe('withGenerationPersistence generation artifacts', () => { runId: 'run-storage-key', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-storage-key', storageKey: ({ runId, artifactId, role }) => `my-app/videos/hero/${role}-${runId}-${artifactId}.png`, }), @@ -254,7 +260,9 @@ describe('withGenerationPersistence generation artifacts', () => { prompt: 'make an image', threadId: 'thread-legacy', runId: 'run-legacy', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-legacy' }), + ], }) const stored = (await persistence.stores.artifacts!.list('run-legacy'))[0]! @@ -276,7 +284,9 @@ describe('withGenerationPersistence generation artifacts', () => { prompt: 'make audio', threadId: 'thread-audio', runId: 'run-audio', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-audio' }), + ], } as AudioGenerateOptions)) as AudioGenerationResult expect(result.artifacts).toHaveLength(1) @@ -310,7 +320,9 @@ describe('withGenerationPersistence generation artifacts', () => { ], threadId: 'thread-input', runId: 'run-input', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-input' }), + ], }) expect(result.artifacts?.map((artifact) => artifact.role)).toEqual([ @@ -334,7 +346,9 @@ describe('withGenerationPersistence generation artifacts', () => { }, }) - expect(() => withGenerationPersistence(persistence)).not.toThrow() + expect(() => + withGenerationPersistence(persistence, { threadId: 'thread-test' }), + ).not.toThrow() }) it('throws when the job store is missing', () => { @@ -345,9 +359,9 @@ describe('withGenerationPersistence generation artifacts', () => { }, }) - expect(() => withGenerationPersistence(persistence)).toThrow( - /Generation persistence requires stores\.generationRuns/i, - ) + expect(() => + withGenerationPersistence(persistence, { threadId: 'thread-test' }), + ).toThrow(/Generation persistence requires stores\.generationRuns/i) }) it('records a job that transitions running -> complete with result + artifacts', async () => { @@ -358,7 +372,9 @@ describe('withGenerationPersistence generation artifacts', () => { prompt: 'make an image', threadId: 'thread-job', runId: 'run-job', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-job' }), + ], }) const job = await persistence.stores.generationRuns.get('run-job') @@ -388,7 +404,9 @@ describe('withGenerationPersistence generation artifacts', () => { prompt: 'make an image', threadId: 'thread-latest', runId: 'run-latest-1', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-latest' }), + ], }) const latest = @@ -412,7 +430,9 @@ describe('withGenerationPersistence generation artifacts', () => { prompt: 'make an image', threadId: 'thread-error', runId: 'run-error', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-error' }), + ], }), ).rejects.toThrow('boom') @@ -444,6 +464,7 @@ describe('withGenerationPersistence generation artifacts', () => { runId: 'run-custom', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-custom', extractArtifacts: () => [ { role: 'output', @@ -477,6 +498,7 @@ describe('withGenerationPersistence generation artifacts', () => { runId: 'run-data-url', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-data-url', extractArtifacts: () => [ { role: 'input', @@ -525,6 +547,7 @@ describe('withGenerationPersistence generation artifacts', () => { runId: 'run-name', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-name', nameArtifact: ({ descriptor, index }) => `${descriptor.role}-${descriptor.mediaType}-${index}.bin`, }), @@ -544,7 +567,9 @@ describe('withGenerationPersistence generation artifacts', () => { stream: true, threadId: 'thread-stream', runId: 'run-stream', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-stream' }), + ], }), ) @@ -560,7 +585,7 @@ describe('withGenerationPersistence generation artifacts', () => { ) }) - it('uses the same fallback run and thread ids for streamed events and persisted artifact refs', async () => { + it('files artifacts under the required threadId, not the minted wire one', async () => { const persistence = memoryPersistence() const chunks = await collect( @@ -568,7 +593,9 @@ describe('withGenerationPersistence generation artifacts', () => { adapter: imageAdapter(), prompt: 'make an image', stream: true, - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { threadId: 'thread-test' }), + ], }), ) @@ -587,10 +614,14 @@ describe('withGenerationPersistence generation artifacts', () => { runId: expect.any(String), threadId: expect.any(String), }) - expect(artifact).toMatchObject({ - runId: started?.runId, - threadId: started?.threadId, - }) + // The run id still falls back in lockstep with the wire. + expect(artifact).toMatchObject({ runId: started?.runId }) + // The thread id deliberately does NOT: the caller named the scope on the + // middleware, and the activity minted a throwaway id for its wire chunks + // because none was passed to it. Persisting the minted one would file the + // artifact in a slot nothing can look up. + expect(artifact?.threadId).toBe('thread-test') + expect(artifact?.threadId).not.toBe(started?.threadId) await expect( persistence.stores.artifacts!.list(started!.runId!), ).resolves.toHaveLength(1) @@ -609,7 +640,11 @@ describe('withGenerationPersistence generation artifacts', () => { prompt: 'make an image', threadId: 'thread-messages-only', runId: 'run-messages-only', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { + threadId: 'thread-messages-only', + }), + ], }) expect(result.artifacts).toBeUndefined() @@ -625,7 +660,11 @@ describe('withGenerationPersistence generation artifacts', () => { }, }) - expect(() => withGenerationPersistence(persistence)).toThrow( + expect(() => + withGenerationPersistence(persistence, { + threadId: 'thread-messages-only', + }), + ).toThrow( /artifact persistence requires both stores\.artifacts and stores\.blobs/i, ) }) @@ -639,7 +678,11 @@ describe('withGenerationPersistence generation artifacts', () => { responseFormat: 'verbose_json', threadId: 'thread-transcription', runId: 'run-transcription', - middleware: [withGenerationPersistence(persistence)], + middleware: [ + withGenerationPersistence(persistence, { + threadId: 'thread-transcription', + }), + ], } as TranscriptionGenerateOptions)) as TranscriptionResult expect(result.artifacts?.map((artifact) => artifact.role)).toEqual([ @@ -705,7 +748,12 @@ describe('artifact URL fetching', () => { prompt: urlPrompt('https://evil.example.com/pixel.png'), threadId: 'thread-input-url', runId: 'run-input-url', - middleware: [withGenerationPersistence(persistence, { artifactFetch })], + middleware: [ + withGenerationPersistence(persistence, { + threadId: 'thread-input-url', + artifactFetch, + }), + ], }) expect(artifactFetch).not.toHaveBeenCalled() @@ -726,6 +774,7 @@ describe('artifact URL fetching', () => { runId: 'run-allow', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-allow', artifactFetch, allowInputUrl: ({ url }) => url.hostname === 'cdn.example.com', }), @@ -753,6 +802,7 @@ describe('artifact URL fetching', () => { runId: 'run-deny', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-deny', artifactFetch, allowInputUrl: ({ url }) => url.hostname === 'cdn.example.com', }), @@ -781,6 +831,7 @@ describe('artifact URL fetching', () => { runId: 'run-ssrf', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-ssrf', artifactFetch, // Even a wide-open predicate must not defeat the host block. allowInputUrl: () => true, @@ -801,7 +852,12 @@ describe('artifact URL fetching', () => { prompt: 'make an image', threadId: 'thread-scheme', runId: 'run-scheme', - middleware: [withGenerationPersistence(persistence, { artifactFetch })], + middleware: [ + withGenerationPersistence(persistence, { + threadId: 'thread-scheme', + artifactFetch, + }), + ], }), ).rejects.toThrow(/Refusing to fetch artifact over file:/) expect(artifactFetch).not.toHaveBeenCalled() @@ -818,7 +874,12 @@ describe('artifact URL fetching', () => { prompt: 'make an image', threadId: 'thread-output', runId: 'run-output', - middleware: [withGenerationPersistence(persistence, { artifactFetch })], + middleware: [ + withGenerationPersistence(persistence, { + threadId: 'thread-output', + artifactFetch, + }), + ], }) expect(artifactFetch).toHaveBeenCalledTimes(1) @@ -847,6 +908,7 @@ describe('artifact URL fetching', () => { runId: 'run-cap', middleware: [ withGenerationPersistence(persistence, { + threadId: 'thread-cap', artifactFetch, maxArtifactBytes: 10, }), diff --git a/packages/ai-persistence/tests/persistence-types.test-d.ts b/packages/ai-persistence/tests/persistence-types.test-d.ts index 7c2be7b9c..28dc475c4 100644 --- a/packages/ai-persistence/tests/persistence-types.test-d.ts +++ b/packages/ai-persistence/tests/persistence-types.test-d.ts @@ -153,11 +153,25 @@ withPersistence(defineAIPersistence({ stores: { runs } })) withPersistence(defineAIPersistence({ stores: { interrupts, messages } })) // Generation requires generationRuns -withGenerationPersistence(defineAIPersistence({ stores: { generationRuns } })) +withGenerationPersistence(defineAIPersistence({ stores: { generationRuns } }), { + threadId: 'scope', +}) // @ts-expect-error generation persistence requires generationRuns -withGenerationPersistence(messagesOnly) +withGenerationPersistence(messagesOnly, { threadId: 'scope' }) // @ts-expect-error a runs store alone does not satisfy generation persistence -withGenerationPersistence(defineAIPersistence({ stores: { runs } })) +withGenerationPersistence(defineAIPersistence({ stores: { runs } }), { + threadId: 'scope', +}) + +// Generation persistence requires the scope to be named, exactly as the client +// hooks do: a run filed under no scope can never be hydrated by one, so this is +// unrepresentable rather than a silent restore-nothing at runtime. +// @ts-expect-error `threadId` is required on the options +withGenerationPersistence(defineAIPersistence({ stores: { generationRuns } }), { + artifactUrl: () => undefined, +}) +// @ts-expect-error the options object itself is required +withGenerationPersistence(defineAIPersistence({ stores: { generationRuns } })) const chatWithRemovedRuns = composePersistence(base, { overrides: { runs: false }, diff --git a/packages/ai-persistence/tests/persistence-validation.test.ts b/packages/ai-persistence/tests/persistence-validation.test.ts index 829341180..7c669775d 100644 --- a/packages/ai-persistence/tests/persistence-validation.test.ts +++ b/packages/ai-persistence/tests/persistence-validation.test.ts @@ -69,6 +69,7 @@ describe('persistence store dependency validation', () => { expect(() => withGenerationPersistence( persistence as Parameters[0], + { threadId: 'scope' }, ), ).toThrow(/requires stores\.generationRuns/i) }) @@ -78,7 +79,9 @@ describe('persistence store dependency validation', () => { stores: { generationRuns: createGenerationRunStore() }, }) - expect(() => withGenerationPersistence(persistence)).not.toThrow() + expect(() => + withGenerationPersistence(persistence, { threadId: 'thread-test' }), + ).not.toThrow() }) it('rejects reconstructChat without messages', async () => { diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md index fa28d00bb..f552baf96 100644 --- a/packages/ai/skills/ai-core/client-persistence/SKILL.md +++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md @@ -206,7 +206,7 @@ export async function POST(request: Request) { adapter: openaiImage('gpt-image-2'), prompt: input.prompt, stream: true, - middleware: [withGenerationPersistence(persistence)], + middleware: [withGenerationPersistence(persistence, { threadId })], }), ) } @@ -248,6 +248,7 @@ export function GET(request: Request) { ```ts withGenerationPersistence(persistence, { + threadId, artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`, }) ``` diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md index fc9d63e44..1a259f1bd 100644 --- a/packages/ai/skills/ai-core/media-generation/SKILL.md +++ b/packages/ai/skills/ai-core/media-generation/SKILL.md @@ -596,6 +596,7 @@ export async function POST(req: Request) { stream: true, middleware: [ withGenerationPersistence(persistence, { + threadId, // Stamp a durable app-origin serve URL (the GET route below) onto // each persisted artifact ref, and rewrite the live result's media to // it. Both live and restored results then render from your origin. diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md index 96649da7c..967c37edc 100644 --- a/packages/ai/skills/ai-core/middleware/SKILL.md +++ b/packages/ai/skills/ai-core/middleware/SKILL.md @@ -506,7 +506,7 @@ once the run reaches a successful boundary, so a provider failure between accepting a resume and finishing leaves the interrupt pending and a retry with the same resume succeeds. -> A companion `withGenerationPersistence(persistence)` tracks run records for +> A companion `withGenerationPersistence(persistence, { threadId })` tracks run records for > non-chat generation activities (image, audio, TTS, video, transcription). Source: docs/persistence/overview.md From 7061f1432f49716d9704ec01fa35855f22c8e9b1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 29 Jul 2026 21:12:25 +0200 Subject: [PATCH 48/66] 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 --- .changeset/durable-runs-survive-disconnect.md | 28 ++++ ...tion-mount-hydration-and-speech-restore.md | 27 ++++ .../generation-rejoin-settles-to-error.md | 19 +++ .changeset/openai-reasoning-sampling.md | 14 ++ .changeset/summarize-resumable.md | 17 +++ packages/ai-client/src/generation-client.ts | 100 ++++++++++---- .../ai-client/src/generation-reconstruct.ts | 32 +++++ packages/ai-client/src/index.ts | 1 + .../ai-client/src/video-generation-client.ts | 85 ++++++++---- .../ai-client/tests/generation-client.test.ts | 89 ++++++++++++- .../tests/reconstruct-speech.test.ts | 51 ++++++++ packages/ai-openai/src/adapters/text.ts | 14 +- packages/ai-openai/src/model-meta.ts | 17 +++ .../tests/model-sampling-support.test.ts | 42 ++++++ packages/ai-react/src/use-generate-speech.ts | 2 + .../summarize/chat-stream-summarize.ts | 5 + packages/ai/src/activities/summarize/index.ts | 25 +++- packages/ai/src/stream-to-response.ts | 123 +++++++++++++++--- packages/ai/src/types.ts | 10 ++ .../ai/tests/stream-delivery-contract.test.ts | 97 +++++++++++--- .../src/adapters/responses-text.ts | 7 +- 21 files changed, 712 insertions(+), 93 deletions(-) create mode 100644 .changeset/durable-runs-survive-disconnect.md create mode 100644 .changeset/generation-mount-hydration-and-speech-restore.md create mode 100644 .changeset/generation-rejoin-settles-to-error.md create mode 100644 .changeset/openai-reasoning-sampling.md create mode 100644 .changeset/summarize-resumable.md create mode 100644 packages/ai-client/tests/reconstruct-speech.test.ts create mode 100644 packages/ai-openai/tests/model-sampling-support.test.ts diff --git a/.changeset/durable-runs-survive-disconnect.md b/.changeset/durable-runs-survive-disconnect.md new file mode 100644 index 000000000..e444ce800 --- /dev/null +++ b/.changeset/durable-runs-survive-disconnect.md @@ -0,0 +1,28 @@ +--- +'@tanstack/ai': patch +--- + +Durable streaming runs now survive a client disconnect (page reload) and can be +tailed to completion by a rejoining client — no route-side detachment code +required. Two internal fixes to `toServerSentEventsResponse` / +`toHttpResponse`, both additive with no public API change: + +- **`RUN_STARTED` is a durability flush boundary.** One-shot generation + activities (image, speech, transcription, summarize) emit `RUN_STARTED`, then + await the provider for seconds, then a terminal. Previously `RUN_STARTED` sat + in the batch buffer, so the durable log was empty for the whole run and a + mount-time `joinRun` fast-failed as "run gone". It now flushes immediately, so + the run is resumable from the instant it starts. +- **The producer is decoupled from the HTTP response when durability is on.** + A client disconnect used to abort the producer and seal the log with + `RUN_ERROR`, even though the run kept running and recorded success. Now, on a + durable (persistence-on) run, a response cancel detaches and the producer + keeps draining into the log to its real terminal, so a rejoining client tails + it to completion. This supersedes the earlier "producers terminalize the log + on cancellation" behavior **for durable runs only**: + - **No durability (persistence off)** → unchanged: a disconnect aborts and + stops the run. + - **Durability present (persistence on)** → the run survives a disconnect. + - A genuine caller stop — aborting an `abortController` you pass (e.g. wired to + `request.signal`, as the resumable-streams demo does) — still terminalizes + the run, so opt-in die-on-disconnect keeps working. diff --git a/.changeset/generation-mount-hydration-and-speech-restore.md b/.changeset/generation-mount-hydration-and-speech-restore.md new file mode 100644 index 000000000..8785acf29 --- /dev/null +++ b/.changeset/generation-mount-hydration-and-speech-restore.md @@ -0,0 +1,27 @@ +--- +'@tanstack/ai-client': patch +'@tanstack/ai-react': patch +--- + +Fix generation mount hydration to run in the commit phase, and restore TTS +results. + +- The `GenerationClient` / `VideoGenerationClient` used to kick off mount + hydration (both the client-driven storage read and the server-driven network + fetch) from their constructor. Framework hooks build the client inside + `useMemo`, so that ran in React's render phase — a client-driven restore fired + a "state update on a component that hasn't mounted yet" warning, and several + server-driven clients mounting together re-fired the hydrate GET on every + discarded/speculative render, flooding the connection pool + (`ERR_INSUFFICIENT_RESOURCES`). Hydration now runs once from `mountDevtools` + (the hooks' commit-phase mount effect), guarded by `serverHydrationStarted`. + `initialResumeSnapshot` still seeds SSR/first paint. Note for direct + (non-framework) `GenerationClient`/`VideoGenerationClient` users: mount + hydration and the "missing `hydrateGeneration` handler" warning now fire from + `mountDevtools()` rather than the constructor, so call `mountDevtools()` (as + every framework hook does on mount) to trigger a server/storage restore; + `generate()` still triggers it too. +- New `reconstructSpeechResult` mapper, wired into `useGenerateSpeech`. A + restored `TTSResult` carries no base64 bytes (they live in the blob store), so + it surfaces the durable serve URL through `result.artifacts`; the speech clip + now repaints after a reload instead of showing status only. diff --git a/.changeset/generation-rejoin-settles-to-error.md b/.changeset/generation-rejoin-settles-to-error.md new file mode 100644 index 000000000..d16277e35 --- /dev/null +++ b/.changeset/generation-rejoin-settles-to-error.md @@ -0,0 +1,19 @@ +--- +'@tanstack/ai-client': patch +--- + +A generation mount-time rejoin that can't finish now settles to `error` instead +of hanging on `generating`. + +- `recordResumeSnapshotError` surfaces `error` on the observable `status` even + when a streamed `RUN_ERROR` already flipped the resume snapshot to `error` + (via `observeResumeSnapshot`). Previously its early-return skipped + `setStatus`, so a rejoin whose delivery log had aged out (or whose route + couldn't serve the join) left the hook stuck on `generating` forever. Guarded + so the live `generate()` path doesn't double-emit `error`. +- `GenerationClient` / `VideoGenerationClient` `dispose()` no longer calls + `stop()`: a teardown (unmount / React StrictMode dispose) must not mark the + run non-resumable and wipe the `running` snapshot the way a user-driven + `stop()` intentionally does — that destroyed client-driven resume state so a + remount could never rejoin. It now aborts only the in-flight delivery, keeps + the snapshot resumable, and re-arms mount hydration so a remount rejoins. diff --git a/.changeset/openai-reasoning-sampling.md b/.changeset/openai-reasoning-sampling.md new file mode 100644 index 000000000..e98ef6d0d --- /dev/null +++ b/.changeset/openai-reasoning-sampling.md @@ -0,0 +1,14 @@ +--- +'@tanstack/ai-openai': patch +--- + +Drop `temperature` / `top_p` for OpenAI reasoning models so they don't 400. + +The o-series and the GPT-5 reasoning family reject `temperature`/`top_p` +(`400 Unsupported parameter`), but a caller — or the summarize adapter's +low-temperature default — has no way to know a given model does. The OpenAI text +adapter now strips both for reasoning models (matched by +`openAIModelRejectsSamplingParams`, which covers `o*` and non-`*-chat-latest` +`gpt-5*` plus `codex-mini-latest`). Stripping only ever averts a guaranteed 400, +so it never changes an otherwise-valid request. This fixes `summarize` (and chat) +on `gpt-5.5` and other reasoning models. diff --git a/.changeset/summarize-resumable.md b/.changeset/summarize-resumable.md new file mode 100644 index 000000000..2f82f3361 --- /dev/null +++ b/.changeset/summarize-resumable.md @@ -0,0 +1,17 @@ +--- +'@tanstack/ai': patch +'@tanstack/openai-base': patch +--- + +Make streaming `summarize()` resumable across a mid-run reload, like the media +activities. Additive, no public API change beyond two optional fields: + +- `summarize()` (and `SummarizationOptions`) accept optional `runId` / `threadId`. + When set on a streaming summarize, they are threaded into the wrapped chat so + the emitted `RUN_STARTED` carries the caller's `runId` — letting a + delivery-durable route key the run's log by the same id the client rejoins + with, so a mount-time `joinRun` tails the run to completion instead of + fast-failing on a mismatched (empty) log. +- `@tanstack/openai-base`'s Responses `chatStream` now honors + `options.runId` for the AG-UI `RUN_STARTED` (mirroring how it already honors + `options.threadId`), falling back to a generated id when unset. diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index d1cc1c6d3..597ed7cb4 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -133,6 +133,7 @@ export class GenerationClient< private readonly callbacksRef: GenerationCallbacks private devtoolsMounted = false private disposed = false + private serverHydrationStarted = false constructor( options: GenerationClientOptions & @@ -199,27 +200,17 @@ export class GenerationClient< options.devtoolsBridgeFactory ?? createNoOpGenerationDevtoolsBridge )(this.buildDevtoolsBridgeOptions()) - // After callbacksRef is assigned: hydration may fire - // onResumeSnapshotChange synchronously if an adapter resolves sync. - this.maybeHydrateResumeSnapshot() - - // Server-driven (`persistence: true`): the client caches nothing locally and - // re-hydrates the last generation for its stable threadId from the server on - // mount. Best-effort and non-blocking; it never auto-starts a run. The - // hydrate handler comes from the connection, or from the `hydrateGeneration` - // option (e.g. a server-function call) when the transport can't carry one. - if ( - this.serverDriven && - (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) - ) { - this.hydrateFromServer() - } else if (this.serverDriven) { - // `persistence: true` without any hydrate source can never restore - // anything — warn rather than silently no-op. - console.warn( - '[TanStack AI] `persistence: true` (server-driven) needs a `hydrateGeneration` handler — either a connection that implements one (e.g. `fetchServerSentEvents` / `fetchHttpStream`, or `stream()` / `rpcStream()` with persistence handlers) or the `hydrateGeneration` option. Without one, nothing is persisted or restored.', - ) - } + // Mount hydration — both the client-driven storage read + // (`maybeHydrateResumeSnapshot`) and the server-driven network fetch + // (`maybeHydrateFromServer`) — is deliberately NOT run here. The framework + // hooks build this client inside `useMemo`, so the constructor executes in + // React's render phase; hydrating here would either setState during render + // (client-driven, a "state update on a component that hasn't mounted yet" + // warning) or re-fire the hydrate GET on every discarded/speculative render + // (server-driven, flooding the connection pool when several clients mount + // together). Both are kicked off once from `mountDevtools`, which the hooks + // call from a commit-phase mount effect. `initialResumeSnapshot` above still + // seeds SSR/first paint synchronously. } private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions { @@ -246,6 +237,14 @@ export class GenerationClient< // client) leaves the client usable again. this.disposed = false this.maybeHydrateResumeSnapshot() + this.maybeHydrateFromServer() + // Re-attach to an in-flight run whose snapshot is already loaded — the + // remount case. On the FIRST mount the snapshot loads asynchronously and + // `repaintRestoredSnapshot` starts the rejoin; on a StrictMode remount the + // snapshot is already present but the prior rejoin was aborted by + // `dispose()`, so retrigger it here. Guarded by `rejoinInFlight`'s own + // dedupe/in-flight checks, so this never double-joins. + this.maybeResumeInFlight() if (this.devtoolsMounted) { return } @@ -480,9 +479,26 @@ export class GenerationClient< dispose(): void { this.disposed = true - this.stop() + // Teardown, NOT a user cancel: abort in-flight DELIVERY (this reader) but + // do NOT call `stop()` — `stop()` marks the run non-resumable and wipes the + // `running` snapshot, which is correct for a Stop button but wrong for an + // unmount / React StrictMode dispose. Clearing it here would destroy the + // client-driven resume state so a remount (and a real page revisit) could + // never rejoin. The run itself survives server-side (durable delivery), so + // the snapshot must stay `running` for the remount to resume it. + if (this.abortController) { + this.abortController.abort() + this.abortController = null + } + this.setIsLoading(false) this.devtoolsBridge.dispose() this.devtoolsMounted = false + // Re-arm mount hydration + rejoin so a remount resumes from the (preserved) + // snapshot. `mountDevtools` re-runs the hydration entry points and + // `maybeResumeInFlight`, all individually guarded. + this.resumeSnapshotHydration = undefined + this.serverHydrationStarted = false + this.rejoinedRunId = undefined } // =========================== @@ -800,6 +816,14 @@ export class GenerationClient< * run is still in flight. */ private recordResumeSnapshotError(error: Error): void { + // Surface the failure on the OBSERVABLE fields FIRST: a rejoin (or live + // stream) that emits RUN_ERROR has already flipped the snapshot to `error` + // via `observeResumeSnapshot`, so the early-return below would otherwise + // skip this and leave `status` stuck on `generating` — the run would look + // like it is still going forever. The guard avoids a duplicate `error` + // emission on the live `generate()` path, which sets the status itself. + if (this.status !== 'error') this.setStatus('error') + this.setError(error) if (this.resumeSnapshot?.status === 'error') return if (!this.resumeSnapshot && !this.resumePersistence) return const previous = this.resumeSnapshot @@ -886,6 +910,26 @@ export class GenerationClient< * client empty rather than throwing, and a `generate()` that starts first owns * the client (hydration then backs off, mirroring the chat client). */ + /** + * Server-driven mount hydration entry point (`persistence: true`). Runs at + * most once, from the commit-phase mount path (`mountDevtools`) — never the + * constructor / render phase — so remounts and speculative renders can't + * re-fire the hydrate GET. + */ + private maybeHydrateFromServer(): void { + if (!this.serverDriven || this.serverHydrationStarted) return + this.serverHydrationStarted = true + if (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) { + this.hydrateFromServer() + } else { + // `persistence: true` without any hydrate source can never restore + // anything — warn rather than silently no-op. + console.warn( + '[TanStack AI] `persistence: true` (server-driven) needs a `hydrateGeneration` handler — either a connection that implements one (e.g. `fetchServerSentEvents` / `fetchHttpStream`, or `stream()` / `rpcStream()` with persistence handlers) or the `hydrateGeneration` option. Without one, nothing is persisted or restored.', + ) + } + } + private hydrateFromServer(): void { const hydrate = this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler @@ -910,6 +954,18 @@ export class GenerationClient< })() } + /** + * Re-attach to an already-loaded `running` snapshot (the remount case). Safe + * to call repeatedly: `rejoinInFlight` dedupes on `rejoinedRunId` and bails + * when a run is already in flight, so on the first mount (where the rejoin was + * already started from `repaintRestoredSnapshot`) this is a no-op. + */ + private maybeResumeInFlight(): void { + if (this.resumeSnapshot?.status !== 'running') return + const runId = this.resumeSnapshot.resumeState?.runId + if (runId) this.rejoinInFlight(runId) + } + /** * Re-attach to a run that is still generating and stream it to completion, * mirroring the chat client's mount-time rejoin. Reuses `processStream`, so diff --git a/packages/ai-client/src/generation-reconstruct.ts b/packages/ai-client/src/generation-reconstruct.ts index dcac9ab9e..dd70513ee 100644 --- a/packages/ai-client/src/generation-reconstruct.ts +++ b/packages/ai-client/src/generation-reconstruct.ts @@ -4,6 +4,7 @@ import type { PersistedArtifactRef, SummarizationResult, TranscriptionResult, + TTSResult, } from '@tanstack/ai' import type { GenerationRestoredResult } from './generation-types' @@ -48,6 +49,37 @@ export function reconstructImageResult( } } +/** + * tts → `{ id, model, audio: '', format, contentType, artifacts }`. + * + * Unlike {@link reconstructAudioResult}, `TTSResult.audio` is a bare base64 + * string with no URL slot, and server-driven persistence never stores the raw + * bytes — only the durable serve URL on the artifact ref. So the restored + * result surfaces the audio through `artifacts` (each carrying `url`); consumers + * play the restored clip from `result.artifacts[0].url` and fall back to the + * live base64 `audio` only for a just-finished (non-restored) run. + */ +export function reconstructSpeechResult( + restored: GenerationRestoredResult, +): TTSResult | null { + const ref = restored.artifacts.find( + (a) => + a.role === 'output' && a.source.mediaType === 'audio' && a.url != null, + ) + if (!ref) return null + const contentType = ref.mimeType || undefined + return { + id: restored.id ?? '', + model: restored.model ?? '', + // Bytes live in the blob store, served at `ref.url`; the base64 field can't + // be rebuilt from the snapshot, so it stays empty on restore. + audio: '', + format: contentType?.split('/')[1] ?? '', + ...(contentType ? { contentType } : {}), + artifacts: restored.artifacts, + } +} + /** audio → `{ id, model, audio: { url }, artifacts }`. */ export function reconstructAudioResult( restored: GenerationRestoredResult, diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index 03915fdcf..e15bc7005 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -105,6 +105,7 @@ export { export { reconstructImageResult, reconstructAudioResult, + reconstructSpeechResult, reconstructTranscriptionResult, reconstructSummarizeResult, } from './generation-reconstruct' diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 530e22b0f..2be97b1d1 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -137,6 +137,7 @@ export class VideoGenerationClient { private readonly callbacksRef: VideoCallbacks private devtoolsMounted = false private disposed = false + private serverHydrationStarted = false constructor( options: VideoGenerationClientOptions & @@ -191,27 +192,17 @@ export class VideoGenerationClient { options.devtoolsBridgeFactory ?? createNoOpVideoDevtoolsBridge )(this.buildDevtoolsBridgeOptions()) - // After callbacksRef is assigned: hydration may fire - // onResumeSnapshotChange synchronously if an adapter resolves sync. - this.maybeHydrateResumeSnapshot() - - // Server-driven (`persistence: true`): the client caches nothing locally and - // re-hydrates the last generation for its stable threadId from the server on - // mount. Best-effort and non-blocking; it never auto-starts a run. The - // hydrate handler comes from the connection, or from the `hydrateGeneration` - // option (e.g. a server-function call) when the transport can't carry one. - if ( - this.serverDriven && - (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) - ) { - this.hydrateFromServer() - } else if (this.serverDriven) { - // `persistence: true` without any hydrate source can never restore - // anything — warn rather than silently no-op. - console.warn( - '[TanStack AI] `persistence: true` (server-driven) needs a `hydrateGeneration` handler — either a connection that implements one (e.g. `fetchServerSentEvents` / `fetchHttpStream`, or `stream()` / `rpcStream()` with persistence handlers) or the `hydrateGeneration` option. Without one, nothing is persisted or restored.', - ) - } + // Mount hydration — both the client-driven storage read + // (`maybeHydrateResumeSnapshot`) and the server-driven network fetch + // (`maybeHydrateFromServer`) — is deliberately NOT run here. The framework + // hooks build this client inside `useMemo`, so the constructor executes in + // React's render phase; hydrating here would either setState during render + // (client-driven, a "state update on a component that hasn't mounted yet" + // warning) or re-fire the hydrate GET on every discarded/speculative render + // (server-driven, flooding the connection pool when several clients mount + // together). Both are kicked off once from `mountDevtools`, which the hooks + // call from a commit-phase mount effect. `initialResumeSnapshot` above still + // seeds SSR/first paint synchronously. } private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions { @@ -240,6 +231,10 @@ export class VideoGenerationClient { // client) leaves the client usable again. this.disposed = false this.maybeHydrateResumeSnapshot() + this.maybeHydrateFromServer() + // Re-attach to an already-loaded `running` snapshot (remount case); see the + // note in GenerationClient.mountDevtools. Guarded by rejoinInFlight. + this.maybeResumeInFlight() if (this.devtoolsMounted) { return } @@ -505,9 +500,18 @@ export class VideoGenerationClient { dispose(): void { this.disposed = true - this.stop() + // Teardown, NOT a user cancel (see GenerationClient.dispose): abort in-flight + // delivery but keep the `running` snapshot resumable so a remount rejoins. + if (this.abortController) { + this.abortController.abort() + this.abortController = null + } + this.setIsLoading(false) this.devtoolsBridge.dispose() this.devtoolsMounted = false + this.resumeSnapshotHydration = undefined + this.serverHydrationStarted = false + this.rejoinedRunId = undefined } // =========================== @@ -838,6 +842,13 @@ export class VideoGenerationClient { * run is still in flight. */ private recordResumeSnapshotError(error: Error): void { + // Surface the failure on the observable fields FIRST, unconditionally (see + // the note in GenerationClient.recordResumeSnapshotError): a RUN_ERROR + // already flipped the snapshot to `error`, so the early-return would else + // skip this and leave `status` stuck on `generating`. The guard avoids a + // duplicate `error` emission on the live `generate()` path. + if (this.status !== 'error') this.setStatus('error') + this.setError(error) if (this.resumeSnapshot?.status === 'error') return if (!this.resumeSnapshot && !this.resumePersistence) return const previous = this.resumeSnapshot @@ -909,6 +920,26 @@ export class VideoGenerationClient { this.repaintRestoredSnapshot(snapshot) } + /** + * Server-driven mount hydration entry point (`persistence: true`). Runs at + * most once, from the commit-phase mount path (`mountDevtools`) — never the + * constructor / render phase — so remounts and speculative renders can't + * re-fire the hydrate GET. + */ + private maybeHydrateFromServer(): void { + if (!this.serverDriven || this.serverHydrationStarted) return + this.serverHydrationStarted = true + if (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) { + this.hydrateFromServer() + } else { + // `persistence: true` without any hydrate source can never restore + // anything — warn rather than silently no-op. + console.warn( + '[TanStack AI] `persistence: true` (server-driven) needs a `hydrateGeneration` handler — either a connection that implements one (e.g. `fetchServerSentEvents` / `fetchHttpStream`, or `stream()` / `rpcStream()` with persistence handlers) or the `hydrateGeneration` option. Without one, nothing is persisted or restored.', + ) + } + } + /** * Server-driven mount hydration (`persistence: true`). The client holds no * local snapshot; on mount it asks the server — keyed by the stable threadId — @@ -941,6 +972,16 @@ export class VideoGenerationClient { })() } + /** + * Re-attach to an already-loaded `running` snapshot (remount case); see the + * note in GenerationClient.maybeResumeInFlight. Guarded by `rejoinInFlight`. + */ + private maybeResumeInFlight(): void { + if (this.resumeSnapshot?.status !== 'running') return + const runId = this.resumeSnapshot.resumeState?.runId + if (runId) this.rejoinInFlight(runId) + } + /** * Re-attach to a video run still generating and stream it to completion, * mirroring the chat client's mount-time rejoin. Reuses `processStream`, so diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index fb41c13e8..a60f742a9 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -1516,6 +1516,7 @@ describe('GenerationClient', () => { persistence, onResumeSnapshotChange, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getResumeSnapshot()).toMatchObject({ @@ -1570,6 +1571,7 @@ describe('GenerationClient', () => { connection: { async *connect() {}, joinRun }, persistence, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getStatus()).toBe('success') @@ -1606,6 +1608,7 @@ describe('GenerationClient', () => { onStatusChange, onResumeStateChange, }) + client.mountDevtools() // The completed snapshot repaints `result` (with the durable serve url) // and `status`, as if the run had just finished. @@ -1655,6 +1658,7 @@ describe('GenerationClient', () => { persistence, onResumeStateChange, }) + client.mountDevtools() await waitForCondition(() => { expect(onResumeStateChange).toHaveBeenCalledWith({ @@ -1685,6 +1689,7 @@ describe('GenerationClient', () => { persistence, onResumeStateChange, }) + client.mountDevtools() // An interrupted generation cannot be resumed, only re-run: without a // `joinRun` handler the restored run surfaces as an error, never a @@ -1737,6 +1742,7 @@ describe('GenerationClient', () => { persistence, joinRun, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getStatus()).toBe('success') @@ -2045,6 +2051,7 @@ describe('GenerationClient', () => { persistence: true, onResumeSnapshotChange, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getResumeSnapshot()).toMatchObject({ @@ -2081,6 +2088,7 @@ describe('GenerationClient', () => { reconstructResult: reconstructImageResult, onResumeStateChange, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getResult()).toEqual({ @@ -2136,6 +2144,7 @@ describe('GenerationClient', () => { connection: { async *connect() {}, hydrateGeneration, joinRun }, persistence: true, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getStatus()).toBe('success') @@ -2145,6 +2154,78 @@ describe('GenerationClient', () => { expect(client.getIsLoading()).toBe(false) }) + it('surfaces an error (never stays stuck generating) when the rejoin throws', async () => { + // The server still reports the run as in flight, but its delivery log has + // aged out / the route can't serve the join, so `joinRun` throws. The + // client must fall out of `generating` into `error`, not hang forever. + const runId = 'run-gone-1' + const joinRun = vi.fn(async function* (): AsyncGenerator { + throw new Error('Unknown or expired memory stream run') + }) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-gone', runId }, + status: 'running' as const, + }, + activeRun: { runId }, + })) + const client = new GenerationClient({ + threadId: 'thread-gone', + connection: { async *connect() {}, hydrateGeneration, joinRun }, + persistence: true, + }) + client.mountDevtools() + + await waitForCondition(() => { + expect(client.getStatus()).toBe('error') + }) + expect(client.getIsLoading()).toBe(false) + expect(client.getError()?.message).toContain('expired') + }) + + it('surfaces an error when the rejoin delivers a terminal RUN_ERROR chunk', async () => { + // The realistic gone-log case: the join GET fast-fails by EMITTING a + // RUN_ERROR chunk (not throwing). `observeResumeSnapshot` flips the + // snapshot to `error` as that chunk streams, so the client must still push + // `error` onto the observable status — otherwise it stays stuck on + // `generating` even though the snapshot already knows it failed. + const runId = 'run-gone-2' + const joinRun = vi.fn(async function* (): AsyncGenerator { + yield { + type: EventType.RUN_STARTED, + runId, + threadId: 'thread-gone-2', + timestamp: Date.now(), + } + yield { + type: EventType.RUN_ERROR, + message: 'Memory stream run produced no data within 100ms', + timestamp: Date.now(), + } as StreamChunk + }) + const hydrateGeneration = vi.fn(async () => ({ + resumeSnapshot: { + schemaVersion: 1 as const, + resumeState: { threadId: 'thread-gone-2', runId }, + status: 'running' as const, + }, + activeRun: { runId }, + })) + const client = new GenerationClient({ + threadId: 'thread-gone-2', + connection: { async *connect() {}, hydrateGeneration, joinRun }, + persistence: true, + }) + client.mountDevtools() + + await waitForCondition(() => { + expect(client.getStatus()).toBe('error') + }) + expect(client.getIsLoading()).toBe(false) + expect(client.getError()?.message).toContain('no data') + }) + it('does nothing when the connection exposes no hydrateGeneration', async () => { const client = new GenerationClient({ threadId: 'thread-server', @@ -2265,6 +2346,7 @@ describe('GenerationClient', () => { connection, persistence: true, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getResumeSnapshot()).toMatchObject({ status: 'complete' }) @@ -2280,6 +2362,7 @@ describe('GenerationClient', () => { persistence: true, hydrateGeneration, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getResumeSnapshot()).toMatchObject({ @@ -2327,6 +2410,7 @@ describe('GenerationClient', () => { hydrateGeneration, joinRun, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getStatus()).toBe('success') @@ -2344,7 +2428,7 @@ describe('GenerationClient', () => { threadId: 'thread-warn', fetcher: async () => ({}), persistence: true, - }) + }).mountDevtools() expect(warn).toHaveBeenCalledWith( expect.stringContaining('`persistence: true`'), ) @@ -2359,7 +2443,7 @@ describe('GenerationClient', () => { resumeSnapshot: null, activeRun: null, })), - }) + }).mountDevtools() await new Promise((resolve) => setTimeout(resolve, 0)) expect(warn).not.toHaveBeenCalledWith( expect.stringContaining('`persistence: true`'), @@ -2382,6 +2466,7 @@ describe('GenerationClient', () => { connection, persistence: true, }) + client.mountDevtools() await waitForCondition(() => { expect(client.getStatus()).toBe('error') diff --git a/packages/ai-client/tests/reconstruct-speech.test.ts b/packages/ai-client/tests/reconstruct-speech.test.ts new file mode 100644 index 000000000..f6095c960 --- /dev/null +++ b/packages/ai-client/tests/reconstruct-speech.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { reconstructSpeechResult } from '../src' +import type { PersistedArtifactRef } from '@tanstack/ai/client' +import type { GenerationRestoredResult } from '../src/generation-types' + +const audioArtifact: PersistedArtifactRef = { + role: 'output', + artifactId: 'artifact-speech-1', + threadId: 'thread-tts', + runId: 'run-tts', + name: 'speech.mp3', + mimeType: 'audio/mpeg', + size: 1024, + createdAt: '2026-07-06T00:00:00.000Z', + url: '/api/artifacts?id=artifact-speech-1', + source: { + activity: 'tts', + path: 'runs/run-tts/speech.mp3', + provider: 'test', + model: 'test-tts', + mediaType: 'audio', + }, +} + +const base: GenerationRestoredResult = { + id: 'gen-1', + model: 'test-tts', + activity: 'tts', + artifacts: [audioArtifact], +} + +describe('reconstructSpeechResult', () => { + it('rebuilds a TTSResult that surfaces the durable url via artifacts', () => { + const result = reconstructSpeechResult(base) + expect(result).not.toBeNull() + expect(result!.audio).toBe('') // bytes are not persisted; url is in artifacts + expect(result!.contentType).toBe('audio/mpeg') + expect(result!.format).toBe('mpeg') + expect(result!.artifacts?.[0]?.url).toBe('/api/artifacts?id=artifact-speech-1') + }) + + it('returns null when no audio output artifact carries a url', () => { + expect(reconstructSpeechResult({ ...base, artifacts: [] })).toBeNull() + expect( + reconstructSpeechResult({ + ...base, + artifacts: [{ ...audioArtifact, url: undefined }], + }), + ).toBeNull() + }) +}) diff --git a/packages/ai-openai/src/adapters/text.ts b/packages/ai-openai/src/adapters/text.ts index efa520385..2e646d1df 100644 --- a/packages/ai-openai/src/adapters/text.ts +++ b/packages/ai-openai/src/adapters/text.ts @@ -3,6 +3,7 @@ import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base' import { validateTextProviderOptions } from '../text/text-provider-options' import { convertToolsToProviderFormat } from '../tools' import { getOpenAIApiKeyFromEnv } from '../utils/client' +import { openAIModelRejectsSamplingParams } from '../model-meta' import type { OPENAI_CHAT_MODELS, OpenAIChatModel, @@ -133,10 +134,21 @@ export class OpenAITextAdapter< ? convertToolsToProviderFormat(options.tools) : undefined - return { + const request: Omit = { ...baseRequest, ...(tools && tools.length > 0 && { tools }), } + + // Reasoning models 400 on `temperature`/`top_p`. Callers (and the summarize + // adapter's low-temperature default) can't know a given model rejects them, + // so drop the pair here — stripping only ever averts a guaranteed 400, never + // changes an otherwise-valid request. + if (openAIModelRejectsSamplingParams(options.model)) { + delete request.temperature + delete request.top_p + } + + return request } } diff --git a/packages/ai-openai/src/model-meta.ts b/packages/ai-openai/src/model-meta.ts index b28d0c604..6246cef35 100644 --- a/packages/ai-openai/src/model-meta.ts +++ b/packages/ai-openai/src/model-meta.ts @@ -2237,6 +2237,23 @@ export const OPENAI_CHAT_MODELS = [ export type OpenAIChatModel = (typeof OPENAI_CHAT_MODELS)[number] +/** + * Whether a model rejects the `temperature` / `top_p` sampling knobs. + * + * OpenAI's reasoning models — the o-series (`o1`, `o3`, `o4`, …) and the GPT-5 + * reasoning family — return `400 Unsupported parameter: 'temperature'` if either + * is sent. Their `*-chat-latest` counterparts are ordinary chat models that + * still accept them, so those are excluded. Matching by name (rather than a + * per-model flag) keeps future `gpt-5.x` reasoning models covered automatically. + * See the note in `text/text-provider-options.ts`. + */ +export function openAIModelRejectsSamplingParams(model: string): boolean { + if (/^o\d/.test(model)) return true + if (model.startsWith('gpt-5') && !model.endsWith('-chat-latest')) return true + if (model === 'codex-mini-latest') return true + return false +} + // Image generation models (based on endpoints: "image-generation" or "image-edit") export const OPENAI_IMAGE_MODELS = [ GPT_IMAGE_2.name, diff --git a/packages/ai-openai/tests/model-sampling-support.test.ts b/packages/ai-openai/tests/model-sampling-support.test.ts new file mode 100644 index 000000000..580722e30 --- /dev/null +++ b/packages/ai-openai/tests/model-sampling-support.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { openAIModelRejectsSamplingParams } from '../src/model-meta' + +describe('openAIModelRejectsSamplingParams', () => { + it('flags o-series and GPT-5 reasoning models', () => { + for (const model of [ + 'o1', + 'o1-pro', + 'o3', + 'o3-mini', + 'o4-mini', + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-pro', + 'gpt-5.1', + 'gpt-5.2', + 'gpt-5.2-pro', + 'gpt-5.5', + 'gpt-5.5-pro', + 'gpt-5.1-codex-mini', + 'codex-mini-latest', + ]) { + expect(openAIModelRejectsSamplingParams(model), model).toBe(true) + } + }) + + it('leaves chat-latest and pre-5 chat models alone', () => { + for (const model of [ + 'gpt-5-chat-latest', + 'gpt-5.1-chat-latest', + 'gpt-5.2-chat-latest', + 'gpt-chat-latest', + 'chatgpt-4o-latest', + 'gpt-4.1', + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-3.5-turbo', + ]) { + expect(openAIModelRejectsSamplingParams(model), model).toBe(false) + } + }) +}) diff --git a/packages/ai-react/src/use-generate-speech.ts b/packages/ai-react/src/use-generate-speech.ts index dcf510e8e..119abdd84 100644 --- a/packages/ai-react/src/use-generate-speech.ts +++ b/packages/ai-react/src/use-generate-speech.ts @@ -1,4 +1,5 @@ import { useGeneration } from './use-generation' +import { reconstructSpeechResult } from '@tanstack/ai-client' import type { StreamChunk, TTSResult } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -160,6 +161,7 @@ export function useGenerateSpeech( >({ ...options, devtools, + reconstructResult: reconstructSpeechResult, }) return generation diff --git a/packages/ai/src/activities/summarize/chat-stream-summarize.ts b/packages/ai/src/activities/summarize/chat-stream-summarize.ts index ab1f039e1..d20879b82 100644 --- a/packages/ai/src/activities/summarize/chat-stream-summarize.ts +++ b/packages/ai/src/activities/summarize/chat-stream-summarize.ts @@ -362,6 +362,11 @@ export class ChatStreamSummarizeAdapter< systemPrompts: [systemPrompt], modelOptions, logger: options.logger, + // Forward the run identity so the wrapped chat stamps it onto RUN_STARTED + // (chat uses `runId` as its `runIdOverride`). Conditional spreads keep the + // fields absent when unset, under `exactOptionalPropertyTypes`. + ...(options.runId !== undefined ? { runId: options.runId } : {}), + ...(options.threadId !== undefined ? { threadId: options.threadId } : {}), } } diff --git a/packages/ai/src/activities/summarize/index.ts b/packages/ai/src/activities/summarize/index.ts index 3c094ff3d..2ac2e6bfc 100644 --- a/packages/ai/src/activities/summarize/index.ts +++ b/packages/ai/src/activities/summarize/index.ts @@ -57,6 +57,14 @@ export interface SummarizeActivityOptions< focus?: Array /** Provider-specific options */ modelOptions?: SummarizeProviderOptions + /** + * Optional run identity. When set on a streaming summarize, it is stamped + * onto the emitted `RUN_STARTED` so a delivery-durable route keys the run's + * log by the same id the client rejoins with — making a mid-run reload + * resumable. Filed under `threadId` when persistence is wired. + */ + runId?: string + threadId?: string /** * Whether to stream the summarization result. * When true, returns an AsyncIterable for streaming output. @@ -250,7 +258,8 @@ async function runSummarize( async function* runStreamingSummarize( options: SummarizeActivityOptions, true>, ): AsyncIterable { - const { adapter, text, maxLength, style, focus, modelOptions } = options + const { adapter, text, maxLength, style, focus, modelOptions, runId, threadId } = + options const model = adapter.model const logger: InternalLogger = resolveDebugOption(options.debug) @@ -260,6 +269,10 @@ async function* runStreamingSummarize( stream: true, }) + // Thread the caller's run identity through so the emitted `RUN_STARTED` + // carries it — keeps a delivery-durable route's log keyed by the id the + // client rejoins with (mid-run reload resumability). Conditional spreads keep + // the fields off the object entirely under `exactOptionalPropertyTypes`. const summarizeOptions = { model, text, @@ -268,6 +281,8 @@ async function* runStreamingSummarize( focus, modelOptions, logger, + ...(runId !== undefined ? { runId } : {}), + ...(threadId !== undefined ? { threadId } : {}), } try { @@ -277,8 +292,12 @@ async function* runStreamingSummarize( return } - // Fall back to non-streaming — wrap result with streamGenerationResult - yield* streamGenerationResult(() => adapter.summarize(summarizeOptions)) + // Fall back to non-streaming — wrap result with streamGenerationResult, + // forwarding the run identity so its RUN_STARTED matches too. + yield* streamGenerationResult(() => adapter.summarize(summarizeOptions), { + ...(runId !== undefined ? { runId } : {}), + ...(threadId !== undefined ? { threadId } : {}), + }) } catch (error) { logger.errors('summarize activity failed', { error, diff --git a/packages/ai/src/stream-to-response.ts b/packages/ai/src/stream-to-response.ts index edabc1c46..b69681d10 100644 --- a/packages/ai/src/stream-to-response.ts +++ b/packages/ai/src/stream-to-response.ts @@ -93,6 +93,7 @@ function toEncodedStream( abortController: AbortController | undefined, encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array, encodeError: (error: unknown) => Uint8Array, + detachOnCancel = false, ): ReadableStream { const cancellation = abortController ?? new AbortController() let iterator: AsyncIterator | undefined @@ -132,7 +133,10 @@ function toEncodedStream( break } if (isAborted(cancellation.signal)) break - controller.enqueue(encodeChunk(result.value, index)) + // After a detached cancel the reader is gone but we keep pulling to + // drain the producer into the durable log; skip enqueuing to the + // closed controller. + if (!cancelled) controller.enqueue(encodeChunk(result.value, index)) index += 1 } } catch (error) { @@ -161,6 +165,15 @@ function toEncodedStream( }, async cancel(reason) { cancelled = true + // Detached durable delivery: the client is gone (e.g. a page reload), but + // the run must finish into the durable log so a rejoining client can tail + // it to the real terminal. Do NOT abort the producer (that would kill the + // run and seal the log with RUN_ERROR) and do NOT await the pump — it + // keeps draining `stream` → the log in the background and terminates + // normally on its own. A genuine caller-driven stop aborts the producer's + // own AbortController instead, which this path never touches. + if (detachOnCancel) return + if (!isAborted(cancellation.signal)) cancellation.abort(reason) let cancellationFailure: RecordedFailure | undefined @@ -202,18 +215,31 @@ export function toServerSentEventsStream( abortController?: AbortController, getId?: (chunk: StreamChunk, index: number) => string | undefined, ): ReadableStream { + const { encodeChunk, encodeError } = sseEncoders(getId) + return toEncodedStream(stream, abortController, encodeChunk, encodeError) +} + +/** + * SSE chunk/error encoders. Shared by the public {@link toServerSentEventsStream} + * and the internal durability branch (which additionally needs `toEncodedStream`'s + * private `detachOnCancel`), so the wire format stays identical for both. + */ +function sseEncoders( + getId?: (chunk: StreamChunk, index: number) => string | undefined, +): { + encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array + encodeError: (error: unknown) => Uint8Array +} { const encoder = new TextEncoder() - return toEncodedStream( - stream, - abortController, - (chunk, index) => { + return { + encodeChunk: (chunk, index) => { const id = getId?.(chunk, index) const idLine = id === undefined ? '' : `id: ${id}\n` return encoder.encode(`${idLine}data: ${JSON.stringify(chunk)}\n\n`) }, - (error) => + encodeError: (error) => encoder.encode(`data: ${JSON.stringify(runErrorChunk(error))}\n\n`), - ) + } } /** Default number of chunks buffered before a durability `append`. */ @@ -237,11 +263,20 @@ function resolveBatchSize(batch: number | undefined): number { /** * Boundaries at which the batching producer flushes early, regardless of the - * batch size — terminal events and tool-call ends. Flushing here keeps the - * durability log promptly consistent at semantically meaningful points. + * batch size — the run-start marker, terminal events, and tool-call ends. + * Flushing here keeps the durability log promptly consistent at semantically + * meaningful points. + * + * `RUN_STARTED` matters especially for one-shot activities (image, speech, + * transcription, summarize): they emit `RUN_STARTED`, then await the provider + * for seconds, then a terminal. Without flushing `RUN_STARTED` the log stays + * empty for the whole run, so a mount-time `joinRun` finds nothing and its + * empty-log deadline fast-fails as "run gone" — even though the run is alive. + * Flushing it immediately makes the run resumable from the instant it starts. */ function isDurabilityFlushBoundary(chunk: StreamChunk): boolean { return ( + chunk.type === 'RUN_STARTED' || chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' || chunk.type === 'TOOL_CALL_END' @@ -558,16 +593,34 @@ export function toServerSentEventsResponse( let body: ReadableStream if (durability) { - const deliveryAbortController = abortController ?? new AbortController() + // A fresh run (not a resume/replay) drains into the durable log under its + // OWN producer controller, decoupled from the HTTP response: a response + // cancel (page reload) detaches and keeps draining in the background so a + // rejoining client tails the log to the real terminal, rather than killing + // the run and sealing the log with RUN_ERROR. The producer is aborted only + // by a caller-supplied `abortController` (a genuine stop()). On the resume + // path the response IS a reader, so a cancel should stop the read normally. + const isFresh = durability.adapter.resumeFrom() === null + const producerAbortController = abortController ?? new AbortController() + const deliveryAbortController = isFresh + ? new AbortController() + : producerAbortController const { source, getId } = durableStreamSource(stream, durability.adapter, { - abortController: deliveryAbortController, + abortController: producerAbortController, batch: durability.batch, // `errors` category is on by default even when `debug` is undefined, so // durability terminal-append / close failures always surface server-side — // including on the client-disconnect path where there is no live consumer. logger: resolveDebugOption(debug), }) - body = toServerSentEventsStream(source, deliveryAbortController, getId) + const { encodeChunk, encodeError } = sseEncoders(getId) + body = toEncodedStream( + source, + deliveryAbortController, + encodeChunk, + encodeError, + isFresh, + ) } else { body = toServerSentEventsStream(stream, abortController) } @@ -662,18 +715,31 @@ export function toHttpStream( abortController?: AbortController, getId?: (chunk: StreamChunk, index: number) => string | undefined, ): ReadableStream { + const { encodeChunk, encodeError } = ndjsonEncoders(getId) + return toEncodedStream(stream, abortController, encodeChunk, encodeError) +} + +/** + * NDJSON chunk/error encoders. Shared by {@link toHttpStream} and the internal + * durability branch (see {@link sseEncoders}). + */ +function ndjsonEncoders( + getId?: (chunk: StreamChunk, index: number) => string | undefined, +): { + encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array + encodeError: (error: unknown) => Uint8Array +} { const encoder = new TextEncoder() - return toEncodedStream( - stream, - abortController, - (chunk, index) => { + return { + encodeChunk: (chunk, index) => { const id = getId?.(chunk, index) const line = id === undefined ? JSON.stringify(chunk) : JSON.stringify({ id, chunk }) return encoder.encode(`${line}\n`) }, - (error) => encoder.encode(`${JSON.stringify(runErrorChunk(error))}\n`), - ) + encodeError: (error) => + encoder.encode(`${JSON.stringify(runErrorChunk(error))}\n`), + } } /** @@ -741,14 +807,29 @@ export function toHttpResponse( let body: ReadableStream if (durability) { - const deliveryAbortController = abortController ?? new AbortController() + // See toServerSentEventsResponse: a fresh run drains into the durable log + // under its own producer controller, so a response cancel (reload) detaches + // and keeps draining in the background instead of killing the run; a resume + // response is a reader whose cancel stops the read normally. + const isFresh = durability.adapter.resumeFrom() === null + const producerAbortController = abortController ?? new AbortController() + const deliveryAbortController = isFresh + ? new AbortController() + : producerAbortController const { source, getId } = durableStreamSource(stream, durability.adapter, { - abortController: deliveryAbortController, + abortController: producerAbortController, batch: durability.batch, // Errors-on-by-default logger (see toServerSentEventsResponse). logger: resolveDebugOption(debug), }) - body = toHttpStream(source, deliveryAbortController, getId) + const { encodeChunk, encodeError } = ndjsonEncoders(getId) + body = toEncodedStream( + source, + deliveryAbortController, + encodeChunk, + encodeError, + isFresh, + ) } else { body = toHttpStream(stream, abortController) } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 2d6e1ca3d..43f9aa4d8 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1977,6 +1977,16 @@ export interface SummarizationOptions< focus?: Array /** Provider-specific options forwarded by the summarize() activity. */ modelOptions?: TProviderOptions + /** + * Run identity forwarded from the summarize() activity. When set, the + * streaming adapter stamps it onto the emitted `RUN_STARTED` (via the wrapped + * chat), so a delivery-durable route keys the run's log by the same id the + * client rejoins with — making a mid-run reload resumable, like the media + * activities. Optional and non-breaking: adapters that ignore it just mint + * their own. + */ + runId?: string + threadId?: string /** * Internal logger threaded from the summarize() entry point. Adapters must * call logger.request() before the SDK call and logger.errors() in catch blocks. diff --git a/packages/ai/tests/stream-delivery-contract.test.ts b/packages/ai/tests/stream-delivery-contract.test.ts index f4e9620d7..525a8e94b 100644 --- a/packages/ai/tests/stream-delivery-contract.test.ts +++ b/packages/ai/tests/stream-delivery-contract.test.ts @@ -102,17 +102,25 @@ describe('delivery durability contract', () => { await expect(bodyPromise).resolves.toContain('backend:normal:0') }) - it('persists an aborted RUN_ERROR and awaits close when the reader cancels', async () => { + // Persistence-on semantics: a durable run is decoupled from its HTTP + // response, so a reader cancel (a page reload / dropped socket) must NOT kill + // the run. The producer keeps draining into the durable log to its own real + // terminal, so a rejoining client can tail it to completion — never a + // synthetic RUN_ERROR. A genuine caller abort (stop(), or a request signal) + // still terminalizes; that case is covered by the next test. + it('keeps producing to the durable log when the reader cancels (persistence survives disconnect)', async () => { const abortController = new AbortController() const closing = deferred() const sourceClosed = deferred() + const proceed = deferred() const appended: Array = [] const close = vi.fn(() => closing.promise) + let seq = 0 const durability = { resumeFrom: () => null, append: async (chunks: Array) => { appended.push(...chunks) - return chunks.map((_, index) => `backend:cancel:${index}`) + return chunks.map(() => `backend:cancel:${seq++}`) }, read: async function* () {}, close, @@ -120,16 +128,72 @@ describe('delivery durability contract', () => { const source: AsyncIterable = { async *[Symbol.asyncIterator]() { try { + yield ev.runStarted() yield ev.textContent('before cancel') + // Still producing while the reader is gone. + await proceed.promise + yield ev.textContent('after cancel') + yield ev.runFinished() + } finally { + sourceClosed.resolve() + } + }, + } + const response = toServerSentEventsResponse(source, { + abortController, + durability: { adapter: durability, batch: 1 }, + }) + if (!response.body) throw new Error('Expected a response body') + const reader = response.body.getReader() + + await reader.read() // RUN_STARTED + await reader.cancel() // client disconnect — must not kill the run + + // The producer's own controller was NOT aborted by the disconnect. + expect(abortController.signal.aborted).toBe(false) + + // Let the detached run finish; it drains the rest into the log. + proceed.resolve() + await sourceClosed.promise + closing.resolve() + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()) + + // The log carries the run's REAL terminal, never a synthetic abort error. + await vi.waitFor(() => + expect(appended.at(-1)?.type).toBe('RUN_FINISHED'), + ) + expect(appended.some((chunk) => chunk.type === 'RUN_ERROR')).toBe(false) + expect(appended.some((chunk) => chunk.type === 'TEXT_MESSAGE_CONTENT')).toBe( + true, + ) + }) + + it('persists a synthetic RUN_ERROR and awaits close when the producer is aborted', async () => { + const abortController = new AbortController() + const closing = deferred() + const sourceClosed = deferred() + const appended: Array = [] + const close = vi.fn(() => closing.promise) + let seq = 0 + const durability = { + resumeFrom: () => null, + append: async (chunks: Array) => { + appended.push(...chunks) + return chunks.map(() => `backend:abort:${seq++}`) + }, + read: async function* () {}, + close, + } satisfies StreamDurability + const source: AsyncIterable = { + async *[Symbol.asyncIterator]() { + try { + yield ev.runStarted() + yield ev.textContent('before abort') if (!abortController.signal.aborted) { await new Promise((resolve) => { - abortController.signal.addEventListener( - 'abort', - () => resolve(), - { - once: true, - }, - ) + abortController.signal.addEventListener('abort', () => resolve(), { + once: true, + }) }) } } finally { @@ -144,14 +208,10 @@ describe('delivery durability contract', () => { if (!response.body) throw new Error('Expected a response body') const reader = response.body.getReader() - await reader.read() - const cancelPromise = reader.cancel() - let cancelSettled = false - void cancelPromise.then(() => { - cancelSettled = true - }) + await reader.read() // RUN_STARTED + // A genuine caller abort (stop(), or a request signal) DOES stop the run. + abortController.abort() - await vi.waitFor(() => expect(abortController.signal.aborted).toBe(true)) await vi.waitFor(() => { expect(appended.at(-1)?.type).toBe('RUN_ERROR') }) @@ -162,11 +222,8 @@ describe('delivery durability contract', () => { code: 'aborted', error: { message: 'Request aborted', code: 'aborted' }, }) - await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()) - expect(cancelSettled).toBe(false) - closing.resolve() - await expect(cancelPromise).resolves.toBeUndefined() + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()) await sourceClosed.promise }) diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index d48018209..e467ac5e6 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -85,9 +85,12 @@ export abstract class OpenAIBaseResponsesTextAdapter< } >() - // AG-UI lifecycle tracking + // AG-UI lifecycle tracking. Honor a caller-supplied `runId` (as `threadId` + // already does) so the emitted RUN_STARTED matches the id the caller keys + // durability by — e.g. a summarize run threading the client's runId through + // for mid-run reload resumability. Falls back to a generated id. const aguiState = { - runId: generateId(this.name), + runId: options.runId ?? generateId(this.name), threadId: options.threadId ?? generateId(this.name), messageId: generateId(this.name), hasEmittedRunStarted: false, From beca80dcaf4ce8e5f177a5fea369d0d7678c9051 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 29 Jul 2026 21:12:43 +0200 Subject: [PATCH 49/66] feat(example): persistent-generation route; rely on library durability New /generations/persistent-generation page wiring all six generation hooks (server-driven for the five media, client-driven for summarize). Server routes now fall back to reconstructGeneration on GET, and the video route drops the hand-rolled startDetachedGeneration/tailGenerationResponse in favor of the plain toServerSentEventsResponse(stream, { durability }) path now that the library owns run lifetime. Summarize route adds delivery durability + a resume GET and threads runId. Claude-Session: https://claude.ai/code/session_01RExq9Ae6T3PT7TZmXfWbMh --- .../ts-react-chat/src/components/Header.tsx | 13 + .../src/lib/generation-durability.ts | 107 +---- .../src/lib/generation-server-persistence.ts | 11 + examples/ts-react-chat/src/routeTree.gen.ts | 63 ++- .../src/routes/api.generate.audio.ts | 17 +- .../src/routes/api.generate.image.artifact.ts | 34 ++ .../src/routes/api.generate.speech.ts | 17 +- .../src/routes/api.generate.video.ts | 91 ++-- .../ts-react-chat/src/routes/api.summarize.ts | 46 +- .../src/routes/api.transcribe.ts | 17 +- .../generations.persistent-generation.tsx | 434 ++++++++++++++++++ 11 files changed, 686 insertions(+), 164 deletions(-) create mode 100644 examples/ts-react-chat/src/lib/generation-server-persistence.ts create mode 100644 examples/ts-react-chat/src/routes/api.generate.image.artifact.ts create mode 100644 examples/ts-react-chat/src/routes/generations.persistent-generation.tsx diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 536495866..9b4d744f9 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -178,6 +178,19 @@ export default function Header() { Video Generation + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-1', + }} + > + + Persistent Generation + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/lib/generation-durability.ts b/examples/ts-react-chat/src/lib/generation-durability.ts index 1e828a3b5..25e84c801 100644 --- a/examples/ts-react-chat/src/lib/generation-durability.ts +++ b/examples/ts-react-chat/src/lib/generation-durability.ts @@ -1,110 +1,29 @@ -import { - EventType, - memoryStream, - resumeServerSentEventsResponse, -} from '@tanstack/ai' -import type { StreamChunk } from '@tanstack/ai' +import { memoryStream, resumeServerSentEventsResponse } from '@tanstack/ai' /** * Delivery durability for the generation routes — the layer that makes a run - * resumable, under the state persistence in `generation-server-store.ts`. + * resumable, alongside the state persistence in `generation-server-store.ts`. * - * Resumability is automatic on the CLIENT and opt-in on the SERVER. A hook - * using an HTTP connection adapter re-attaches to an in-flight run on mount by - * issuing `GET ?offset=-1&runId=…`; if the route never opted in, that - * request falls through to the SPA's HTML shell and the client fails trying to - * parse it as SSE ("Stream response body read failed"). So every streaming - * generation route here answers it. + * `memoryStream` logs each chunk so a rejoining client replays it instead of + * re-running the model. Routes opt in by passing the adapter as `durability` on + * their `toServerSentEventsResponse`, and by serving + * {@link replayGenerationIfResuming} from a `GET`. * - * Two of the three durability layers live in this file: - * - * - DELIVERY — `memoryStream` logs each chunk so a rejoining client replays it - * instead of re-running the model. Routes opt in by passing the adapter as - * `durability` on their response, and by serving - * {@link replayGenerationIfResuming} from a `GET`. - * - RUN LIFETIME — {@link startDetachedGeneration} unhooks the run from the - * request so a reload cannot kill it. Only worth it when a run is long enough - * that losing it hurts (video), since a detached run keeps billing after the - * user leaves. Short activities let the run die with the request and are - * simply re-run. + * There is no route-side "detach" helper. A durable response already survives a + * client disconnect: cancelling it cancels only the reader, while the run keeps + * draining into the log to completion, so a mount-time `joinRun` tails it to the + * end. The library owns the run's lifetime once `durability` is set. * * `memoryStream` keeps logs in a process-global map: development and * single-process deployments only. Swap it for `durableStream(request, { - * server })` from `@tanstack/ai-durable-stream` in production; nothing else - * here changes. - */ - -/** Runs whose producer is still appending, so a retried POST attaches instead. */ -const activeProducers = new Set() - -/** Internal base for the synthetic Requests `memoryStream` is keyed by. */ -const DURABILITY_ORIGIN = 'http://generation.internal/' - -/** - * Start `makeStream()` detached from the HTTP request, appending every chunk to - * `runId`'s delivery log. A second call for a run already in flight is a no-op, - * so a retried POST attaches to the existing producer rather than generating - * (and billing) twice. - * - * Pair with {@link tailGenerationResponse}, which streams the log to the caller - * — cancelling that response then cancels a reader, never the producer. + * server })` from `@tanstack/ai-durable-stream` in production; nothing else here + * changes. */ -export function startDetachedGeneration( - runId: string, - makeStream: () => AsyncIterable, -): void { - if (activeProducers.has(runId)) return - activeProducers.add(runId) - - // Producer-mode handle, keyed by runId via the X-Run-Id header. - const sink = memoryStream( - new Request(DURABILITY_ORIGIN, { headers: { 'X-Run-Id': runId } }), - ) - - void (async () => { - try { - for await (const chunk of makeStream()) { - await sink.append([chunk]) - } - } catch (error) { - // The run threw before emitting a terminal chunk (the persistence - // middleware's onError already recorded the failure). Append a RUN_ERROR - // so readers unblock instead of waiting on a stream that never finishes. - await sink.append([ - { - type: EventType.RUN_ERROR, - message: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - } as StreamChunk, - ]) - } finally { - await sink.close() - activeProducers.delete(runId) - } - })() -} - -/** - * Stream a detached run to THIS client by tailing its log from the start. - * - * The generous first-chunk deadline is deliberate: this reader races the - * producer the same request just started. The default is tuned short for the - * opposite case — a reload rejoin, where an empty log means the run is gone. - */ -export function tailGenerationResponse(runId: string): Response { - const reader = memoryStream( - new Request( - `${DURABILITY_ORIGIN}?offset=-1&runId=${encodeURIComponent(runId)}`, - ), - { firstChunkDeadlineMs: 10_000 }, - ) - return resumeServerSentEventsResponse({ adapter: reader }) -} /** * The GET half of `joinRun`: replay a run when the request carries a resume * offset, otherwise `null` so the route can fall through to whatever else its - * GET serves (image also answers `reconstructGeneration` there). + * GET serves (the generation routes also answer `reconstructGeneration` there). * * The run id rides the `X-Run-Id` header or `?runId`, and the offset the * `Last-Event-ID` header or `?offset` — ask the adapter via `resumeFrom()` diff --git a/examples/ts-react-chat/src/lib/generation-server-persistence.ts b/examples/ts-react-chat/src/lib/generation-server-persistence.ts new file mode 100644 index 000000000..9dcc8e6e4 --- /dev/null +++ b/examples/ts-react-chat/src/lib/generation-server-persistence.ts @@ -0,0 +1,11 @@ +import { memoryPersistence } from '@tanstack/ai-persistence' + +/** + * Server persistence for the generation demo. `memoryPersistence()` ships the + * three stores generation persistence uses — `generationRuns` (run lifecycle + + * result metadata), plus `artifacts` and `blobs` for durable byte storage — so a + * reload restores the actual generated image, not just its status. The generate + * route and the artifact serve route both import this module so they share one + * store. Point it at a durable backend for production. + */ +export const generationServerPersistence = memoryPersistence() diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index b84954eff..78ecdfa20 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as GenerationsSummarizeRouteImport } from './routes/generations.s import { Route as GenerationsStructuredOutputRouteImport } from './routes/generations.structured-output' import { Route as GenerationsStructuredChatRouteImport } from './routes/generations.structured-chat' import { Route as GenerationsSpeechRouteImport } from './routes/generations.speech' +import { Route as GenerationsPersistentGenerationRouteImport } from './routes/generations.persistent-generation' import { Route as GenerationsImageRouteImport } from './routes/generations.image' import { Route as GenerationsAudioRouteImport } from './routes/generations.audio' import { Route as ExampleRuntimeContextRouteImport } from './routes/example.runtime-context' @@ -62,6 +63,7 @@ import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.vide import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.speech' import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image' import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio' +import { Route as ApiGenerateImageArtifactRouteImport } from './routes/api.generate.image.artifact' const TypesafeToolsRoute = TypesafeToolsRouteImport.update({ id: '/typesafe-tools', @@ -181,6 +183,12 @@ const GenerationsSpeechRoute = GenerationsSpeechRouteImport.update({ path: '/generations/speech', getParentRoute: () => rootRouteImport, } as any) +const GenerationsPersistentGenerationRoute = + GenerationsPersistentGenerationRouteImport.update({ + id: '/generations/persistent-generation', + path: '/generations/persistent-generation', + getParentRoute: () => rootRouteImport, + } as any) const GenerationsImageRoute = GenerationsImageRouteImport.update({ id: '/generations/image', path: '/generations/image', @@ -331,6 +339,12 @@ const ApiGenerateAudioRoute = ApiGenerateAudioRouteImport.update({ path: '/api/generate/audio', getParentRoute: () => rootRouteImport, } as any) +const ApiGenerateImageArtifactRoute = + ApiGenerateImageArtifactRouteImport.update({ + id: '/artifact', + path: '/artifact', + getParentRoute: () => ApiGenerateImageRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -374,6 +388,7 @@ export interface FileRoutesByFullPath { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute '/generations/structured-output': typeof GenerationsStructuredOutputRoute @@ -381,11 +396,12 @@ export interface FileRoutesByFullPath { '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute '/api/generate/audio': typeof ApiGenerateAudioRoute - '/api/generate/image': typeof ApiGenerateImageRoute + '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars/': typeof ExampleGuitarsIndexRoute + '/api/generate/image/artifact': typeof ApiGenerateImageArtifactRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -429,6 +445,7 @@ export interface FileRoutesByTo { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute '/generations/structured-output': typeof GenerationsStructuredOutputRoute @@ -436,11 +453,12 @@ export interface FileRoutesByTo { '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute '/api/generate/audio': typeof ApiGenerateAudioRoute - '/api/generate/image': typeof ApiGenerateImageRoute + '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars': typeof ExampleGuitarsIndexRoute + '/api/generate/image/artifact': typeof ApiGenerateImageArtifactRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -485,6 +503,7 @@ export interface FileRoutesById { '/example/runtime-context': typeof ExampleRuntimeContextRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute + '/generations/persistent-generation': typeof GenerationsPersistentGenerationRoute '/generations/speech': typeof GenerationsSpeechRoute '/generations/structured-chat': typeof GenerationsStructuredChatRoute '/generations/structured-output': typeof GenerationsStructuredOutputRoute @@ -492,11 +511,12 @@ export interface FileRoutesById { '/generations/transcription': typeof GenerationsTranscriptionRoute '/generations/video': typeof GenerationsVideoRoute '/api/generate/audio': typeof ApiGenerateAudioRoute - '/api/generate/image': typeof ApiGenerateImageRoute + '/api/generate/image': typeof ApiGenerateImageRouteWithChildren '/api/generate/speech': typeof ApiGenerateSpeechRoute '/api/generate/video': typeof ApiGenerateVideoRoute '/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute '/example/guitars/': typeof ExampleGuitarsIndexRoute + '/api/generate/image/artifact': typeof ApiGenerateImageArtifactRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -542,6 +562,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' | '/generations/structured-output' @@ -554,6 +575,7 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars/' + | '/api/generate/image/artifact' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -597,6 +619,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' | '/generations/structured-output' @@ -609,6 +632,7 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars' + | '/api/generate/image/artifact' id: | '__root__' | '/' @@ -652,6 +676,7 @@ export interface FileRouteTypes { | '/example/runtime-context' | '/generations/audio' | '/generations/image' + | '/generations/persistent-generation' | '/generations/speech' | '/generations/structured-chat' | '/generations/structured-output' @@ -664,6 +689,7 @@ export interface FileRouteTypes { | '/api/generate/video' | '/example/guitars/$guitarId' | '/example/guitars/' + | '/api/generate/image/artifact' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -708,6 +734,7 @@ export interface RootRouteChildren { ExampleRuntimeContextRoute: typeof ExampleRuntimeContextRoute GenerationsAudioRoute: typeof GenerationsAudioRoute GenerationsImageRoute: typeof GenerationsImageRoute + GenerationsPersistentGenerationRoute: typeof GenerationsPersistentGenerationRoute GenerationsSpeechRoute: typeof GenerationsSpeechRoute GenerationsStructuredChatRoute: typeof GenerationsStructuredChatRoute GenerationsStructuredOutputRoute: typeof GenerationsStructuredOutputRoute @@ -715,7 +742,7 @@ export interface RootRouteChildren { GenerationsTranscriptionRoute: typeof GenerationsTranscriptionRoute GenerationsVideoRoute: typeof GenerationsVideoRoute ApiGenerateAudioRoute: typeof ApiGenerateAudioRoute - ApiGenerateImageRoute: typeof ApiGenerateImageRoute + ApiGenerateImageRoute: typeof ApiGenerateImageRouteWithChildren ApiGenerateSpeechRoute: typeof ApiGenerateSpeechRoute ApiGenerateVideoRoute: typeof ApiGenerateVideoRoute ExampleGuitarsGuitarIdRoute: typeof ExampleGuitarsGuitarIdRoute @@ -885,6 +912,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GenerationsSpeechRouteImport parentRoute: typeof rootRouteImport } + '/generations/persistent-generation': { + id: '/generations/persistent-generation' + path: '/generations/persistent-generation' + fullPath: '/generations/persistent-generation' + preLoaderRoute: typeof GenerationsPersistentGenerationRouteImport + parentRoute: typeof rootRouteImport + } '/generations/image': { id: '/generations/image' path: '/generations/image' @@ -1095,9 +1129,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGenerateAudioRouteImport parentRoute: typeof rootRouteImport } + '/api/generate/image/artifact': { + id: '/api/generate/image/artifact' + path: '/artifact' + fullPath: '/api/generate/image/artifact' + preLoaderRoute: typeof ApiGenerateImageArtifactRouteImport + parentRoute: typeof ApiGenerateImageRoute + } } } +interface ApiGenerateImageRouteChildren { + ApiGenerateImageArtifactRoute: typeof ApiGenerateImageArtifactRoute +} + +const ApiGenerateImageRouteChildren: ApiGenerateImageRouteChildren = { + ApiGenerateImageArtifactRoute: ApiGenerateImageArtifactRoute, +} + +const ApiGenerateImageRouteWithChildren = + ApiGenerateImageRoute._addFileChildren(ApiGenerateImageRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, CapabilityDemoRoute: CapabilityDemoRoute, @@ -1140,6 +1192,7 @@ const rootRouteChildren: RootRouteChildren = { ExampleRuntimeContextRoute: ExampleRuntimeContextRoute, GenerationsAudioRoute: GenerationsAudioRoute, GenerationsImageRoute: GenerationsImageRoute, + GenerationsPersistentGenerationRoute: GenerationsPersistentGenerationRoute, GenerationsSpeechRoute: GenerationsSpeechRoute, GenerationsStructuredChatRoute: GenerationsStructuredChatRoute, GenerationsStructuredOutputRoute: GenerationsStructuredOutputRoute, @@ -1147,7 +1200,7 @@ const rootRouteChildren: RootRouteChildren = { GenerationsTranscriptionRoute: GenerationsTranscriptionRoute, GenerationsVideoRoute: GenerationsVideoRoute, ApiGenerateAudioRoute: ApiGenerateAudioRoute, - ApiGenerateImageRoute: ApiGenerateImageRoute, + ApiGenerateImageRoute: ApiGenerateImageRouteWithChildren, ApiGenerateSpeechRoute: ApiGenerateSpeechRoute, ApiGenerateVideoRoute: ApiGenerateVideoRoute, ExampleGuitarsGuitarIdRoute: ExampleGuitarsGuitarIdRoute, diff --git a/examples/ts-react-chat/src/routes/api.generate.audio.ts b/examples/ts-react-chat/src/routes/api.generate.audio.ts index 364357901..b7d5596f3 100644 --- a/examples/ts-react-chat/src/routes/api.generate.audio.ts +++ b/examples/ts-react-chat/src/routes/api.generate.audio.ts @@ -5,7 +5,10 @@ import { memoryStream, toServerSentEventsResponse, } from '@tanstack/ai' -import { withGenerationPersistence } from '@tanstack/ai-persistence' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, @@ -159,12 +162,14 @@ export const Route = createFileRoute('/api/generate/audio')({ } }, - // `joinRun` replay — re-attach to a run still in flight from a previous - // request. 404 when the run is unknown or its log has aged out, rather - // than the SPA shell the client cannot parse as SSE. - GET: ({ request }) => + // Two independent jobs, resolved in order (like the image route): + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`, so a completed + // clip still restores after a reload once its delivery log ages out. + GET: async ({ request }) => replayGenerationIfResuming(request) ?? - new Response('no resumable run', { status: 404 }), + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.image.artifact.ts b/examples/ts-react-chat/src/routes/api.generate.image.artifact.ts new file mode 100644 index 000000000..8cd444c9b --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.generate.image.artifact.ts @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/react-router' +import { retrieveArtifact, retrieveBlob } from '@tanstack/ai-persistence' +import { generationServerPersistence } from '../lib/generation-server-persistence' + +/** + * Serves a persisted generation artifact's bytes by id, straight from the + * `retrieveArtifact` / `retrieveBlob` helpers. `withGenerationPersistence`'s + * `artifactUrl` stamps `?id=` onto each stored image, so a restored + * run renders its media from this route (our own origin) rather than the + * provider's expiring URL. + */ +export const Route = createFileRoute('/api/generate/image/artifact')({ + server: { + handlers: { + GET: async ({ request }) => { + const id = new URL(request.url).searchParams.get('id') + if (!id) return new Response('missing id', { status: 400 }) + + const artifact = await retrieveArtifact(generationServerPersistence, id) + if (!artifact) return new Response('not found', { status: 404 }) + + const blob = await retrieveBlob(generationServerPersistence, artifact) + if (!blob) return new Response('not found', { status: 404 }) + + return new Response(blob.body ?? (await blob.arrayBuffer()), { + headers: { + 'content-type': artifact.mimeType, + 'content-length': String(artifact.size), + }, + }) + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/api.generate.speech.ts b/examples/ts-react-chat/src/routes/api.generate.speech.ts index f325f81ae..687e9be36 100644 --- a/examples/ts-react-chat/src/routes/api.generate.speech.ts +++ b/examples/ts-react-chat/src/routes/api.generate.speech.ts @@ -5,7 +5,10 @@ import { memoryStream, toServerSentEventsResponse, } from '@tanstack/ai' -import { withGenerationPersistence } from '@tanstack/ai-persistence' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, @@ -152,12 +155,14 @@ export const Route = createFileRoute('/api/generate/speech')({ } }, - // `joinRun` replay — re-attach to a run still in flight from a previous - // request. 404 when the run is unknown or its log has aged out, rather - // than the SPA shell the client cannot parse as SSE. - GET: ({ request }) => + // Two independent jobs, resolved in order (like the image route): + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`, so a completed + // clip still restores after a reload once its delivery log ages out. + GET: async ({ request }) => replayGenerationIfResuming(request) ?? - new Response('no resumable run', { status: 404 }), + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.generate.video.ts b/examples/ts-react-chat/src/routes/api.generate.video.ts index b7bdb3151..6e7695283 100644 --- a/examples/ts-react-chat/src/routes/api.generate.video.ts +++ b/examples/ts-react-chat/src/routes/api.generate.video.ts @@ -1,12 +1,16 @@ import { createFileRoute } from '@tanstack/react-router' -import { generateVideo, generationParamsFromBody } from '@tanstack/ai' +import { + generateVideo, + generationParamsFromBody, + memoryStream, + toServerSentEventsResponse, +} from '@tanstack/ai' import { grokVideo } from '@tanstack/ai-grok' -import { withGenerationPersistence } from '@tanstack/ai-persistence' import { - replayGenerationIfResuming, - startDetachedGeneration, - tailGenerationResponse, -} from '../lib/generation-durability' + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' +import { replayGenerationIfResuming } from '../lib/generation-durability' import { artifactServeUrl, generationServerPersistence, @@ -20,12 +24,14 @@ import { * video into our blob store, and `artifactUrl` rewrites the result to the * shared `/api/artifacts` route — so it still plays after the provider's link * expires. - * - DELIVERY + LIFETIME: the run is detached from this request and its chunks - * go to a replayable log (see `../lib/generation-durability`), so a reload - * neither kills the job nor loses the events emitted while away. + * - DELIVERY + LIFETIME: `memoryStream` logs each chunk for replay, and because + * a durable response is decoupled from its request, a reload cancels only the + * reader — the run keeps polling to completion into the log, and the client's + * `joinRun` on mount tails it to the end. No route-side detachment needed; the + * library owns the run's lifetime once `durability` is set. * - * The GET is what the client's `joinRun` calls on mount to tail a run that was - * still going when the page went away. + * The GET does two jobs: `joinRun` delivery replay for an in-flight run, then + * `?threadId=` mount hydration for a finished run whose delivery log aged out. */ export const Route = createFileRoute('/api/generate/video')({ server: { @@ -48,43 +54,40 @@ export const Route = createFileRoute('/api/generate/video')({ ) } - // Durability is keyed by run id. A client that sends none has no id to - // rejoin with either, so minting one here costs nothing and keeps the - // producer/reader split uniform. - const resolvedRunId = runId ?? crypto.randomUUID() - - startDetachedGeneration(resolvedRunId, () => - generateVideo({ - adapter: grokVideo(model ?? 'grok-imagine-video'), - prompt, - size, - duration, - stream: true, - pollingInterval: 3000, - maxDuration: 600_000, - runId: resolvedRunId, - ...(threadId ? { threadId } : {}), - middleware: [ - withGenerationPersistence(generationServerPersistence(), { - threadId, - artifactUrl: (ref) => artifactServeUrl(ref.artifactId), - }), - ], - // No client abortController: the run owns its own lifetime. - }), - ) + const stream = generateVideo({ + adapter: grokVideo(model ?? 'grok-imagine-video'), + prompt, + size, + duration, + stream: true, + pollingInterval: 3000, + maxDuration: 600_000, + ...(threadId ? { threadId } : {}), + ...(runId ? { runId } : {}), + middleware: [ + withGenerationPersistence(generationServerPersistence(), { + threadId, + artifactUrl: (ref) => artifactServeUrl(ref.artifactId), + }), + ], + }) - // Tail the log rather than the model, so cancelling this response (a - // reload) cancels only the reader. - return tailGenerationResponse(resolvedRunId) + // Durable delivery: the run survives a reload and keeps polling to + // completion into the log; a mount-time `joinRun` tails it to the end. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) }, - // `joinRun` replay — re-attach to a run still in flight from a previous - // request. Returns 404 when the run is unknown or its log has aged out, - // rather than serving the SPA shell the client cannot parse as SSE. - GET: ({ request }) => + // Two independent jobs, resolved in order (like the image route): + // 1. `joinRun` delivery replay, when the request carries a resume offset — + // re-attach to a run still in flight from a previous request. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`, so a completed + // video (aged out of the delivery log) still restores after a reload. + GET: async ({ request }) => replayGenerationIfResuming(request) ?? - new Response('no resumable run', { status: 404 }), + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.summarize.ts b/examples/ts-react-chat/src/routes/api.summarize.ts index 5ea4ef994..9eb717c8d 100644 --- a/examples/ts-react-chat/src/routes/api.summarize.ts +++ b/examples/ts-react-chat/src/routes/api.summarize.ts @@ -1,24 +1,64 @@ import { createFileRoute } from '@tanstack/react-router' -import { summarize, toServerSentEventsResponse } from '@tanstack/ai' +import { + memoryStream, + summarize, + toServerSentEventsResponse, +} from '@tanstack/ai' import { openaiSummarize } from '@tanstack/ai-openai' +import { replayGenerationIfResuming } from '../lib/generation-durability' +/** + * Text summarization with DELIVERY durability, so a mid-run reload rejoins and + * tails the stream to completion — the same resumability the media routes get. + * + * Summarize is not a generation "kind" (no media artifacts to persist), so this + * page drives its STATE persistence from the client (`generationPersistence` + * localStorage) — the finished summary restores from there on a done-refresh. + * The only server-side piece needed for a *mid-run* reload is the delivery log: + * `memoryStream` records the chunks, and the run's `runId` is threaded into + * `summarize()` so the emitted `RUN_STARTED` is keyed by the same id the client + * rejoins with. Without that alignment the client's `joinRun` GET would tail a + * different (empty) log and fast-fail. + */ export const Route = createFileRoute('/api/summarize')({ server: { handlers: { POST: async ({ request }) => { const body = await request.json() const { text, maxLength, style, model } = body.data + // The AG-UI envelope carries the run identity alongside `data`. It also + // rides the `X-Run-Id` header (which `memoryStream` keys the log by), so + // threading `runId` into `summarize()` keeps RUN_STARTED and the log in + // sync. + const threadId = + typeof body.threadId === 'string' ? body.threadId : undefined + const runId = typeof body.runId === 'string' ? body.runId : undefined const stream = summarize({ - adapter: openaiSummarize(model ?? 'gpt-4o-mini'), + adapter: openaiSummarize(model ?? 'gpt-5.5'), text, maxLength, style, stream: true, + ...(runId ? { runId } : {}), + ...(threadId ? { threadId } : {}), }) - return toServerSentEventsResponse(stream) + // Delivery durability: chunks are logged and id-tagged, so a mount-time + // `joinRun` replays/tails instead of failing. The producer is decoupled + // from this response by the library, so a reload keeps it draining to + // the log until the summary finishes. + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) }, + + // `joinRun` replay — re-attach to a summarize run still in flight from a + // previous request. 404 when the run is unknown or its log has aged out, + // rather than the SPA shell the client cannot parse as SSE. + GET: ({ request }) => + replayGenerationIfResuming(request) ?? + new Response('no resumable run', { status: 404 }), }, }, }) diff --git a/examples/ts-react-chat/src/routes/api.transcribe.ts b/examples/ts-react-chat/src/routes/api.transcribe.ts index a92482a0f..bf8d84479 100644 --- a/examples/ts-react-chat/src/routes/api.transcribe.ts +++ b/examples/ts-react-chat/src/routes/api.transcribe.ts @@ -5,7 +5,10 @@ import { memoryStream, toServerSentEventsResponse, } from '@tanstack/ai' -import { withGenerationPersistence } from '@tanstack/ai-persistence' +import { + reconstructGeneration, + withGenerationPersistence, +} from '@tanstack/ai-persistence' import { z } from 'zod' import { InvalidModelOverrideError, @@ -162,12 +165,14 @@ export const Route = createFileRoute('/api/transcribe')({ } }, - // `joinRun` replay — re-attach to a run still in flight from a previous - // request. 404 when the run is unknown or its log has aged out, rather - // than the SPA shell the client cannot parse as SSE. - GET: ({ request }) => + // Two independent jobs, resolved in order (like the image route): + // 1. `joinRun` delivery replay, when the request carries a resume offset. + // 2. Mount hydration for `persistence: true`: the latest run for + // `?threadId=`, as `{ resumeSnapshot, activeRun }`, so a completed + // transcription still restores after a reload once its log ages out. + GET: async ({ request }) => replayGenerationIfResuming(request) ?? - new Response('no resumable run', { status: 404 }), + (await reconstructGeneration(generationServerPersistence(), request)), }, }, }) diff --git a/examples/ts-react-chat/src/routes/generations.persistent-generation.tsx b/examples/ts-react-chat/src/routes/generations.persistent-generation.tsx new file mode 100644 index 000000000..6d156a8e5 --- /dev/null +++ b/examples/ts-react-chat/src/routes/generations.persistent-generation.tsx @@ -0,0 +1,434 @@ +import { useState } from 'react' +import type { ReactNode } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { + FileAudio, + FileText, + Image, + Mic, + Music, + Play, + RotateCcw, + Video, +} from 'lucide-react' +import { + useGenerateAudio, + useGenerateImage, + useGenerateSpeech, + useGenerateVideo, + useSummarize, + useTranscription, +} from '@tanstack/ai-react' +import { fetchServerSentEvents } from '@tanstack/ai-client' +import { generationPersistence } from '../lib/generation-persistence' + +/** + * Every generation surface, persisted, on one page — the manual-verification + * harness for generation persistence. + * + * The five MEDIA activities (image, audio, speech, transcription, video) are + * **server-driven**: `persistence: true` + a `connection`. The browser caches + * nothing; the server records each run and copies the generated bytes into its + * own store (see `../lib/generation-server-store` and the `/api/generate/*` + * routes). On mount the hook issues a `GET …?threadId=…`, answered by + * `reconstructGeneration`, so a full reload restores the result — and if the + * run is still in flight, the hook rejoins its stream and finishes it in place. + * + * `summarize` is text, not a generation kind, so it can't be server-persisted + * the same way. It uses the CLIENT adapter (`generationPersistence`, + * localStorage): the summary text lives in the resume snapshot itself, so a + * reload restores it without any server round-trip. + * + * Each hook has a stable `threadId` — that is the scope the run is filed under + * and the storage key, so it must not change across reloads. + */ + +function PersistentGenerationPage() { + const [imagePrompt, setImagePrompt] = useState('a lighthouse at dusk') + const [audioPrompt, setAudioPrompt] = useState('a calm lofi beat') + const [speechText, setSpeechText] = useState( + 'Persistence keeps this generation across a reload.', + ) + const [summaryText, setSummaryText] = useState(SAMPLE_TEXT) + const [videoPrompt, setVideoPrompt] = useState('a paper plane over a city') + + // --- Media: server-driven (persistence: true + connection) --------------- + + const image = useGenerateImage({ + id: 'persistent-generation:image', + threadId: 'persistent-generation:image', + connection: fetchServerSentEvents('/api/generate/image'), + persistence: true, + }) + + const audio = useGenerateAudio({ + id: 'persistent-generation:audio', + threadId: 'persistent-generation:audio', + connection: fetchServerSentEvents('/api/generate/audio'), + // Fal-backed (FAL_KEY): gemini-lyria needs a Gemini key this example's + // .env doesn't ship. Swap the provider if you have a different key set. + body: { provider: 'fal-audio', model: 'fal-ai/elevenlabs/music' }, + persistence: true, + }) + + const speech = useGenerateSpeech({ + id: 'persistent-generation:speech', + threadId: 'persistent-generation:speech', + connection: fetchServerSentEvents('/api/generate/speech'), + body: { provider: 'openai' }, + persistence: true, + }) + + const transcription = useTranscription({ + id: 'persistent-generation:transcription', + threadId: 'persistent-generation:transcription', + connection: fetchServerSentEvents('/api/transcribe'), + body: { provider: 'openai' }, + persistence: true, + }) + + const video = useGenerateVideo({ + id: 'persistent-generation:video', + threadId: 'persistent-generation:video', + connection: fetchServerSentEvents('/api/generate/video'), + persistence: true, + }) + + // --- Text: client-driven (summary text lives in the snapshot) ------------ + + const summarize = useSummarize({ + id: 'persistent-generation:summarize', + threadId: 'persistent-generation:summarize', + connection: fetchServerSentEvents('/api/summarize'), + body: { model: 'gpt-5.5' }, + persistence: generationPersistence, + }) + + const handleTranscribe = async ( + e: React.ChangeEvent, + ): Promise => { + const file = e.target.files?.[0] + if (!file) return + const buffer = await file.arrayBuffer() + const base64 = btoa( + new Uint8Array(buffer).reduce((s, b) => s + String.fromCharCode(b), ''), + ) + await transcription.generate({ + audio: `data:${file.type};base64,${base64}`, + language: 'en', + }) + e.target.value = '' + } + + return ( +
+
+
+

+ Generation persistence +

+

+ Persistent Generation +

+

+ Every generation surface, persisted. Start any run and reload the + page: a run still in flight reconnects and finishes; a finished run + restores from the server. Summarize restores from localStorage. +

+
+ +
+ void image.generate({ prompt: imagePrompt })} + onReset={image.reset} + > + setImagePrompt(e.target.value)} + disabled={image.isLoading} + className="w-full rounded-md border border-gray-800 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-cyan-500" + /> +
+ {image.result?.images.map((img, i) => ( + {img.revisedPrompt + ))} +
+
+ + void video.generate({ prompt: videoPrompt })} + onReset={video.reset} + onStop={video.stop} + > + setVideoPrompt(e.target.value)} + disabled={video.isLoading} + className="w-full rounded-md border border-gray-800 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-cyan-500" + /> +
+ job {video.jobId ?? 'none'} + status {video.videoStatus?.status ?? 'idle'} + + progress{' '} + {video.videoStatus?.progress == null + ? '0%' + : `${video.videoStatus.progress}%`} + +
+ {video.result?.url && ( +
+ + void audio.generate({ prompt: audioPrompt })} + onReset={audio.reset} + > + setAudioPrompt(e.target.value)} + disabled={audio.isLoading} + className="w-full rounded-md border border-gray-800 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-cyan-500" + /> + {audio.result?.audio.url && ( + + + void speech.generate({ text: speechText })} + onReset={speech.reset} + > +