@tanstack/ai-client 0.23.3 → 0.25.1

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.
package/src/types.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  DocumentPart,
10
10
  ImagePart,
11
11
  InferSchemaType,
12
+ InterruptDefinition,
12
13
  InferToolInput,
13
14
  InferToolOutput,
14
15
  InputSchemaOf,
@@ -86,6 +87,51 @@ export interface GenericAGUIInterrupt extends BoundInterruptBase {
86
87
  resolveInterrupt: (payload: unknown) => void
87
88
  }
88
89
 
90
+ type InterruptResponseInput<TDefinition> =
91
+ TDefinition extends InterruptDefinition<any, any, infer TResponseSchema, any>
92
+ ? InferSchemaType<TResponseSchema>
93
+ : never
94
+
95
+ type RegisteredGenericInterruptFor<
96
+ TDefinition extends InterruptDefinition<any, any, any, any>,
97
+ > =
98
+ TDefinition extends InterruptDefinition<
99
+ infer TDefinitionId,
100
+ any,
101
+ any,
102
+ infer TPayload
103
+ >
104
+ ? BoundInterruptBase & {
105
+ readonly kind: 'generic'
106
+ readonly definitionId: TDefinitionId
107
+ readonly key: string
108
+ readonly payload: TPayload | undefined
109
+ readonly binding: Readonly<
110
+ Extract<InterruptBinding, { kind: 'generic' }> & {
111
+ definitionId: TDefinitionId
112
+ key: string
113
+ batchIndex: number
114
+ }
115
+ >
116
+ resolveInterrupt: (
117
+ response: InterruptResponseInput<TDefinition>,
118
+ ) => void
119
+ }
120
+ : never
121
+
122
+ export type RegisteredGenericInterrupt<
123
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
124
+ > = TInterrupts[number] extends infer TDefinition
125
+ ? TDefinition extends InterruptDefinition<any, any, any, any>
126
+ ? RegisteredGenericInterruptFor<TDefinition>
127
+ : never
128
+ : never
129
+
130
+ /** A bound generic interrupt for one `defineInterrupt()` definition. */
131
+ export type GenericInterrupt<
132
+ TDefinition extends InterruptDefinition<any, any, any, any>,
133
+ > = RegisteredGenericInterruptFor<TDefinition>
134
+
89
135
  /**
90
136
  * An interrupt that arrived on the stream carrying no resume binding this
91
137
  * client understands — no `tanstack:interruptBinding`, or one written at a
@@ -98,7 +144,10 @@ export interface GenericAGUIInterrupt extends BoundInterruptBase {
98
144
  * send an answer no one is waiting for. Render it, or route it to whatever
99
145
  * actually owns the pause.
100
146
  */
