From 1fe3929586c721df665b79966b6182bc447b2af5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 13:24:55 +0200 Subject: [PATCH 01/22] 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 45c038a42..ed6f8c873 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 dddb4e2b9d9576bc2135934234cf0e5003937595 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 13:45:19 +0200 Subject: [PATCH 02/22] 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 f3b35f2a6766c1bf6d8981aa8df7f428fd3f1fb0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:03:12 +0200 Subject: [PATCH 03/22] 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 b031f60d3ad03c1be48509c79ba2de8c8bdb0705 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:04:53 +0200 Subject: [PATCH 04/22] 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 afbd8bd10187508b5de5d766121928a0895a13bc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:18:31 +0200 Subject: [PATCH 05/22] 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 0e91e4242cbe93b0aa1af96d890b89da33b79a13 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:32:14 +0200 Subject: [PATCH 06/22] 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 fb054da22fa6f366977007d94c8a5cf6f40b23aa Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 14:58:33 +0200 Subject: [PATCH 07/22] 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 765bcfd1d34e839aca54dc0a95bc6603f5af7f8d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 15:05:12 +0200 Subject: [PATCH 08/22] 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 549b01d57cc66ff8a801023c623376aa04c1a73a 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/22] 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 ba659af19faf38cb59cfa60f14b596c7935eb6fb 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/22] 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 ed6f8c873..d3f34d915 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 81b5b74c39a91b3c2395e42f93662f8ff499bd1e 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/22] 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 70bf9b65f49ec5137b28ef9d46ec07fa835e0b31 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/22] 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 18a329fd75476e230e70d20a0bff3b2f435a249b 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/22] 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 840e49fbc752c59635db89d8ab4d79d81a164de0 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/22] 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 fbc3dc331aa75f8927294cbdac07c0740270e3b6 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/22] 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 7cdf124127b563154c6907d91ad1507730e76b16 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/22] 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 a1fb4360a98b0b55ae6ad6a8e282aaebea7b5414 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/22] 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 e3fd042edbc59b7779d2129369f488593d32e59b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 16:13:19 +0200 Subject: [PATCH 18/22] feat(persistence): durable generation media-byte storage Server-side artifact + blob storage for generated media, layered on withGenerationPersistence. - @tanstack/ai: result-transform machinery (resultTransforms/artifactInputs on GenerationMiddlewareContext, applyGenerationResultTransforms), threadId/ runId on the image/audio/speech/transcription activities, and generation:artifacts emission from streamGenerationResult. - @tanstack/ai-utils: base64ToUint8Array. - @tanstack/ai-persistence: ArtifactStore + BlobStore contracts, in-memory impls in memoryPersistence(), and withGenerationPersistence byte-persistence (writes bytes to blobs, records ArtifactRecord, attaches PersistedArtifactRef, emits generation:artifacts) with extractArtifacts/nameArtifact options. --- .changeset/generation-persistence.md | 9 +- packages/ai-persistence/package.json | 3 + packages/ai-persistence/src/index.ts | 23 + packages/ai-persistence/src/memory.ts | 241 +++++++- packages/ai-persistence/src/middleware.ts | 578 +++++++++++++++++- packages/ai-persistence/src/types.ts | 149 ++++- .../tests/generation-artifacts.test.ts | 464 ++++++++++++++ packages/ai-persistence/tests/memory.test.ts | 2 + 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 | 10 +- 20 files changed, 1621 insertions(+), 52 deletions(-) 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..9f08c75ed 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -1,4 +1,7 @@ --- +'@tanstack/ai': minor +'@tanstack/ai-utils': minor +'@tanstack/ai-persistence': minor '@tanstack/ai-client': minor '@tanstack/ai-react': minor '@tanstack/ai-solid': minor @@ -7,7 +10,11 @@ '@tanstack/ai-angular': minor --- -Add client-side generation persistence: a lightweight resume snapshot for media generation activities. +Add generation persistence: a lightweight client resume snapshot plus optional durable storage of the generated media bytes. + +**Media byte storage.** `withGenerationPersistence` now persists the generated bytes when the persistence backend provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store. As an image/audio/speech/transcription run finishes, the middleware writes each artifact'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`. `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, 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` 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). 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..a6fbcaa59 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 media bytes (opt-in; pair `artifacts` with `blobs`) + ArtifactRecord, + ArtifactStore, + BlobBody, + BlobRecord, + BlobObject, + BlobListPage, + BlobPutOptions, + BlobListOptions, + BlobStore, AIPersistence, AIPersistenceOverrides, ComposedAIPersistenceStores, @@ -33,8 +43,21 @@ export type { // AIPersistenceStores is intentionally NOT re-exported — use a named chat // shape or AIPersistence<{ messages: MessageStore, … }>. +// Core artifact wire types (re-exported for convenience) +export type { + PersistedArtifactActivity, + PersistedArtifactRef, + PersistedArtifactRole, +} from '@tanstack/ai' + // Middleware (state only — 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' diff --git a/packages/ai-persistence/src/memory.ts b/packages/ai-persistence/src/memory.ts index 70f8368f1..c15c2535a 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,227 @@ 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 **chat** state stores plus the optional + * generation media stores. * - * 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 (the chat persistence shape) + * widened with `artifacts` + `blobs`, so it satisfies `withPersistence` and the + * byte-persisting path of `withGenerationPersistence` alike. 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..6c3de8faf 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,11 +35,55 @@ import type { import type { AIPersistence, AIPersistenceStores, + ArtifactRecord, + BlobBody, ChatTranscriptStores, InterruptRecord, RunStore, } from './types' +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 interrupted: boolean @@ -259,18 +307,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 = `artifacts/${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, } } @@ -313,9 +826,20 @@ type InvalidChatPersistence = type ValidChatPersistence = InvalidChatPersistence extends true ? never : unknown -/** Generation entrypoint invalid when `runs` is known-absent. */ +/** + * Generation entrypoint invalid when: + * - `runs` is known-absent, or + * - exactly one of `artifacts` / `blobs` is known-present (the byte path writes + * to both, so one without the other is always a misconfiguration). + */ type InvalidGenerationPersistence = - StoreIsDefinitelyAbsent extends true ? true : false + StoreIsDefinitelyAbsent extends true + ? true + : StoreIsDefinitelyPresent extends true + ? StoreIsDefinitelyAbsent + : StoreIsDefinitelyPresent extends true + ? StoreIsDefinitelyAbsent + : false type ValidGenerationPersistence = InvalidGenerationPersistence extends true ? never : unknown @@ -622,10 +1146,12 @@ 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 and optionally + * persists media artifacts/blobs for image, audio, TTS, video, and + * transcription activities. * - * Requires `stores.runs`. + * Requires `stores.runs`. `stores.artifacts` + `stores.blobs` (as a pair) opt + * into durable media bytes. * * ⚠️ TEMPORARY / WRONG SHAPE — do not extend this design. * @@ -640,15 +1166,24 @@ export function withPersistence( * 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. + * + * The artifact path below does **not** resolve this: it keys `ArtifactRecord` + * on `ctx.runId ?? ctx.requestId` while the run record above is still keyed on + * `ctx.requestId`, so a caller-supplied `runId` leaves `runs.get(runId)` empty + * while `artifacts.list(runId)` returns rows. The two stores cannot be joined + * until the job store lands. */ -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 runStore = persistence.stores.runs - if (!runStore) { + const { wantsArtifactPersistence, runs } = resolvePersistencePlan(persistence) + if (!runs) { // validateGenerationPersistenceStores already throws; this narrows for TypeScript. throw new Error('Generation persistence requires stores.runs.') } @@ -658,25 +1193,40 @@ export function withGenerationPersistence< async onStart(ctx: GenerationMiddlewareContext) { // STOPGAP ONLY — see function JSDoc. Do not copy this pattern. - await createOrResumeRun(runStore, ctx.requestId, ctx.requestId) + await createOrResumeRun(runs, 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( ctx: GenerationMiddlewareContext, info: GenerationFinishInfo, ) { - await completeRun(runStore, ctx.requestId, info.usage) + await completeRun(runs, ctx.requestId, info.usage) }, async onError(ctx: GenerationMiddlewareContext, info: GenerationErrorInfo) { - await failRun(runStore, ctx.requestId, info.error) + await failRun(runs, ctx.requestId, info.error) }, async onAbort( ctx: GenerationMiddlewareContext, _info: GenerationAbortInfo, ) { - await interruptRun(runStore, ctx.requestId) + await interruptRun(runs, ctx.requestId) }, } } diff --git a/packages/ai-persistence/src/types.ts b/packages/ai-persistence/src/types.ts index a1fd7f488..240c569c4 100644 --- a/packages/ai-persistence/src/types.ts +++ b/packages/ai-persistence/src/types.ts @@ -31,9 +31,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. @@ -277,6 +278,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. * @@ -293,6 +417,8 @@ export interface AIPersistenceStores { runs?: RunStore interrupts?: InterruptStore metadata?: MetadataStore + artifacts?: ArtifactStore + blobs?: BlobStore } /** @@ -465,6 +591,8 @@ const storeKeys = [ 'runs', 'interrupts', 'metadata', + 'artifacts', + 'blobs', ] satisfies Array const storeKeySet = new Set(storeKeys) @@ -499,8 +627,12 @@ export function validateChatPersistenceStores( } /** - * Generation middleware entrypoint rule: `runs` is required (run lifecycle is - * the only generation state this middleware tracks). + * Generation middleware entrypoint rules: + * - `runs` is required (run lifecycle is the generation state this middleware + * always tracks) + * - `artifacts` and `blobs` are optional, but come as a pair — the byte path + * writes the bytes to `blobs` and the describing row to `artifacts`, so one + * without the other is always a misconfiguration */ export function validateGenerationPersistenceStores( persistence: AIPersistence, @@ -509,6 +641,13 @@ export function validateGenerationPersistenceStores( if (!persistence.stores.runs) { throw new Error('Generation persistence requires stores.runs.') } + 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.', + ) + } } /** 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..77dc23aca --- /dev/null +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -0,0 +1,464 @@ +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 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('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-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 c903e3e94..c263b85d3 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -202,6 +202,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..cee0068c4 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:* @@ -32223,8 +32227,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@2.1.0: {} @@ -32374,7 +32378,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.7 - get-tsconfig: 4.13.0 + get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 From 0d15167e1594a635bb53fbb4dc3af4b2449bc033 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 16:41:44 +0200 Subject: [PATCH 19/22] docs(persistence): document generation media-byte storage (artifacts + blobs, serve route, extractArtifacts/nameArtifact) --- docs/persistence/generation-persistence.md | 110 ++++++++++++++++++--- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index f41c0cfc4..1fa665bd3 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,13 @@ 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 references to the output. 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 +89,88 @@ 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`. + +## 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//`, so a second +handler can read them back and serve them from your own origin: + +```ts group=generation-bytes +import { + generateImage, + generationParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiImage } from '@tanstack/ai-openai' +import { + memoryPersistence, + withGenerationPersistence, +} from '@tanstack/ai-persistence' + +const persistence = memoryPersistence() + +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) +} + +// Serve a stored artifact's bytes by id. +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 persistence.stores.artifacts?.get(artifactId) + if (!artifact) return new Response('not found', { status: 404 }) + + const blob = await persistence.stores.blobs?.get( + `artifacts/${artifact.runId}/${artifact.artifactId}`, + ) + 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 your 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 +259,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 never holds the generated bytes, only references to them. +Without an `artifacts` + `blobs` backend those references point at the provider's +own URL, which usually expires, so a snapshot opened much later can point at +media that is gone. Add the two stores (see +[Store the generated bytes](#store-the-generated-bytes)) to keep the files and +serve them from your own origin instead. From fc9a29680534bf783b6036cb40142d3411e44352 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 17:13:20 +0200 Subject: [PATCH 20/22] feat(ai-persistence): retrieveArtifact / retrieveBlob serve helpers Add retrieveArtifact(persistence, id) and retrieveBlob(persistence, idOrRecord) so a serve handler fetches a persisted generation artifact's metadata and bytes without hand-rolling the blob key. artifactBlobKey is the shared key builder used by both withGenerationPersistence (write) and retrieveBlob (read). --- .changeset/generation-persistence.md | 2 +- docs/persistence/generation-persistence.md | 8 ++-- packages/ai-persistence/src/index.ts | 5 ++- packages/ai-persistence/src/middleware.ts | 3 +- packages/ai-persistence/src/retrieve.ts | 45 +++++++++++++++++++ .../tests/generation-artifacts.test.ts | 32 +++++++++++++ 6 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 packages/ai-persistence/src/retrieve.ts diff --git a/.changeset/generation-persistence.md b/.changeset/generation-persistence.md index 9f08c75ed..3602d9f22 100644 --- a/.changeset/generation-persistence.md +++ b/.changeset/generation-persistence.md @@ -12,7 +12,7 @@ Add generation persistence: a lightweight client resume snapshot plus optional durable storage of the generated media bytes. -**Media byte storage.** `withGenerationPersistence` now persists the generated bytes when the persistence backend provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store. As an image/audio/speech/transcription run finishes, the middleware writes each artifact'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`. `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.** `withGenerationPersistence` now persists the generated bytes when the persistence backend provides both an `artifacts` (`ArtifactStore`) and a `blobs` (`BlobStore`) store. As an image/audio/speech/transcription run finishes, the middleware writes each artifact'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`. `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`. To serve a stored artifact, `@tanstack/ai-persistence` exports `retrieveArtifact(persistence, id)` and `retrieveBlob(persistence, idOrRecord)` (plus `artifactBlobKey`). Add client-side generation persistence: a lightweight, read-only resume snapshot for media generation activities. diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 1fa665bd3..cba06162f 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -114,6 +114,8 @@ import { import { openaiImage } from '@tanstack/ai-openai' import { memoryPersistence, + retrieveArtifact, + retrieveBlob, withGenerationPersistence, } from '@tanstack/ai-persistence' @@ -144,12 +146,10 @@ 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 persistence.stores.artifacts?.get(artifactId) + const artifact = await retrieveArtifact(persistence, artifactId) if (!artifact) return new Response('not found', { status: 404 }) - const blob = await persistence.stores.blobs?.get( - `artifacts/${artifact.runId}/${artifact.artifactId}`, - ) + const blob = await retrieveBlob(persistence, artifact) if (!blob) return new Response('not found', { status: 404 }) return new Response(blob.body ?? (await blob.arrayBuffer()), { diff --git a/packages/ai-persistence/src/index.ts b/packages/ai-persistence/src/index.ts index a6fbcaa59..25e556643 100644 --- a/packages/ai-persistence/src/index.ts +++ b/packages/ai-persistence/src/index.ts @@ -63,7 +63,10 @@ export type { 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 (state stores + generation media) export { memoryPersistence } from './memory' // Interrupt controller diff --git a/packages/ai-persistence/src/middleware.ts b/packages/ai-persistence/src/middleware.ts index 6c3de8faf..43090aba7 100644 --- a/packages/ai-persistence/src/middleware.ts +++ b/packages/ai-persistence/src/middleware.ts @@ -41,6 +41,7 @@ import type { InterruptRecord, RunStore, } from './types' +import { artifactBlobKey } from './retrieve' export interface WithPersistenceOptions { extractArtifacts?: ( @@ -702,7 +703,7 @@ async function persistGenerationArtifacts( const artifactId = ctx.createId('artifact') const { body, size, mimeType, externalUrl } = await descriptorBody(descriptor) - const key = `artifacts/${runId}/${artifactId}` + const key = artifactBlobKey({ runId, artifactId }) const stored = await persistence.stores.blobs.put(key, body, { contentType: mimeType, customMetadata: { 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/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index 77dc23aca..f2642cecb 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -8,6 +8,7 @@ import { 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, @@ -153,6 +154,37 @@ describe('withGenerationPersistence generation artifacts', () => { 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() From 4fc4605e5dc0602d8bd4ac57050ec1e173751d34 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 23 Jul 2026 17:45:16 +0200 Subject: [PATCH 21/22] docs(persistence): clarify the artifact serve route fetches files, not chat --- docs/persistence/generation-persistence.md | 26 +++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index cba06162f..e3e059980 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -102,8 +102,11 @@ 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//`, so a second -handler can read them back and serve them from your own origin: +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 by its id and streams the bytes +from your own origin. This is a plain file endpoint: it serves one stored file +and nothing more. It does not resume a run or rebuild a conversation. ```ts group=generation-bytes import { @@ -167,9 +170,22 @@ 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 your serve route. +### Fetching artifacts later + +Two pieces cooperate, and neither one rebuilds a chat: + +- The run **remembers which files it produced**. Each reference carries an + `artifactId` and shows up on the generation hook as `resultArtifacts` (and + `pendingArtifacts` while streaming). The client snapshot keeps these across a + reload, and you can also store them in your own database. +- The **`GET` route turns an `artifactId` into bytes**. Point an `` `src`, + a download link, or a later request at `/api/generate/image?id=` + and it streams the stored file. + +So a page that generated an image yesterday can show it today: take the +`artifactId` you kept and hit the serve route. That is the whole loop, one id in, +one file out. Rebuilding a conversation's stored messages is a separate concern +handled by the chat `reconstructChat` helper, not by anything here. ## Show the last run after a reload From a1f39a26ca1807cfe3d2ac4987ec68f5eff73828 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:54:17 +1000 Subject: [PATCH 22/22] fix(persistence): reconcile generation media bytes with contract-only core Resolve the rebase of the artifact/blob path onto the reworked ai-persistence: - memoryPersistence() keeps the base's no-locks decision and gains only artifacts + blobs; locks stay separate via InMemoryLockStore + withLocks. - AIPersistenceStores / storeKeys gain artifacts + blobs but not locks. - validateGenerationPersistenceStores and InvalidGenerationPersistence now carry both invariants: runs is required, and artifacts/blobs come as a pair. - withGenerationPersistence keeps the base's TEMPORARY/WRONG-SHAPE warning; the byte path does not fix the requestId-vs-runId keying, so the note now says so. - Type test widens memoryPersistence() past exact ChatPersistence equality; artifact pairing test gains a runs store so it exercises the pairing rule. --- .../tests/generation-artifacts.test.ts | 3 +++ .../tests/persistence-types.test-d.ts | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/ai-persistence/tests/generation-artifacts.test.ts b/packages/ai-persistence/tests/generation-artifacts.test.ts index f2642cecb..ebce9d867 100644 --- a/packages/ai-persistence/tests/generation-artifacts.test.ts +++ b/packages/ai-persistence/tests/generation-artifacts.test.ts @@ -449,8 +449,11 @@ describe('withGenerationPersistence generation artifacts', () => { it('fails early when artifact persistence is enabled without a paired blob store', () => { const full = memoryPersistence() + // `runs` is present so the required-runs rule passes and the artifacts / + // blobs pairing rule is the one under test. const persistence: AIPersistence = defineAIPersistence({ stores: { + runs: full.stores.runs, artifacts: full.stores.artifacts, }, }) diff --git a/packages/ai-persistence/tests/persistence-types.test-d.ts b/packages/ai-persistence/tests/persistence-types.test-d.ts index 60f040141..2c5d209aa 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,18 @@ const uncertainInherited = composePersistence(base, { }) expectTypeOf(uncertainInherited.stores.messages).toEqualTypeOf() -// Named shapes -expectTypeOf(memoryPersistence()).toEqualTypeOf() +// Named shapes. memoryPersistence() is the chat shape widened with the +// generation media stores, so it still satisfies every chat entrypoint while +// also opting into the byte path of withGenerationPersistence. +const memory = memoryPersistence() +expectTypeOf(memory.stores.messages).toEqualTypeOf() +expectTypeOf(memory.stores.runs).toEqualTypeOf() +expectTypeOf(memory.stores.interrupts).toEqualTypeOf() +expectTypeOf(memory.stores.metadata).toEqualTypeOf() +expectTypeOf(memory.stores.artifacts).toEqualTypeOf() +expectTypeOf(memory.stores.blobs).toEqualTypeOf() +withPersistence(memory) +withGenerationPersistence(memory) const transcript: ChatTranscriptPersistence = messagesOnly void transcript declare const fullChat: ChatPersistence