@tanstack/ai-client 0.23.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,9 +26,11 @@ import type {
26
26
  GenerationClientOptions,
27
27
  GenerationClientState,
28
28
  GenerationFetcher,
29
+ GenerationPersistenceOptions,
29
30
  GenerationRestoredResult,
30
31
  GenerationResumeSnapshot,
31
32
  GenerationResumeState,
33
+ GenerationTransport,
32
34
  } from './generation-types'
33
35
 
34
36
  /**
@@ -108,10 +110,10 @@ export class GenerationClient<
108
110
  private readonly joinRunHandler:
109
111
  | ConnectConnectionAdapter['joinRun']
110
112
  | undefined
111
- private readonly uniqueId: string
113
+ private uniqueId: string
112
114
  private readonly devtoolsMetadata: AIDevtoolsClientMetadata
113
115
  private readonly devtoolsBridge: GenerationDevtoolsBridge<TOutput>
114
- private readonly threadId: string
116
+ private threadId: string
115
117
  private readonly persistenceScope: string | undefined
116
118
  // Server-driven mode (`persistence: true`): no local snapshot store; on mount
117
119
  // the client hydrates the last generation for `threadId` from the server.
@@ -133,26 +135,20 @@ export class GenerationClient<
133
135
  private serverHydrationStarted = false
134
136
 
135
137
  constructor(
136
- options: GenerationClientOptions<TInput, TResult, TOutput> &
137
- (
138
- | { connection: ConnectConnectionAdapter; fetcher?: never }
139
- | {
140
- fetcher: GenerationFetcher<TInput, TResult>
141
- connection?: never
142
- }
143
- ),
138
+ options: Omit<
139
+ GenerationClientOptions<TInput, TResult, TOutput>,
140
+ 'persistence' | 'threadId'
141
+ > &
142
+ GenerationPersistenceOptions &
143
+ GenerationTransport<TInput, TResult>,
144
144
  ) {
145
- // `threadId` is the single identity. Deprecated `id` is only a fallback
146
- // when no threadId is given (ephemeral runs / legacy call sites).
147
- this.uniqueId =
148
- options.threadId ?? options.id ?? this.generateUniqueId('generation')
149
- // AG-UI requires a thread id on every run, so fall back to uniqueId. The
150
- // generated fallback is for the WIRE ONLY: it is not stable across reloads,
151
- // so persistence must never key on it — see `persistenceScope` below.
152
- this.threadId = options.threadId ?? this.uniqueId
153
- // The persistence scope: the explicit `threadId` and nothing else. The
154
- // types require it whenever `persistence` is set; this field keeps the
155
- // fallback from silently becoming a storage key for JS callers.
145
+ // `threadId` is the only identity. Do not mint a random id during
146
+ // construct: hooks build this client during render.
147
+ this.threadId = options.threadId ?? ''
148
+ this.uniqueId = this.threadId
149
+ // The persistence scope is the explicit `threadId` and nothing else.
150
+ // The types require it whenever `persistence` is set. This field keeps a
151
+ // generated wire id from becoming a storage key for JS callers.
156
152
  this.persistenceScope = options.threadId
157
153
  this.connection = options.connection
158
154
  this.fetcher = options.fetcher
@@ -199,10 +195,17 @@ export class GenerationClient<
199
195
  }
200
196
 
201
197
  private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions<TOutput> {
198
+ const client = this
202
199
  return {
203
- hookId: this.uniqueId,
204
- clientId: this.uniqueId,
205
- threadId: this.threadId,
200
+ get hookId() {
201
+ return client.uniqueId
202
+ },
203
+ get clientId() {
204
+ return client.uniqueId
205
+ },
206
+ get threadId() {
207
+ return client.threadId
208
+ },
206
209
  metadata: this.devtoolsMetadata,
207
210
  getCoreState: () => ({
208
211
  input: this.input,
@@ -216,6 +219,7 @@ export class GenerationClient<
216
219
  }
217
220
 
218
221
  mountDevtools(): void {
222
+ this.ensureThreadId()
219
223
  // Mounting revives a disposed client. Framework hooks call this from
220
224
  // their mount effect, so a dispose → remount cycle (e.g. React
221
225
  // StrictMode's mount → cleanup → mount replay against the same memoized
@@ -629,6 +633,14 @@ export class GenerationClient<
629
633
  }
630
634
  }
631
635
 
636
+ private ensureThreadId(): string {
637
+ if (!this.threadId) {
638
+ this.threadId = this.generateUniqueId('generation')
639
+ }
640
+ this.uniqueId = this.threadId
641
+ return this.threadId
642
+ }
643
+
632
644
  private generateUniqueId(prefix: string): string {
633
645
  return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
634
646
  }
@@ -201,27 +201,23 @@ export interface GenerationResumeSnapshot {
201
201
  }
202
202
 
203
203
  /**
204
- * The `persistence` / `threadId` / `id` identity shared by every generation hook.
204
+ * The `persistence` / `threadId` identity shared by every generation hook.
205
205
  *
206
206
  * Turning persistence on **requires** a `threadId`, the stable scope runs are
207
207
  * filed under. Without one the client would hydrate by a generated id that
208
208
  * changes every reload, so nothing would ever restore; making it a type error
209
209
  * means the compiler asks for the scope instead of the runtime inventing one.
210
210
  *
211
- * `threadId` is the single identity for the hook, the AG-UI wire thread, and
212
- * persistence. Legacy `id` is deprecated and typed `never` whenever
213
- * `threadId` is supplied — pass one scope, not two.
211
+ * `threadId` is the only identity for the hook, the AG-UI wire thread, and
212
+ * persistence. There is no separate instance `id`.
214
213
  *
215
- * Ephemeral generations (no `persistence`, or `persistence: false`) may still
216
- * pass a deprecated `id` when they have no `threadId`, as a wire/devtools
217
- * fallback. Prefer giving them a `threadId` instead.
218
- *
219
- * USAGE: intersect this onto a hook's parameter and subtract the three keys
220
- * from the options interface, leaving that interface a plain (non-union)
214
+ * USAGE: intersect this onto a hook's parameter and subtract `persistence`
215
+ * and `threadId` from the options interface (plus any other keys the hook
216
+ * redeclares, such as `onResult`), leaving that interface a plain (non-union)
221
217
  * object so `Pick` / `Omit` composition elsewhere keeps working:
222
218
  *
223
219
  * ```ts
224
- * options: Omit<UseGenerateImageOptions, 'onResult' | 'persistence' | 'threadId' | 'id'> & {
220
+ * options: Omit<UseGenerateImageOptions, 'onResult' | 'persistence' | 'threadId'> & {
225
221
  * onResult?: (result: ImageGenerationResult) => TTransformed
226
222
  * } & GenerationPersistenceOptions
227
223
  * ```
@@ -233,31 +229,13 @@ export interface GenerationResumeSnapshot {
233
229
  export type GenerationPersistenceOptions =
234
230
  | {
235
231
  persistence: true
236
- /** Required by `persistence` — the stable scope runs are filed under. */
237
- threadId: string
238
- /**
239
- * @deprecated Prefer `threadId`. Not allowed when `threadId` is set —
240
- * `threadId` is the single identity for the hook, the wire, and persistence.
241
- */
242
- id?: never
243
- }
244
- | {
245
- persistence?: false | undefined
246
- /** Stable scope for the generation slot (also the wire / devtools identity). */
232
+ /** Required by `persistence`. The stable scope runs are filed under. */
247
233
  threadId: string
248
- /**
249
- * @deprecated Prefer `threadId`. Not allowed when `threadId` is set.
250
- */
251
- id?: never
252
234
  }
