@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.
@@ -18,6 +18,7 @@ import { InterruptManager } from './interrupt-manager'
18
18
  import type {
19
19
  AnyClientTool,
20
20
  ContentPart,
21
+ InterruptDefinition,
21
22
  InterruptSubmissionError,
22
23
  ModelMessage,
23
24
  RunAgentResumeItem,
@@ -42,8 +43,8 @@ import type {
42
43
  ChatClientOptions,
43
44
  ChatClientState,
44
45
  ChatFetcher,
45
- ChatInterrupt,
46
46
  ChatInterruptState,
47
+ ResolvableChatInterrupt,
47
48
  ChatPendingInterrupt,
48
49
  ChatResumeSnapshot,
49
50
  ChatResumeState,
@@ -59,15 +60,34 @@ import type {
59
60
  UIMessage,
60
61
  WhenBusy,
61
62
  } from './types'
62
- import type { InterruptManagerSubmission } from './interrupt-manager'
63
+ import type {
64
+ InterruptManagerChangeSource,
65
+ InterruptManagerSubmission,
66
+ } from './interrupt-manager'
63
67
 
64
68
  /** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */
65
69
  interface InternalQueuedMessage extends QueuedMessage {
66
70
  body?: Record<string, any>
67
71
  }
68
72
 
73
+ function assertUniqueInterruptDefinitions(
74
+ interrupts:
75
+ | ReadonlyArray<InterruptDefinition<any, any, any, any>>
76
+ | undefined,
77
+ ): void {
78
+ const ids = new Set<string>()
79
+ for (const interrupt of interrupts ?? []) {
80
+ if (ids.has(interrupt.id)) {
81
+ throw new Error(`Duplicate interrupt definition id: ${interrupt.id}`)
82
+ }
83
+ ids.add(interrupt.id)
84
+ }
85
+ }
86
+
69
87
  type ChatClientUpdateOptionsWithoutContext<
70
88
  TTools extends ReadonlyArray<AnyClientTool>,
89
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
90
+ readonly [],
71
91
  > = {
72
92
  connection?: ConnectionAdapter
73
93
  fetcher?: ChatFetcher
@@ -75,6 +95,7 @@ type ChatClientUpdateOptionsWithoutContext<
75
95
  body?: Record<string, any>
76
96
  forwardedProps?: Record<string, any>
77
97
  tools?: TTools
98
+ interrupts?: TInterrupts
78
99
  queue?: QueueOption
79
100
  onResponse?: (response?: Response) => void | Promise<void>
80
101
  onChunk?: (chunk: StreamChunk) => void
@@ -86,14 +107,17 @@ type ChatClientUpdateOptionsWithoutContext<
86
107
  onQueueChange?: (queue: Array<QueuedMessage>) => void
87
108
  onResumeStateChange?: (
88
109
  resumeState: ChatResumeState | null,
89
- pendingInterrupts: BoundInterrupts<TTools>,
110
+ pendingInterrupts: BoundInterrupts<TTools, TInterrupts>,
90
111
  ) => void
91
112
  /**
92
113
  * Fires whenever the id of the run in flight changes: the new id when a run
93
114
  * starts (including a rejoin), `null` when it settles.
94
115
  */
95
116
  onRunIdChange?: (runId: string | null) => void
96
- onInterruptStateChange?: (state: ChatInterruptState<TTools>) => void
117
+ onInterruptStateChange?: (
118
+ state: ChatInterruptState<TTools, TInterrupts>,
119
+ context: { source: 'hydrate' | 'live' },
120
+ ) => void
97
121
  onCustomEvent?: (
98
122
  eventType: string,
99
123
  data: unknown,
@@ -271,11 +295,13 @@ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
271
295
  export class ChatClient<
272
296
  TTools extends ReadonlyArray<AnyClientTool> = any,
273
297
  TContext = unknown,
298
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
299
+ any,
274
300
  > {
275
301
  private readonly processor: StreamProcessor
276
302
  private connection: SubscribeConnectionAdapter
277
- private readonly uniqueId: string
278
- private readonly threadId: string
303
+ private uniqueId: string
304
+ private threadId: string
279
305
  // Durable chat persistence (optional): messages + resume snapshot as one
280
306
  // combined record, so a full page reload restores the transcript, rehydrates
281
307
  // pending interrupts, and rejoins an in-flight run. Clear-during-stream
@@ -292,7 +318,7 @@ export class ChatClient<
292
318
  // run is rejoined at most once even when both the sync read and the async
293
319
  // hydrate surface the same resume pointer.
294
320
  private rejoinedRunId: string | null = null
295
- private readonly interruptManager: InterruptManager<TTools>
321
+ private readonly interruptManager: InterruptManager<TTools, TInterrupts>
296
322
  private activeInterruptSubmission: InterruptManagerSubmission | undefined
297
323
  private interruptSubmissionFailure:
298
324
  | { errors: ReadonlyArray<InterruptSubmissionError> }
@@ -397,10 +423,13 @@ export class ChatClient<
397
423
  onQueueChange: (queue: Array<QueuedMessage>) => void
398
424
  onResumeStateChange: (
399
425
  resumeState: ChatResumeState | null,
400
- pendingInterrupts: BoundInterrupts<TTools>,
426
+ pendingInterrupts: BoundInterrupts<TTools, TInterrupts>,
401
427
  ) => void
402
428
  onRunIdChange: (runId: string | null) => void
403
- onInterruptStateChange: (state: ChatInterruptState<TTools>) => void
429
+ onInterruptStateChange: (
430
+ state: ChatInterruptState<TTools, TInterrupts>,
431
+ context: { source: 'hydrate' | 'live' },
432
+ ) => void
404
433
  onCustomEvent: (
405
434
  eventType: string,
406
435
  data: unknown,
@@ -409,13 +438,14 @@ export class ChatClient<
409
438
  }
410
439
  }
411
440
 
412
- constructor(options: ChatClientOptions<TTools, TContext>) {
413
- this.threadId = options.threadId || this.generateUniqueId('thread')
414
- // The instance/devtools id defaults to the threadId (the chat's identity),
415
- // falling back to a generated id only when neither is set. `id` overrides it
416
- // only for direct ChatClient users who key storage separately from the wire
417
- // thread; the framework hooks never pass it.
418
- this.uniqueId = options.id || this.threadId
441
+ constructor(options: ChatClientOptions<TTools, TContext, TInterrupts>) {
442
+ assertUniqueInterruptDefinitions(options.interrupts)
443
+ // Do not mint a random thread id during construct. Framework hooks build
444
+ // this client during render (SSR included). The wire/devtools identity is
445
+ // `threadId`; it is assigned here when the caller passed one, or later in
446
+ // `ensureThreadId()` from attach / mount / send.
447
+ this.threadId = options.threadId || ''
448
+ this.uniqueId = this.threadId
419
449
  // `persistence` is `false`/omitted (ephemeral, in-memory), `true`
420
450
  // (server-authoritative: cache nothing client-side, hydrate the thread from
421
451
  // the server by `threadId` on mount), or a storage adapter
@@ -424,17 +454,24 @@ export class ChatClient<
424
454
  // the mount hydration and keeps a client record from shadowing server history.
425
455
  let cachesMessages = true
426
456
  if (options.persistence === true) {
457
+ if (!options.threadId) {
458
+ throw new Error(
459
+ '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).',
460
+ )
461
+ }
427
462
  cachesMessages = false
428
463
  } else if (options.persistence) {
429
464
  // A storage adapter: keep the combined record (transcript + resume pointer)
430
465
  // in the browser. Persistence keys on `threadId` (the conversation
431
- // identity) so a reload with the same `threadId` finds the same record;
432
- // `id` overrides it only when set, for apps that key storage separately
433
- // from the wire thread.
434
- const persistenceKey = options.id ?? this.threadId
466
+ // identity) so a reload with the same `threadId` finds the same record.
467
+ if (!options.threadId) {
468
+ throw new Error(
469
+ '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).',
470
+ )
471
+ }
435
472
  this.persistor = new ChatPersistor(
436
473
  options.persistence,
437
- persistenceKey,
474
+ options.threadId,
438
475
  (messages) => this.processor.setMessages(messages),
439
476
  (snapshot) => this.applyPersistedResume(snapshot),
440
477
  )
@@ -486,10 +523,13 @@ export class ChatClient<
486
523
  },
487
524
  }
488
525
 
489
- this.interruptManager = new InterruptManager({
526
+ this.interruptManager = new InterruptManager<TTools, TInterrupts>({
490
527
  ...(options.tools !== undefined ? { tools: options.tools } : {}),
528
+ ...(options.interrupts !== undefined
529
+ ? { interrupts: options.interrupts }
530
+ : {}),
491
531
  submit: (submission) => this.submitInterruptBatch(submission),
492
- onChange: () => this.notifyResumeStateChange(),
532
+ onChange: (source) => this.notifyResumeStateChange(source),
493
533
  })
494
534
 
495
535
  // In-memory rehydrate of interrupt descriptors (e.g. after a page reload
@@ -798,6 +838,7 @@ export class ChatClient<
798
838
  */
799
839
  attach(): void {
800
840
  if (this.disposed || this.tailing) return
841
+ this.ensureThreadId()
801
842
  this.tailing = true
802
843
 
803
844
  // Full page reload with an in-flight run persisted (synchronous store):
@@ -848,7 +889,7 @@ export class ChatClient<
848
889
  private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void {
849
890
  const resumeState = readResumeState(snapshot)
850
891
  if (resumeState === undefined) {
851
- this.interruptManager.reset()
892
+ this.interruptManager.reset({ source: 'hydrate' })
852
893
  return
853
894
  }
854
895
  this.lastResume = resumeState
@@ -856,16 +897,19 @@ export class ChatClient<
856
897
  ? snapshot.pendingInterrupts
857
898
  : []
858
899
  if (pendingInterrupts.length === 0) {
859
- this.interruptManager.reset()
900
+ this.interruptManager.reset({ source: 'hydrate' })
860
901
  return
861
902
  }
862
903
  const generation = this.interruptGeneration(pendingInterrupts)
863
- this.interruptManager.hydrate({
864
- threadId: resumeState.threadId,
865
- interruptedRunId: resumeState.runId,
866
- generation,
867
- interrupts: pendingInterrupts,
868
- })
904
+ this.interruptManager.hydrate(
905
+ {
906
+ threadId: resumeState.threadId,
907
+ interruptedRunId: resumeState.runId,
908
+ generation,
909
+ interrupts: pendingInterrupts,
910
+ },
911
+ 'hydrate',
912
+ )
869
913
  }
870
914
 
871
915
  /**
@@ -973,6 +1017,7 @@ export class ChatClient<
973
1017
  }
974
1018
 
975
1019
  mountDevtools(): void {
1020
+ this.ensureThreadId()
976
1021
  if (this.devtoolsMounted) {
977
1022
  return
978
1023
  }
@@ -981,6 +1026,14 @@ export class ChatClient<
981
1026
  this.devtoolsBridge.mountWithTools(this.processor.getMessages().length)
982
1027
  }
983
1028
 
1029
+ private ensureThreadId(): string {
1030
+ if (!this.threadId) {
1031
+ this.threadId = this.generateUniqueId('thread')
1032
+ }
1033
+ this.uniqueId = this.threadId
1034
+ return this.threadId
1035
+ }
1036
+
984
1037
  /**
985
1038
  * Drain a runId-less RUN_ERROR that belongs to a cleared run the client is
986
1039
  * still tracking. The persistor owns the cleared-run bookkeeping; the client
@@ -1085,12 +1138,15 @@ export class ChatClient<
1085
1138
  threadId: threadId ?? this.threadId,
1086
1139
  runId: interruptedRunId,
1087
1140
  }
1088
- this.interruptManager.hydrate({
1089
- threadId: this.lastResume.threadId,
1090
- interruptedRunId,
1091
- generation: this.interruptGeneration(chunk.outcome.interrupts),
1092
- interrupts: chunk.outcome.interrupts,
1093
- })
1141
+ this.interruptManager.hydrate(
1142
+ {
1143
+ threadId: this.lastResume.threadId,
1144
+ interruptedRunId,
1145
+ generation: this.interruptGeneration(chunk.outcome.interrupts),
1146
+ interrupts: chunk.outcome.interrupts,
1147
+ },
1148
+ 'live',
1149
+ )
1094
1150
  return
1095
1151
  }
1096
1152
 
@@ -1137,7 +1193,7 @@ export class ChatClient<
1137
1193
  this.interruptManager.reset()
1138
1194
  return
1139
1195
  }
1140
- this.notifyResumeStateChange()
1196
+ this.notifyResumeStateChange('live')
1141
1197
  }
1142
1198
 
1143
1199
  /**
@@ -1166,25 +1222,37 @@ export class ChatClient<
1166
1222
  this.callbacksRef.current.onRunIdChange(runId)
1167
1223
  }
1168
1224
 
1169
- getInterruptState(): ChatInterruptState<TTools> {
1225
+ getInterruptState(): ChatInterruptState<TTools, TInterrupts> {
1170
1226
  return this.interruptManager.getState()
1171
1227
  }
1172
1228
 
1173
- getInterrupts(): BoundInterrupts<TTools> {
1174
- return this.interruptManager.getInterrupts()
1229
+ getInterrupts(): BoundInterrupts<TTools, TInterrupts> {
1230
+ return this.interruptManager.getInterrupts() as BoundInterrupts<
1231
+ TTools,
1232
+ TInterrupts
1233
+ >
1175
1234
  }
1176
1235
 
1177
1236
  /** @deprecated Use getInterrupts(). */
1178
- getPendingInterrupts(): BoundInterrupts<TTools> {
1179
- return this.interruptManager.getInterrupts()
1237
+ getPendingInterrupts(): BoundInterrupts<TTools, TInterrupts> {
1238
+ return this.interruptManager.getInterrupts() as BoundInterrupts<
1239
+ TTools,
1240
+ TInterrupts
1241
+ >
1180
1242
  }
1181
1243
 
1182
1244
  resolveInterrupts(approved: boolean): void
1183
1245
  resolveInterrupts(
1184
- resolver: (interrupt: ChatInterrupt<TTools>) => undefined,
1246
+ resolver: (
1247
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
1248
+ ) => undefined,
1185
1249
  ): void
1186
1250
  resolveInterrupts(
1187
- resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => undefined),
1251
+ resolution:
1252
+ | boolean
1253
+ | ((
1254
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
1255
+ ) => undefined),
1188
1256
  ): void {
1189
1257
  // Branch so TypeScript can select the InterruptManager.resolve overloads.
1190
1258
  if (typeof resolution === 'boolean') {
@@ -1345,19 +1413,20 @@ export class ChatClient<
1345
1413
  this.devtoolsBridge.emitSnapshot()
1346
1414
  }
1347
1415
 
1348
- private notifyResumeStateChange(): void {
1416
+ private notifyResumeStateChange(source: InterruptManagerChangeSource): void {
1349
1417
  const resumeState = this.getResumeState()
1418
+ // Capture state before invoking callbacks so a synchronous nested change
1419
+ // cannot pair this publication's source with a later manager snapshot.
1420
+ const interruptState = this.interruptManager.getState()
1350
1421
  // Persist (or clear) the durable resume snapshot so a full page reload can
1351
1422
  // rehydrate pending interrupts and rejoin the run. Folded into the same
1352
1423
  // persistence adapter that stores messages (one record per chat).
1353
1424
  this.persistResumeSnapshot(resumeState)
1354
1425
  this.callbacksRef.current.onResumeStateChange(
1355
1426
  resumeState,
1356
- this.interruptManager.getInterrupts(),
1357
- )
1358
- this.callbacksRef.current.onInterruptStateChange(
1359
- this.interruptManager.getState(),
1427
+ interruptState.interrupts,
1360
1428
  )
1429
+ this.callbacksRef.current.onInterruptStateChange(interruptState, { source })
1361
1430
  }
1362
1431
 
1363
1432
  /**
@@ -1398,10 +1467,17 @@ export class ChatClient<
1398
1467
  private buildDevtoolsBridgeOptions(
1399
1468
  devtools: ChatClientOptions['devtools'],
1400
1469
  ): ChatDevtoolsBridgeOptions {
1470
+ const client = this
1401
1471
  return {
1402
- hookId: this.uniqueId,
1403
- clientId: this.uniqueId,
1404
- threadId: this.threadId,
1472
+ get hookId() {
1473
+ return client.uniqueId
1474
+ },
1475
+ get clientId() {
1476
+ return client.uniqueId
1477
+ },
1478
+ get threadId() {
1479
+ return client.threadId
1480
+ },
1405
1481
  metadata: {
1406
1482
  hookName: devtools?.hookName ?? 'useChat',
1407
1483
  outputKind: devtools?.outputKind ?? 'chat',
@@ -1600,6 +1676,12 @@ export class ChatClient<
1600
1676
  }
1601
1677
  await this.processIncomingChunk(chunk, { defer: false })
1602
1678
  }
1679
+ // Same contract as `streamResponse`: client tools may finish (and
1680
+ // queue a resume) while `isLoading` is still true. Wait for them
1681
+ // before teardown so `drainPostStreamActions` below sees the queue.
1682
+ if (this.pendingToolExecutions.size > 0) {
1683
+ await Promise.all(this.pendingToolExecutions.values())
1684
+ }
1603
1685
  } catch (error) {
1604
1686
  // Pre-attach failures (unknown/evicted run, connect deadline abort)
1605
1687
  // stay soft: keep the restored transcript. Post-attach transport/parser
@@ -1641,6 +1723,7 @@ export class ChatClient<
1641
1723
  this.abortController = null
1642
1724
  this.setIsLoading(false)
1643
1725
  if (this.status === 'streaming') this.setStatus('ready')
1726
+ await this.drainPostStreamActions()
1644
1727
  }
1645
1728
  }
1646
1729
  })()
@@ -2607,6 +2690,18 @@ export class ChatClient<
2607
2690
  * a text-only response has nothing to auto-send.
2608
2691
  */
2609
2692
  private shouldAutoSend(): boolean {
2693
+ // A pending interrupt owns the next send. Auto-continuing after a
2694
+ // completed server tool would start a sibling run and hide the card.
2695
+ if (this.lastResume) return false
2696
+ // Ownership follows the descriptors, not the submission handle. Generic
2697
+ // interrupts settle the resume stream through a post-stream action that
2698
+ // runs before `submitInterruptBatch`'s `finally` clears the handle, so
2699
+ // gating on the handle alone would strand a legacy client tool that the
2700
+ // native resume itself emitted (#1106).
2701
+ if (this.activeInterruptSubmission && this.hasPendingInterrupts()) {
2702
+ return false
2703
+ }
2704
+ if (this.interruptManager.getInterrupts().length > 0) return false
2610
2705
  const messages = this.processor.getMessages()
2611
2706
  const lastAssistant = messages.findLast(
2612
2707
  (m: UIMessage) => m.role === 'assistant',