@tanstack/ai-client 0.34.0 → 0.36.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.
package/README.md CHANGED
@@ -240,6 +240,7 @@ Official adapters include:
240
240
  | [`@tanstack/ai-byteplus`](https://tanstack.com/ai/latest/docs/adapters/byteplus) | BytePlus Seed chat, Seedance video, Seedream image, and Seed Speech TTS/ASR |
241
241
  | [`@tanstack/ai-fal`](https://tanstack.com/ai/latest/docs/adapters/fal) | fal.ai image, video, audio, speech, and transcription models |
242
242
  | [`@tanstack/ai-reactor`](https://tanstack.com/ai/latest/docs/adapters/reactor) | Reactor live world and video generation (Orbis, Happy Oyster, LingBot, Helios, FastH3) |
243
+ | [`@tanstack/ai-worldlabs`](https://tanstack.com/ai/latest/docs/adapters/worldlabs) | World Labs Marble persistent 3D world generation |
243
244
  | [`@tanstack/ai-cloudflare`](https://tanstack.com/ai/latest/docs/adapters/cloudflare) | Cloudflare Workers AI chat, embeddings, image, speech, transcription, and AI Gateway |
244
245
 
245
246
  The adapter system is tree-shakeable by activity. Import `openaiText` for chat,
@@ -143,6 +143,8 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
143
143
  private readonly activeRunIds;
144
144
  /** Latched by `dispose()`; stops any late async callback starting new work. */
145
145
  private disposed;
146
+ /** The error a failed mount hydration set, cleared by the next successful one. */
147
+ private hydrationError;
146
148
  /** Whether a view is currently watching. See `attach` / `detach`. */
147
149
  private tailing;
148
150
  /** Constructor inputs `attach()` needs on every re-attach, not just the first. */
@@ -219,6 +221,19 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
219
221
  * send that starts first owns the client (hydration then backs off).
220
222
  */
221
223
  private hydrateFromServer;
224
+ /**
225
+ * Surface a mount-hydration failure (`persistence: true`) on the observable
226
+ * fields, mirroring `GenerationClient.failHydration`, so "this thread failed
227
+ * to load" is distinguishable from "this thread has no messages" and the app
228
+ * can show an error / offer a retry. A genuine miss — the server having no
229
+ * record for a fresh thread — resolves normally and never reaches here; only a
230
+ * thrown transport / authorize-gate error does.
231
+ *
232
+ * Skipped when the view unmounted (`!tailing`) or a `sendMessage` took
233
+ * ownership while the hydrate GET was in flight, so a live run's state always
234
+ * wins over a stale mount-time failure — same guard as the success path above.
235
+ */
236
+ private failHydration;
222
237
  mountDevtools(): void;
223
238
  private ensureThreadId;
224
239
  /**
@@ -295,7 +310,8 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
295
310
  */
296
311
  private startSubscription;
297
312
  /**
298
- * Consume chunks from the connection subscription.
313
+ * Consume chunks from the connection subscription. Chunks are processed in
314
+ * order; the loop yields to the host after each processing budget.
299
315
  */
300
316
  private consumeSubscription;
301
317
  /**
@@ -317,7 +333,7 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
317
333
  * give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and
318
334
  * clear the dead pointer so it does not retry on the next load.
319
335
  *
320
- * Replay chunks are processed WITHOUT the per-chunk yield the live path uses,
336
+ * Replay chunks are processed WITHOUT the time-slice yield the live path uses,
321
337
  * so the buffered prefix snaps in and only the genuinely-live tail streams at
322
338
  * network speed — a reload looks like the run continued, not like it re-typed.
323
339
  */
@@ -6,9 +6,15 @@ import { fetcherToConnectionAdapter, getChunkRunId as getChunkRunId$1, normalize
6
6
  import { ChatPersistor } from "./client-persistor.js";
7
7
  import { ClearedStreamTracker } from "./cleared-stream-tracker.js";
8
8
  import { InterruptManager } from "./interrupt-manager.js";
9
- import { EventType, StreamProcessor, convertSchemaToJsonSchema, generateMessageId, isStandardSchema, mergeMetadata, normalizeToUIMessage, parseWithStandardSchema, restoreInboundChunk, tanstackMetadata } from "@tanstack/ai/client";
9
+ import { EventType, StreamProcessor, cloneAndDeepFreezeJson, convertSchemaToJsonSchema, generateMessageId, isStandardSchema, mergeMetadata, normalizeToUIMessage, restoreInboundChunk, tanstackMetadata, validateWithStandardSchema } from "@tanstack/ai/client";
10
10
  import { ByokBlockedError, ByokMissingError, ByokUnresolvedProviderError } from "@tanstack/ai/byok";
11
11
  //#region src/chat-client.ts
12
+ var STREAM_PROCESSING_BUDGET_MS = 8;
13
+ function yieldToHost() {
14
+ const { scheduler } = globalThis;
15
+ if (scheduler?.yield) return scheduler.yield();
16
+ return new Promise((resolve) => setTimeout(resolve, 0));
17
+ }
12
18
  function assertUniqueInterruptDefinitions(interrupts) {
13
19
  const ids = /* @__PURE__ */ new Set();
14
20
  for (const interrupt of interrupts ?? []) {
@@ -138,10 +144,16 @@ var REJOIN_CONNECT_DEADLINE_MS = 2e3;
138
144
  var REJOIN_REBUILD_TRIGGERS = /* @__PURE__ */ new Set([
139
145
  "TEXT_MESSAGE_START",
140
146
  "TEXT_MESSAGE_CONTENT",
147
+ "REASONING_MESSAGE_CONTENT",
141
148
  "TOOL_CALL_START",
142
149
  "MESSAGES_SNAPSHOT",
143
150
  "SUBAGENT_STARTED"
144
151
  ]);
152
+ function rebuildsAssistantMessage(chunk) {
153
+ if (chunk.type === "REASONING_ENCRYPTED_VALUE") return chunk.subtype === "message" && typeof chunk.encryptedValue === "string" && chunk.encryptedValue.length > 0;
154
+ if (chunk.type === "STEP_FINISHED") return "signature" in chunk && Boolean(chunk.signature);
155
+ return REJOIN_REBUILD_TRIGGERS.has(chunk.type);
156
+ }
145
157
  /** Every subagent card in the messages, nested cards included. */
146
158
  function collectSubagentParts(messages) {
147
159
  const cards = [];
@@ -258,6 +270,8 @@ var ChatClient = class {
258
270
  activeRunIds = /* @__PURE__ */ new Set();
259
271
  /** Latched by `dispose()`; stops any late async callback starting new work. */
260
272
  disposed = false;
273
+ /** The error a failed mount hydration set, cleared by the next successful one. */
274
+ hydrationError;
261
275
  /** Whether a view is currently watching. See `attach` / `detach`. */
262
276
  tailing = false;
263
277
  /** Constructor inputs `attach()` needs on every re-attach, not just the first. */
@@ -556,19 +570,25 @@ var ChatClient = class {
556
570
  const generation = this.historyGeneration;
557
571
  try {
558
572
  result = await hydrate(this.threadId, hydrateOptions);
559
- } catch {
573
+ } catch (cause) {
574
+ if (generation === this.historyGeneration) this.failHydration(cause);
560
575
  return;
561
576
  }
562
577
  if (generation !== this.historyGeneration) return;
563
578
  if (this.disposed || !this.tailing) return;
564
579
  if (this.isLoading || this.abortController) return;
580
+ if (this.hydrationError && this.error === this.hydrationError) {
581
+ this.setError(void 0);
582
+ this.setStatus("ready");
583
+ }
584
+ this.hydrationError = void 0;
565
585
  this.applyHydrationPage(result.page);
566
586
  if (result.messages.length > 0) {
567
587
  const windowMessages = normalizeMessagesDates(result.messages);
568
588
  this.processor.setMessages(windowMessages);
569
589
  this.rememberServerMessageIds(windowMessages);
570
590
  }
571
- if (result.interrupts && result.interrupts.pending.length > 0) this.applyResumeSnapshot({
591
+ if (result.interrupts && result.interrupts.pending.length > 0 && (!result.activeRun?.runId || result.activeRun.runId === result.interrupts.runId)) this.applyResumeSnapshot({
572
592
  resumeState: {
573
593
  threadId: this.threadId,
574
594
  runId: result.interrupts.runId
@@ -578,6 +598,29 @@ var ChatClient = class {
578
598
  else if (result.activeRun?.runId) this.maybeRejoinInFlight(result.activeRun.runId);
579
599
  })();
580
600
  }
601
+ /**
602
+ * Surface a mount-hydration failure (`persistence: true`) on the observable
603
+ * fields, mirroring `GenerationClient.failHydration`, so "this thread failed
604
+ * to load" is distinguishable from "this thread has no messages" and the app
605
+ * can show an error / offer a retry. A genuine miss — the server having no
606
+ * record for a fresh thread — resolves normally and never reaches here; only a
607
+ * thrown transport / authorize-gate error does.
608
+ *
609
+ * Skipped when the view unmounted (`!tailing`) or a `sendMessage` took
610
+ * ownership while the hydrate GET was in flight, so a live run's state always
611
+ * wins over a stale mount-time failure — same guard as the success path above.
612
+ */
613
+ failHydration(cause) {
614
+ if (this.disposed || !this.tailing) return;
615
+ if (this.isLoading || this.abortController) return;
616
+ const error = cause instanceof Error ? cause : new Error(String(cause));
617
+ if (error instanceof ByokMissingError) this.byok?.request(error.provider, "missing");
618
+ if (error instanceof ByokBlockedError && error.reason === "locked") this.byok?.request(error.provider, "locked");
619
+ this.hydrationError = error;
620
+ this.setStatus("error");
621
+ this.setError(error);
622
+ this.callbacksRef.current.onError(error);
623
+ }
581
624
  mountDevtools() {
582
625
  this.ensureThreadId();
583
626
  if (this.devtoolsMounted) return;
@@ -974,13 +1017,21 @@ var ChatClient = class {
974
1017
  });
975
1018
  }
976
1019
  /**
977
- * Consume chunks from the connection subscription.
1020
+ * Consume chunks from the connection subscription. Chunks are processed in
1021
+ * order; the loop yields to the host after each processing budget.
978
1022
  */
979
1023
  async consumeSubscription(signal) {
980
1024
  const stream = this.connection.subscribe(signal);
1025
+ let chunkProcessingTime = 0;
981
1026
  for await (const chunk of stream) {
982
1027
  if (signal.aborted) break;
983
- await this.processIncomingChunk(chunk);
1028
+ const startedAt = performance.now();
1029
+ this.processIncomingChunk(chunk);
1030
+ chunkProcessingTime += performance.now() - startedAt;
1031
+ if (chunkProcessingTime < STREAM_PROCESSING_BUDGET_MS) continue;
1032
+ chunkProcessingTime = 0;
1033
+ if (typeof document !== "undefined" && document.hidden) continue;
1034
+ await yieldToHost();
984
1035
  }
985
1036
  }
986
1037
  /**
@@ -1002,7 +1053,7 @@ var ChatClient = class {
1002
1053
  * give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and
1003
1054
  * clear the dead pointer so it does not retry on the next load.
1004
1055
  *
1005
- * Replay chunks are processed WITHOUT the per-chunk yield the live path uses,
1056
+ * Replay chunks are processed WITHOUT the time-slice yield the live path uses,
1006
1057
  * so the buffered prefix snaps in and only the genuinely-live tail streams at
1007
1058
  * network speed — a reload looks like the run continued, not like it re-typed.
1008
1059
  */
@@ -1033,11 +1084,11 @@ var ChatClient = class {
1033
1084
  attached = true;
1034
1085
  clearTimeout(connectTimer);
1035
1086
  }
1036
- if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) {
1087
+ if (!rebuilt && rebuildsAssistantMessage(chunk)) {
1037
1088
  rebuilt = true;
1038
1089
  this.dropTrailingInFlightAssistant();
1039
1090
  }
1040
- await this.processIncomingChunk(chunk, { defer: false });
1091
+ this.processIncomingChunk(chunk);
1041
1092
  }
1042
1093
  if (this.pendingToolExecutions.size > 0) await Promise.all(this.pendingToolExecutions.values());
1043
1094
  } catch (error) {
@@ -1070,7 +1121,7 @@ var ChatClient = class {
1070
1121
  const last = messages[messages.length - 1];
1071
1122
  if (last && last.role === "assistant") this.processor.setMessages(messages.slice(0, -1));
1072
1123
  }
1073
- async processIncomingChunk(chunk, options) {
1124
+ processIncomingChunk(chunk) {
1074
1125
  chunk = restoreInboundChunk(chunk);
1075
1126
  if (chunk.type === "RUN_ERROR" && this.isActiveInterruptSubmissionFailure(chunk)) {
1076
1127
  const interruptErrors = tanstackMetadata(chunk)?.interruptErrors;
@@ -1099,7 +1150,6 @@ var ChatClient = class {
1099
1150
  this.syncSubagentHandles();
1100
1151
  this.updateRunLifecycle(chunk);
1101
1152
  this.observeInterruptState(chunk);
1102
- if (options?.defer !== false && (typeof document === "undefined" || !document.hidden)) await new Promise((resolve) => setTimeout(resolve, 0));
1103
1153
  this.resolveJoinedRun(chunk);
1104
1154
  }
1105
1155
  isActiveInterruptSubmissionFailure(chunk) {
@@ -1554,10 +1604,12 @@ var ChatClient = class {
1554
1604
  await this.addToolResultForClientTool(result, clientTool, this.streamContinuationGeneration);
1555
1605
  }
1556
1606
  async addToolResultForClientTool(result, clientTool, continuationGeneration, context) {
1557
- if (clientTool && result.state !== "output-error") try {
1607
+ if (result.state !== "output-error") try {
1608
+ let output = clientTool?.outputSchema && isStandardSchema(clientTool.outputSchema) ? await this.validateClientToolOutput(clientTool, result.output) : result.output;
1609
+ if (this.interruptManager.getDescriptors().some((interrupt) => interrupt.toolCallId === result.toolCallId)) output = cloneAndDeepFreezeJson(output);
1558
1610
  result = {
1559
1611
  ...result,
1560
- output: this.validateClientToolOutput(clientTool, result.output)
1612
+ output
1561
1613
  };
1562
1614
  } catch (error) {
1563
1615
  result = {
@@ -1571,16 +1623,17 @@ var ChatClient = class {
1571
1623
  if (continuationGeneration !== this.continuationGeneration) return;
1572
1624
  this.processor.addToolResult(result.toolCallId, result.output, result.state === "output-error" ? result.errorText || "Tool execution failed" : void 0);
1573
1625
  this.devtoolsBridge.emitSnapshot();
1574
- if (this.interruptManager.resolveClientToolOutput(result.toolCallId, result.state === "output-error" ? { error: result.errorText || "Tool execution failed" } : result.output)) return;
1626
+ if (result.state === "output-error" ? this.interruptManager.resolveClientToolError(result.toolCallId, result.errorText || "Tool execution failed") : this.interruptManager.resolveClientToolOutput(result.toolCallId, result.output)) return;
1575
1627
  if (this.isLoading) {
1576
1628
  this.queuePostStreamAction(() => continuationGeneration === this.continuationGeneration ? this.checkForContinuation() : Promise.resolve());
1577
1629
  return;
1578
1630
  }
1579
1631
  await this.checkForContinuation();
1580
1632
  }
1581
- validateClientToolOutput(clientTool, output) {
1582
- if (clientTool.outputSchema && isStandardSchema(clientTool.outputSchema)) return parseWithStandardSchema(clientTool.outputSchema, output);
1583
- return output;
1633
+ async validateClientToolOutput(clientTool, output) {
1634
+ const validation = await validateWithStandardSchema(clientTool.outputSchema, output);
1635
+ if (!validation.success) throw new Error(validation.issues.map((issue) => issue.message).join(", "));
1636
+ return validation.data;
1584
1637
  }
1585
1638
  /**
1586
1639
  * Respond to a tool approval request