253
235
  | {
254
236
  persistence?: false | undefined
255
- threadId?: undefined
256
- /**
257
- * @deprecated Prefer `threadId` as the single identity. Only allowed when
258
- * `threadId` is omitted — legacy wire/devtools fallback for ephemeral runs.
259
- */
260
- id?: string
237
+ /** Stable scope for the generation slot (also the wire / DevTools identity). */
238
+ threadId?: string
261
239
  }
262
240
 
263
241
  // ===========================
@@ -330,28 +308,19 @@ export type GenerationTransport<TInput, TResult> =
330
308
  */
331
309
  // eslint-disable-next-line @typescript-eslint/naming-convention -- _TInput is unused in the interface body but part of the public positional generic API (callers supply it for inference)
332
310
  export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
333
- /**
334
- * @deprecated Prefer {@link GenerationClientOptions.threadId}. Legacy instance
335
- * id used only as a wire/devtools fallback when `threadId` is omitted. When
336
- * both are passed, `threadId` wins and `id` is ignored. Framework hooks type
337
- * `id` as `never` whenever `threadId` is set — see
338
- * {@link GenerationPersistenceOptions}.
339
- */
340
- id?: string
341
-
342
311
  /**
343
312
  * The **scope** this generation belongs to: a stable, app-chosen name for the
344
313
  * slot successive runs fill, not a link to a chat conversation. This is the
345
- * single identity for the client — wire thread id, devtools hook id, and
314
+ * only identity for the client: wire thread id, DevTools hook id, and
346
315
  * persistence key.
347
316
  *
348
- * A generation hook starts empty and produces many runs over its life — each
317
+ * A generation hook starts empty and produces many runs over its life. Each
349
318
  * run gets its own `runId`, but they all belong to one scope. Persistence
350
319
  * keys on this: server-driven hydrates the last run for it on mount. It is
351
320
  * also sent as the AG-UI thread id on the wire, since the protocol requires
352
321
  * one.
353
322
  *
354
- * Derive it from your own domain — it must be meaningful before any media
323
+ * Derive it from your own domain. It must be meaningful before any media
355
324
  * exists and identical after a reload:
356
325
  *
357
326
  * ```ts
@@ -360,9 +329,8 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
360
329
  *
361
330
  * **Required whenever `persistence` is set.** An app that cannot name the
362
331
  * scope has nothing to restore *to*, and a generated fallback would key each
363
- * reload differently — silently restoring nothing. Optional only for
364
- * ephemeral runs, where it falls back to deprecated `id` (or a generated id)
365
- * purely to satisfy the wire and nothing is written.
332
+ * reload differently, silently restoring nothing. Optional only for
333
+ * ephemeral runs. If omitted, the client mints a wire id after mount.
366
334
  */
