@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/README.md CHANGED
@@ -36,6 +36,14 @@
36
36
  </a>
37
37
  </div>
38
38
 
39
+ <br />
40
+
41
+ <div align="center">
42
+ <a href="https://tanstack.com/blog/tanstack-open-source-awards-2026">
43
+ <img src="https://raw.githubusercontent.com/TanStack/ai/16826d81cade868956df240d6239a671e689193c/media/js-open-source-award-2026-ai-project-of-the-year.svg" alt="Winner of the 2026 JavaScript Open Source Award for AI Project of the Year" width="250" />
44
+ </a>
45
+ </div>
46
+
39
47
  # TanStack AI
40
48
 
41
49
  Type-safe, provider-agnostic TypeScript SDK for building streaming chat,
@@ -191,7 +199,7 @@ Learn more in the
191
199
  build low-latency realtime voice experiences.
192
200
  - [Code Mode](https://tanstack.com/ai/latest/docs/code-mode/code-mode) - let
193
201
  models write and execute TypeScript inside a secure isolate.
194
- - [Code Mode with Skills](https://tanstack.com/ai/latest/docs/code-mode/code-mode-with-skills) -
202
+ - [Code Mode with Snippets](https://tanstack.com/ai/latest/docs/code-mode/code-mode-with-snippets) -
195
203
  give Code Mode reusable runtime capabilities.
196
204
 
197
205
  ## Providers
@@ -208,6 +216,7 @@ Official adapters include:
208
216
  | [`@tanstack/ai-grok`](https://tanstack.com/ai/latest/docs/adapters/grok) | xAI Grok chat, images, and realtime |
209
217
  | [`@tanstack/ai-groq`](https://tanstack.com/ai/latest/docs/adapters/groq) | Groq low-latency inference |
210
218
  | [`@tanstack/ai-elevenlabs`](https://tanstack.com/ai/latest/docs/adapters/elevenlabs) | ElevenLabs realtime voice, speech, transcription, music, and sound effects |
219
+ | [`@tanstack/ai-byteplus`](https://tanstack.com/ai/latest/docs/adapters/byteplus) | BytePlus Seed chat, Seedance video, Seedream image, and Seed Speech TTS/ASR |
211
220
  | [`@tanstack/ai-fal`](https://tanstack.com/ai/latest/docs/adapters/fal) | fal.ai image, video, audio, speech, and transcription models |
212
221
 
213
222
  The adapter system is tree-shakeable by activity. Import `openaiText` for chat,
@@ -1,13 +1,14 @@
1
- import { AnyClientTool, ModelMessage, RunAgentResumeItem, StreamChunk } from '@tanstack/ai/client';
1
+ import { AnyClientTool, InterruptDefinition, ModelMessage, RunAgentResumeItem, StreamChunk } from '@tanstack/ai/client';
2
2
  import { ConnectionAdapter } from './connection-adapters.js';
3
- import { BoundInterrupts, ChatClientOptions, ChatClientState, ChatFetcher, ChatInterrupt, ChatInterruptState, ChatResumeState, ConnectionStatus, MultimodalContent, QueueOption, QueueStrategy, QueuedMessage, SendMessageOptions, UIMessage, WhenBusy } from './types.js';
4
- type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClientTool>> = {
3
+ import { BoundInterrupts, ChatClientOptions, ChatClientState, ChatFetcher, ChatInterruptState, ResolvableChatInterrupt, ChatResumeState, ConnectionStatus, MultimodalContent, QueueOption, QueueStrategy, QueuedMessage, SendMessageOptions, UIMessage, WhenBusy } from './types.js';
4
+ type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClientTool>, TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> = readonly []> = {
5
5
  connection?: ConnectionAdapter;
6
6
  fetcher?: ChatFetcher;
7
7
  /** @deprecated Use `forwardedProps` instead. */
8
8
  body?: Record<string, any>;
9
9
  forwardedProps?: Record<string, any>;
10
10
  tools?: TTools;
11
+ interrupts?: TInterrupts;
11
12
  queue?: QueueOption;
12
13
  onResponse?: (response?: Response) => void | Promise<void>;
13
14
  onChunk?: (chunk: StreamChunk) => void;
@@ -17,13 +18,15 @@ type ChatClientUpdateOptionsWithoutContext<TTools extends ReadonlyArray<AnyClien
17
18
  onConnectionStatusChange?: (status: ConnectionStatus) => void;
18
19
  onSessionGeneratingChange?: (isGenerating: boolean) => void;
19
20
  onQueueChange?: (queue: Array<QueuedMessage>) => void;
20
- onResumeStateChange?: (resumeState: ChatResumeState | null, pendingInterrupts: BoundInterrupts<TTools>) => void;
21
+ onResumeStateChange?: (resumeState: ChatResumeState | null, pendingInterrupts: BoundInterrupts<TTools, TInterrupts>) => void;
21
22
  /**
22
23
  * Fires whenever the id of the run in flight changes: the new id when a run
23
24
  * starts (including a rejoin), `null` when it settles.
24
25
  */
25
26
  onRunIdChange?: (runId: string | null) => void;
26
- onInterruptStateChange?: (state: ChatInterruptState<TTools>) => void;
27
+ onInterruptStateChange?: (state: ChatInterruptState<TTools, TInterrupts>, context: {
28
+ source: 'hydrate' | 'live';
29
+ }) => void;
27
30
  onCustomEvent?: (eventType: string, data: unknown, context: {
28
31
  toolCallId?: string;
29
32
  }) => void;
@@ -43,11 +46,11 @@ export interface NormalizedQueueConfig {
43
46
  strategy?: QueueStrategy;
44
47
  }
45
48
  export declare function normalizeQueueOption(option: QueueOption | undefined): NormalizedQueueConfig;
46
- export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = any, TContext = unknown> {
49
+ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = any, TContext = unknown, TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> = any> {
47
50
  private readonly processor;
48
51
  private connection;
49
- private readonly uniqueId;
50
- private readonly threadId;
52
+ private uniqueId;
53
+ private threadId;
51
54
  private readonly persistor?;
52
55
  private readonly clearedStreamTracker;
53
56
  private currentRunId;
@@ -130,7 +133,7 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
130
133
  private readonly cachesMessages;
131
134
  private devtoolsMounted;
132
135
  private readonly callbacksRef;
133
- constructor(options: ChatClientOptions<TTools, TContext>);
136
+ constructor(options: ChatClientOptions<TTools, TContext, TInterrupts>);
134
137
  /**
135
138
  * START TAILING: re-attach to an in-flight run so its chunks arrive here.
136
139
  *
@@ -190,6 +193,7 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
190
193
  */
191
194
  private hydrateFromServer;
192
195
  mountDevtools(): void;
196
+ private ensureThreadId;
193
197
  /**
194
198
  * Drain a runId-less RUN_ERROR that belongs to a cleared run the client is
195
199
  * still tracking. The persistor owns the cleared-run bookkeeping; the client
@@ -220,12 +224,12 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
220
224
  */
221
225
  getCurrentRunId(): string | null;
222
226
  private setCurrentRunId;
223
- getInterruptState(): ChatInterruptState<TTools>;
224
- getInterrupts(): BoundInterrupts<TTools>;
227
+ getInterruptState(): ChatInterruptState<TTools, TInterrupts>;
228
+ getInterrupts(): BoundInterrupts<TTools, TInterrupts>;
225
229
  /** @deprecated Use getInterrupts(). */
226
- getPendingInterrupts(): BoundInterrupts<TTools>;
230
+ getPendingInterrupts(): BoundInterrupts<TTools, TInterrupts>;
227
231
  resolveInterrupts(approved: boolean): void;
228
- resolveInterrupts(resolver: (interrupt: ChatInterrupt<TTools>) => undefined): void;
232
+ resolveInterrupts(resolver: (interrupt: ResolvableChatInterrupt<TTools, TInterrupts>) => undefined): void;
229
233
  cancelInterrupts(): void;
230
234
  retryInterrupts(): void;
231
235
  /** Unsafe low-level resume escape hatch. Prefer bound interrupt methods. */
@@ -5,6 +5,13 @@ import { ClearedStreamTracker } from "./cleared-stream-tracker.js";
5
5
  import { InterruptManager } from "./interrupt-manager.js";
6
6
  import { StreamProcessor, convertSchemaToJsonSchema, generateMessageId, isStandardSchema, normalizeToUIMessage, parseWithStandardSchema } from "@tanstack/ai/client";
7
7
  //#region src/chat-client.ts
8
+ function assertUniqueInterruptDefinitions(interrupts) {
9
+ const ids = /* @__PURE__ */ new Set();
10
+ for (const interrupt of interrupts ?? []) {
11
+ if (ids.has(interrupt.id)) throw new Error(`Duplicate interrupt definition id: ${interrupt.id}`);
12
+ ids.add(interrupt.id);
13
+ }
14
+ }
8
15
  function resolveTransport(transport) {
9
16
  const { connection, fetcher } = transport;
10
17
  if (connection && fetcher) throw new Error("ChatClient: pass either `connection` or `fetcher`, not both.");
@@ -197,13 +204,16 @@ var ChatClient = class {
197
204
  devtoolsMounted = false;
198
205
  callbacksRef;
199
206
  constructor(options) {
200
- this.threadId = options.threadId || this.generateUniqueId("thread");
201
- this.uniqueId = options.id || this.threadId;
207
+ assertUniqueInterruptDefinitions(options.interrupts);
208
+ this.threadId = options.threadId || "";
209
+ this.uniqueId = this.threadId;
202
210
  let cachesMessages = true;
203
- if (options.persistence === true) cachesMessages = false;
204
- else if (options.persistence) {
205
- const persistenceKey = options.id ?? this.threadId;
206
- this.persistor = new ChatPersistor(options.persistence, persistenceKey, (messages) => this.processor.setMessages(messages), (snapshot) => this.applyPersistedResume(snapshot));
211
+ if (options.persistence === true) {
212
+ if (!options.threadId) throw new Error("[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).");
213
+ cachesMessages = false;
214
+ } else if (options.persistence) {
215
+ if (!options.threadId) throw new Error("[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).");
216
+ this.persistor = new ChatPersistor(options.persistence, options.threadId, (messages) => this.processor.setMessages(messages), (snapshot) => this.applyPersistedResume(snapshot));
207
217
  }
208
218
  this.bodyOption = options.body || {};
209
219
  this.forwardedPropsOption = options.forwardedProps || {};
@@ -234,8 +244,9 @@ var ChatClient = class {
234
244
  } };
235
245
  this.interruptManager = new InterruptManager({
236
246
  ...options.tools !== void 0 ? { tools: options.tools } : {},
247
+ ...options.interrupts !== void 0 ? { interrupts: options.interrupts } : {},
237
248
  submit: (submission) => this.submitInterruptBatch(submission),
238
- onChange: () => this.notifyResumeStateChange()
249
+ onChange: (source) => this.notifyResumeStateChange(source)
239
250
  });
240
251
  if (options.initialResumeSnapshot) this.applyResumeSnapshot(options.initialResumeSnapshot);
241
252
  const persistedState = this.persistor?.readInitial();
@@ -367,6 +378,7 @@ var ChatClient = class {
367
378
  */
368
379
  attach() {
369
380
  if (this.disposed || this.tailing) return;
381
+ this.ensureThreadId();
370
382
  this.tailing = true;
371
383
  if (this.rejoinRunId) this.maybeRejoinInFlight(this.rejoinRunId);
372
384
  if (!this.cachesMessages && this.connection.hydrate) this.hydrateFromServer();
@@ -396,13 +408,13 @@ var ChatClient = class {
396
408
  applyResumeSnapshot(snapshot) {
397
409
  const resumeState = readResumeState(snapshot);
398
410
  if (resumeState === void 0) {
399
- this.interruptManager.reset();
411
+ this.interruptManager.reset({ source: "hydrate" });
400
412
  return;
401
413
  }
402
414
  this.lastResume = resumeState;
403
415
  const pendingInterrupts = Array.isArray(snapshot.pendingInterrupts) ? snapshot.pendingInterrupts : [];
404
416
  if (pendingInterrupts.length === 0) {
405
- this.interruptManager.reset();
417
+ this.interruptManager.reset({ source: "hydrate" });
406
418
  return;
407
419
  }
408
420
  const generation = this.interruptGeneration(pendingInterrupts);
@@ -411,7 +423,7 @@ var ChatClient = class {
411
423
  interruptedRunId: resumeState.runId,
412
424
  generation,
413
425
  interrupts: pendingInterrupts
414
- });
426
+ }, "hydrate");
415
427
  }
416
428
  /**
417
429
  * Apply a resume snapshot read from durable storage. Restores interrupt state,
@@ -474,10 +486,16 @@ var ChatClient = class {
474
486
  })();
475
487
  }
476
488
  mountDevtools() {
489
+ this.ensureThreadId();
477
490
  if (this.devtoolsMounted) return;
478
491
  this.devtoolsMounted = true;
479
492
  this.devtoolsBridge.mountWithTools(this.processor.getMessages().length);
480
493
  }
494
+ ensureThreadId() {
495
+ if (!this.threadId) this.threadId = this.generateUniqueId("thread");
496
+ this.uniqueId = this.threadId;
497
+ return this.threadId;
498
+ }
481
499
  /**
482
500
  * Drain a runId-less RUN_ERROR that belongs to a cleared run the client is
483
501
  * still tracking. The persistor owns the cleared-run bookkeeping; the client
@@ -547,7 +565,7 @@ var ChatClient = class {
547
565
  interruptedRunId,
548
566
  generation: this.interruptGeneration(chunk.outcome.interrupts),
549
567
  interrupts: chunk.outcome.interrupts
550
- });
568
+ }, "live");
551
569
  return;
552
570
  }
553
571
  const isRunlessSessionError = chunk.type === "RUN_ERROR" && !runId;
@@ -562,7 +580,7 @@ var ChatClient = class {
562
580
  this.interruptManager.reset();
563
581
  return;
564
582
  }
565
- this.notifyResumeStateChange();
583
+ this.notifyResumeStateChange("live");
566
584
  }
567
585
  /**
568
586
  * The interrupt-resume state for the active/interrupted run (its run/thread
@@ -699,11 +717,12 @@ var ChatClient = class {
699
717
  this.callbacksRef.current.onSessionGeneratingChange(isGenerating);
700
718
  this.devtoolsBridge.emitSnapshot();
701
719
  }
702
- notifyResumeStateChange() {
720
+ notifyResumeStateChange(source) {
703
721
  const resumeState = this.getResumeState();
722
+ const interruptState = this.interruptManager.getState();
704
723
  this.persistResumeSnapshot(resumeState);
705
- this.callbacksRef.current.onResumeStateChange(resumeState, this.interruptManager.getInterrupts());
706
- this.callbacksRef.current.onInterruptStateChange(this.interruptManager.getState());
724
+ this.callbacksRef.current.onResumeStateChange(resumeState, interruptState.interrupts);
725
+ this.callbacksRef.current.onInterruptStateChange(interruptState, { source });
707
726
  }
708
727
  /**
709
728
  * Build the durable resume snapshot from the current resume state + pending
@@ -732,10 +751,17 @@ var ChatClient = class {
732
751
  this.events.errorChanged(error?.message || null);
733
752
  }
734
753
  buildDevtoolsBridgeOptions(devtools) {
754
+ const client = this;
735
755
  return {
736
- hookId: this.uniqueId,
737
- clientId: this.uniqueId,
738
- threadId: this.threadId,
756
+ get hookId() {
757
+ return client.uniqueId;
758
+ },
759
+ get clientId() {
760
+ return client.uniqueId;
761
+ },
762
+ get threadId() {
763
+ return client.threadId;
764
+ },
739
765
  metadata: {
740
766
  hookName: devtools?.hookName ?? "useChat",
741
767
  outputKind: devtools?.outputKind ?? "chat",
@@ -885,6 +911,7 @@ var ChatClient = class {
885
911
  }
886
912
  await this.processIncomingChunk(chunk, { defer: false });
887
913
  }
914
+ if (this.pendingToolExecutions.size > 0) await Promise.all(this.pendingToolExecutions.values());
888
915
  } catch (error) {
889
916
  const isAbort = error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
890
917
  if (!attached && !isAbort) refused = true;
@@ -899,6 +926,7 @@ var ChatClient = class {
899
926
  this.abortController = null;
900
927
  this.setIsLoading(false);
901
928
  if (this.status === "streaming") this.setStatus("ready");
929
+ await this.drainPostStreamActions();
902
930
  }
903
931
  }
904
932
  })();
@@ -1468,6 +1496,9 @@ var ChatClient = class {
1468
1496
  * a text-only response has nothing to auto-send.
1469
1497
  */
1470
1498
  shouldAutoSend() {
1499
+ if (this.lastResume) return false;
1500
+ if (this.activeInterruptSubmission && this.hasPendingInterrupts()) return false;
1501
+ if (this.interruptManager.getInterrupts().length > 0) return false;
1471
1502
  const lastAssistant = this.processor.getMessages().findLast((m) => m.role === "assistant");
1472
1503
  if (!lastAssistant) return false;
1473
1504
  if (!lastAssistant.parts.some((p) => p.type === "tool-call")) return false;