101
- export interface UnboundInterrupt extends BoundInterruptBase {
147
+ export interface UnboundInterrupt extends Omit<
148
+ BoundInterruptBase,
149
+ 'cancel' | 'clearResolution'
150
+ > {
102
151
  readonly kind: 'unbound'
103
152
  readonly binding?: undefined
104
153
  readonly canResolve: false
@@ -188,18 +237,37 @@ type ApprovalInterrupts<TTools extends ReadonlyArray<AnyClientTool>> =
188
237
  // union.
189
238
  export type ChatInterrupt<
190
239
  TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
191
- > = GenericAGUIInterrupt | UnboundInterrupt | ApprovalInterrupts<TTools>
240
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
241
+ readonly [],
242
+ > =
243
+ | GenericAGUIInterrupt
244
+ | RegisteredGenericInterrupt<TInterrupts>
245
+ | UnboundInterrupt
246
+ | ApprovalInterrupts<TTools>
247
+
248
+ export type ResolvableChatInterrupt<
249
+ TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
250
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
251
+ readonly [],
252
+ > =
253
+ | GenericAGUIInterrupt
254
+ | RegisteredGenericInterrupt<TInterrupts>
255
+ | ApprovalInterrupts<TTools>
192
256
 
193
257
  export type BoundInterrupts<
194
258
  TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
195
- > = ReadonlyArray<ChatInterrupt<TTools>>
259
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
260
+ readonly [],
261
+ > = ReadonlyArray<ChatInterrupt<TTools, TInterrupts>>
196
262
 
197
263
  export interface ChatInterruptState<
198
264
  TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
265
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
266
+ readonly [],
199
267
  > {
200
- readonly interrupts: BoundInterrupts<TTools>
268
+ readonly interrupts: BoundInterrupts<TTools, TInterrupts>
201
269
  /** @deprecated Use `interrupts`. Same snapshot today. */
202
- readonly pendingInterrupts: BoundInterrupts<TTools>
270
+ readonly pendingInterrupts: BoundInterrupts<TTools, TInterrupts>
203
271
  readonly interruptErrors: ReadonlyArray<BatchInterruptError>
204
272
  readonly resuming: boolean
205
273
  }
@@ -628,6 +696,36 @@ export type ChatPersistenceOption<
628
696
  TTools extends ReadonlyArray<AnyClientTool> = any,
629
697
  > = boolean | ChatClientPersistence<TTools>
630
698
 
699
+ /**
700
+ * The `persistence` / `threadId` pairing for `ChatClient` and the chat hooks.
701
+ *
702
+ * Persistence that is on (`true` or a storage adapter) requires a `threadId`.
703
+ * A minted id changes every reload, so nothing would restore. The compiler
704
+ * asks for the conversation id instead.
705
+ *
706
+ * Omit `persistence`, or set it to `false`, and `threadId` stays optional.
707
+ * The client then mints one after mount for the wire and DevTools.
708
+ *
709
+ * Intersect this onto `ChatClientOptions`. Do not apply a later plain `Omit`
710
+ * to that type: it collapses the union and the requirement disappears. Use
711
+ * {@link DistributedOmit}.
712
+ */
713
+ export type ChatPersistenceOptions<
714
+ TTools extends ReadonlyArray<AnyClientTool> = any,
715
+ > =
716
+ | {
717
+ persistence: true
718
+ threadId: string
719
+ }
720
+ | {
721
+ persistence: ChatClientPersistence<TTools>
722
+ threadId: string
723
+ }
724
+ | {
725
+ persistence?: false | undefined
726
+ threadId?: string
727
+ }
728
+
631
729
  type IsUnknown<T> = unknown extends T
632
730
  ? [T] extends [unknown]
633
731
  ? true
@@ -712,49 +810,14 @@ export type ClientContextOptionFromTools<TTools, TContext> = [
712
810
  export interface ChatClientBaseOptions<
713
811
  TTools extends ReadonlyArray<AnyClientTool> = any,
714
812
  TContext = unknown,
813
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
814
+ readonly [],
715
815
  > {
716
816
  /**
717
817
  * Initial messages to populate the chat
718
818
  */
719
819
  initialMessages?: Array<UIMessage<TTools>>
720
820
 
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
821
  /**
759
822
  * Initial resumable run state, useful when rehydrating a persisted client
760
823
  * after a full page reload. This restores the client-side interrupt
@@ -871,7 +934,7 @@ export interface ChatClientBaseOptions<
871
934
  */
872
935
  onResumeStateChange?: (
873
936
  resumeState: ChatResumeState | null,
874
- pendingInterrupts: BoundInterrupts<TTools>,
937
+ pendingInterrupts: BoundInterrupts<TTools, TInterrupts>,
875
938
  ) => void
876
939
 
877
940
  /**
@@ -880,8 +943,15 @@ export interface ChatClientBaseOptions<
880
943
  */
881
944
  onRunIdChange?: (runId: string | null) => void
882
945
 
883
- /** Callback when the immutable interrupt state snapshot changes. */
884
- onInterruptStateChange?: (state: ChatInterruptState<TTools>) => void
946
+ /**
947
+ * Callback when the immutable interrupt state snapshot changes.
948
+ * Snapshot restoration passes `{ source: 'hydrate' }`; streamed and
949
+ * client-initiated updates pass `{ source: 'live' }`.
950
+ */
951
+ onInterruptStateChange?: (
952
+ state: ChatInterruptState<TTools, TInterrupts>,
953
+ context: { source: 'hydrate' | 'live' },
954
+ ) => void
885
955
 
886
956
  /**
887
957
  * Callback when a custom event is received from a server-side tool.
@@ -903,6 +973,9 @@ export interface ChatClientBaseOptions<
903
973
  */
904
974
  tools?: TTools
905
975
 
976
+ /** First-party generic interrupts this client can type and resolve. */
977
+ interrupts?: TInterrupts
978
+
906
979
  /**
907
980
  * Devtools hook metadata for this client instance.
908
981
  */
@@ -932,14 +1005,21 @@ export interface ChatClientBaseOptions<
932
1005
 
933
1006
  /**
934
1007
  * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be
935
- * provided — the type-level XOR is enforced via `ChatTransport`.
1008
+ * provided — the type-level XOR is enforced via `ChatTransport`. Persistence
1009
+ * that is on requires a `threadId` via {@link ChatPersistenceOptions}.
936
1010
  */
937
1011
  export type ChatClientOptions<
938
1012
  TTools extends ReadonlyArray<AnyClientTool> = any,
939
1013
  TContext = InferredClientContext<TTools>,
940
- > = DistributedOmit<ChatClientBaseOptions<TTools, TContext>, 'context'> &
1014
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
1015
+ readonly [],
1016
+ > = DistributedOmit<
1017
+ ChatClientBaseOptions<TTools, TContext, TInterrupts>,
1018
+ 'context'
1019
+ > &
941
1020
  ClientContextOptionFromTools<TTools, TContext> &
942
- ChatTransport
1021
+ ChatTransport &
1022
+ ChatPersistenceOptions<TTools>
943
1023
 
944
1024
  export interface ChatRequestBody {
945
1025
  messages: Array<ModelMessage>
@@ -986,9 +1066,12 @@ export function clientTools<const T extends Array<AnyClientTool>>(
986
1066
  export function createChatClientOptions<
987
1067
  const TTools extends ReadonlyArray<AnyClientTool>,
988
1068
  TContext = InferredClientContext<TTools>,
1069
+ const TInterrupts extends ReadonlyArray<
1070
+ InterruptDefinition<any, any, any, any>
1071
+ > = readonly [],
989
1072
  >(
990
- options: ChatClientOptions<TTools, TContext>,
991
- ): ChatClientOptions<TTools, TContext> {
1073
+ options: ChatClientOptions<TTools, TContext, TInterrupts>,
1074
+ ): ChatClientOptions<TTools, TContext, TInterrupts> {
992
1075
  return options
993
1076
  }
994
1077
 
@@ -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
  }