367
335
  threadId?: string
368
336
 
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export type {
30
30
  ChatClientPersistence,
31
31
  ChatPersistedState,
32
32
  ChatPersistenceOption,
33
+ ChatPersistenceOptions,
33
34
  ChatStorageAdapter,
34
35
  ChatClientOptions,
35
36
  ChatPendingInterrupt,
@@ -143,6 +144,7 @@ export {
143
144
  xhrHttpStream,
144
145
  stream,
145
146
  rpcStream,
147
+ webSocket,
146
148
  StreamTruncatedError,
147
149
  DurableStreamIncompleteError,
148
150
  StreamReconnectLimitError,
@@ -155,6 +157,7 @@ export {
155
157
  type RunAgentInputContext,
156
158
  type StreamConnectionHandlers,
157
159
  type SubscribeConnectionAdapter,
160
+ type WebSocketConnectionOptions,
158
161
  type XhrConnectionOptions,
159
162
  } from './connection-adapters'
160
163
 
package/src/types.ts CHANGED
@@ -628,6 +628,36 @@ export type ChatPersistenceOption<
628
628
  TTools extends ReadonlyArray<AnyClientTool> = any,
629
629
  > = boolean | ChatClientPersistence<TTools>
630
630
 
631
+ /**
632
+ * The `persistence` / `threadId` pairing for `ChatClient` and the chat hooks.
633
+ *
634
+ * Persistence that is on (`true` or a storage adapter) requires a `threadId`.
635
+ * A minted id changes every reload, so nothing would restore. The compiler
636
+ * asks for the conversation id instead.
637
+ *
638
+ * Omit `persistence`, or set it to `false`, and `threadId` stays optional.
639
+ * The client then mints one after mount for the wire and DevTools.
640
+ *
641
+ * Intersect this onto `ChatClientOptions`. Do not apply a later plain `Omit`
642
+ * to that type: it collapses the union and the requirement disappears. Use
643
+ * {@link DistributedOmit}.
644
+ */
645
+ export type ChatPersistenceOptions<
646
+ TTools extends ReadonlyArray<AnyClientTool> = any,
647
+ > =
648
+ | {
649
+ persistence: true
650
+ threadId: string
651
+ }
652
+ | {
653
+ persistence: ChatClientPersistence<TTools>
654
+ threadId: string
655
+ }
656
+ | {
657
+ persistence?: false | undefined
658
+ threadId?: string
659
+ }
660
+
631
661
  type IsUnknown<T> = unknown extends T
632
662
  ? [T] extends [unknown]
633
663
  ? true
@@ -718,43 +748,6 @@ export interface ChatClientBaseOptions<
718
748
  */
719
749
  initialMessages?: Array<UIMessage<TTools>>
720
750
 
721
- /**
722
- * How this chat persists across reloads. See {@link ChatPersistenceOption}.
723
- *
724
- * - Omit or `false`: ephemeral, in-memory only.
725
- * - `true`: server-authoritative. The client caches nothing and hydrates the
726
- * thread from the server by its `threadId` on mount (needs a connection with
727
- * a `hydrate` handler). Big transcripts never touch the browser, and the same
728
- * thread opens the same way on another device.
729
- * - a {@link ChatClientPersistence} adapter: client-authoritative. The combined
730
- * {@link ChatPersistedState} record (transcript plus resume pointer) is cached
731
- * in the browser, restoring the transcript, pending interrupts, and an
732
- * in-flight run on reload.
733
- *
734
- * Use `initialResumeSnapshot` for a host-supplied in-memory rehydrate instead.
735
- */
736
- persistence?: ChatPersistenceOption<TTools>
737
-
738
- /**
739
- * Optional storage-key override for this chat instance, and the devtools
740
- * instance id. Persistence keys on `threadId` by default; set `id` only when
741
- * you need the persisted record keyed separately from the wire thread.
742
- * Prefer a stable `threadId` for the common case.
743
- *
744
- * The framework hooks (`useChat` / `createChat`) do NOT expose `id`: a hook's
745
- * identity is its `threadId`. This lower-level escape hatch exists only for
746
- * direct `ChatClient` construction.
747
- */
748
- id?: string
749
-
750
- /**
751
- * The conversation id for this chat, stable across sends and reloads. It is
752
- * the AG-UI thread key on the wire AND the key client persistence stores the
753
- * conversation under, so set a stable `threadId` to have a reload restore the
754
- * same conversation. If omitted, a unique thread id is generated per session.
755
- */
756
- threadId?: string
757
-
758
751
  /**
759
752
  * Initial resumable run state, useful when rehydrating a persisted client
760
753
  * after a full page reload. This restores the client-side interrupt
@@ -932,14 +925,16 @@ export interface ChatClientBaseOptions<
932
925
 
933
926
  /**
934
927
  * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be
935
- * provided — the type-level XOR is enforced via `ChatTransport`.
928
+ * provided — the type-level XOR is enforced via `ChatTransport`. Persistence
929
+ * that is on requires a `threadId` via {@link ChatPersistenceOptions}.
936
930
  */
937
931
  export type ChatClientOptions<
938
932
  TTools extends ReadonlyArray<AnyClientTool> = any,
939
933
  TContext = InferredClientContext<TTools>,
940
934
  > = DistributedOmit<ChatClientBaseOptions<TTools, TContext>, 'context'> &
941
935
  ClientContextOptionFromTools<TTools, TContext> &
942
- ChatTransport
936
+ ChatTransport &
937
+ ChatPersistenceOptions<TTools>
943
938
 
944
939
  export interface ChatRequestBody {
945
940
  messages: Array<ModelMessage>
@@ -25,8 +25,10 @@ import type {
25
25
  import type {
26
26
  GenerationClientState,
27
27
  GenerationFetcher,
28
+ GenerationPersistenceOptions,
28
29
  GenerationResumeSnapshot,
29
30
  GenerationResumeState,
31
+ GenerationTransport,
30
32
  VideoGenerateInput,
31
33
  VideoGenerateResult,
32
34
  VideoGenerationClientOptions,
@@ -111,10 +113,10 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
111
113
  private readonly fetcher:
112
114
  | GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
113
115
  | undefined
114
- private readonly uniqueId: string
116
+ private uniqueId: string
115
117
  private readonly devtoolsMetadata: AIDevtoolsClientMetadata
116
118
  private readonly devtoolsBridge: VideoDevtoolsBridge<TOutput>
117
- private readonly threadId: string
119
+ private threadId: string
118
120
  // Server-driven mode (`persistence: true`): no local snapshot store; on mount
119
121
  // the client hydrates the last generation for `threadId` from the server.
120
122
  private readonly serverDriven: boolean = false
@@ -137,22 +139,17 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
137
139
  private serverHydrationStarted = false
138
140
 
139
141
  constructor(
140
- options: VideoGenerationClientOptions<TOutput> &
141
- (
142
- | { connection: ConnectConnectionAdapter; fetcher?: never }
143
- | {
144
- fetcher: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
145
- connection?: never
146
- }
147
- ),
142
+ options: Omit<
143
+ VideoGenerationClientOptions<TOutput>,
144
+ 'persistence' | 'threadId'
145
+ > &
146
+ GenerationPersistenceOptions &
147
+ GenerationTransport<VideoGenerateInput, VideoGenerateResult>,
148
148
  ) {
149
- // `threadId` is the single identity. Deprecated `id` is only a fallback
150
- // when no threadId is given (ephemeral runs / legacy call sites).
151
- this.uniqueId =
152
- options.threadId ?? options.id ?? this.generateUniqueId('video')
153
- // The wire/hydration thread key. Server-driven mode needs a stable key, so
154
- // prefer an explicit `threadId`, then legacy `id`, then a generated id.
155
- this.threadId = options.threadId ?? this.uniqueId
149
+ // `threadId` is the only identity. Do not mint a random id during
150
+ // construct: hooks build this client during render.
151
+ this.threadId = options.threadId ?? ''
152
+ this.uniqueId = this.threadId
156
153
  this.connection = options.connection
157
154
  this.fetcher = options.fetcher
158
155
  this.hydrateGenerationHandler = options.hydrateGeneration
@@ -192,10 +189,17 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
192
189
  }
193
190
 
194
191
  private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions<TOutput> {
192
+ const client = this
195
193
  return {
196
- hookId: this.uniqueId,
197
- clientId: this.uniqueId,
198
- threadId: this.threadId,
194
+ get hookId() {
195
+ return client.uniqueId
196
+ },
197
+ get clientId() {
198
+ return client.uniqueId
199
+ },
200
+ get threadId() {
201
+ return client.threadId
202
+ },
199
203
  metadata: this.devtoolsMetadata,
200
204
  getCoreState: () => ({
201
205
  input: this.input,
@@ -211,6 +215,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
211
215
  }
212
216
 
213
217
  mountDevtools(): void {
218
+ this.ensureThreadId()
214
219
  // Mounting revives a disposed client. Framework hooks call this from
215
220
  // their mount effect, so a dispose → remount cycle (e.g. React
216
221
  // StrictMode's mount → cleanup → mount replay against the same memoized
@@ -677,6 +682,14 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
677
682
  }
678
683
  }
679
684
 
685
+ private ensureThreadId(): string {
686
+ if (!this.threadId) {
687
+ this.threadId = this.generateUniqueId('video')
688
+ }
689
+ this.uniqueId = this.threadId
690
+ return this.threadId
691
+ }
692
+
680
693
  private generateUniqueId(prefix: string): string {
681
694
  return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
682
695
  }