@tanstack/ai-client 0.22.0 → 0.23.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.
Files changed (72) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/audio-recorder.js +190 -213
  3. package/dist/esm/audio-recorder.js.map +1 -1
  4. package/dist/esm/chat-client.d.ts +172 -3
  5. package/dist/esm/chat-client.js +1656 -1386
  6. package/dist/esm/chat-client.js.map +1 -1
  7. package/dist/esm/cleared-stream-tracker.d.ts +23 -0
  8. package/dist/esm/cleared-stream-tracker.js +97 -0
  9. package/dist/esm/cleared-stream-tracker.js.map +1 -0
  10. package/dist/esm/client-persistor.d.ts +25 -12
  11. package/dist/esm/client-persistor.js +260 -235
  12. package/dist/esm/client-persistor.js.map +1 -1
  13. package/dist/esm/connection-adapters.d.ts +231 -10
  14. package/dist/esm/connection-adapters.js +989 -574
  15. package/dist/esm/connection-adapters.js.map +1 -1
  16. package/dist/esm/devtools-noop.d.ts +1 -0
  17. package/dist/esm/devtools-noop.js +79 -139
  18. package/dist/esm/devtools-noop.js.map +1 -1
  19. package/dist/esm/devtools.d.ts +31 -1
  20. package/dist/esm/devtools.js +977 -1127
  21. package/dist/esm/devtools.js.map +1 -1
  22. package/dist/esm/events.js +224 -226
  23. package/dist/esm/events.js.map +1 -1
  24. package/dist/esm/generation-client.d.ts +145 -2
  25. package/dist/esm/generation-client.js +659 -321
  26. package/dist/esm/generation-client.js.map +1 -1
  27. package/dist/esm/generation-reconstruct.d.ts +21 -0
  28. package/dist/esm/generation-reconstruct.js +85 -0
  29. package/dist/esm/generation-reconstruct.js.map +1 -0
  30. package/dist/esm/generation-types.d.ts +289 -3
  31. package/dist/esm/generation-types.js +356 -13
  32. package/dist/esm/generation-types.js.map +1 -1
  33. package/dist/esm/index.d.ts +9 -4
  34. package/dist/esm/index.js +7 -39
  35. package/dist/esm/interrupt-manager.d.ts +77 -0
  36. package/dist/esm/interrupt-manager.js +787 -0
  37. package/dist/esm/interrupt-manager.js.map +1 -0
  38. package/dist/esm/mcp-app-bridge.js +56 -64
  39. package/dist/esm/mcp-app-bridge.js.map +1 -1
  40. package/dist/esm/realtime-client.js +366 -440
  41. package/dist/esm/realtime-client.js.map +1 -1
  42. package/dist/esm/response-stream.js +19 -26
  43. package/dist/esm/response-stream.js.map +1 -1
  44. package/dist/esm/sse-parser.js +44 -47
  45. package/dist/esm/sse-parser.js.map +1 -1
  46. package/dist/esm/sse-utils.js +8 -9
  47. package/dist/esm/sse-utils.js.map +1 -1
  48. package/dist/esm/storage-adapters.d.ts +62 -0
  49. package/dist/esm/storage-adapters.js +174 -0
  50. package/dist/esm/storage-adapters.js.map +1 -0
  51. package/dist/esm/types.d.ts +212 -10
  52. package/dist/esm/types.js +38 -7
  53. package/dist/esm/types.js.map +1 -1
  54. package/dist/esm/video-generation-client.d.ts +113 -2
  55. package/dist/esm/video-generation-client.js +665 -379
  56. package/dist/esm/video-generation-client.js.map +1 -1
  57. package/package.json +7 -7
  58. package/src/chat-client.ts +1079 -61
  59. package/src/cleared-stream-tracker.ts +151 -0
  60. package/src/client-persistor.ts +102 -33
  61. package/src/connection-adapters.ts +1185 -142
  62. package/src/devtools-noop.ts +4 -3
  63. package/src/devtools.ts +121 -3
  64. package/src/generation-client.ts +563 -13
  65. package/src/generation-reconstruct.ts +121 -0
  66. package/src/generation-types.ts +727 -3
  67. package/src/index.ts +56 -1
  68. package/src/interrupt-manager.ts +1440 -0
  69. package/src/storage-adapters.ts +242 -0
  70. package/src/types.ts +301 -9
  71. package/src/video-generation-client.ts +479 -13
  72. package/dist/esm/index.js.map +0 -1
@@ -1,1396 +1,1666 @@
1
- import { StreamProcessor, normalizeToUIMessage, generateMessageId, convertSchemaToJsonSchema, isStandardSchema, parseWithStandardSchema } from "@tanstack/ai/client";
2
1
  import { createNoOpChatDevtoolsBridge } from "./devtools-noop.js";
3
- import { normalizeConnectionAdapter, getChunkRunId, fetcherToConnectionAdapter } from "./connection-adapters.js";
2
+ import { fetcherToConnectionAdapter, getChunkRunId, normalizeConnectionAdapter } from "./connection-adapters.js";
4
3
  import { ChatPersistor } from "./client-persistor.js";
4
+ import { ClearedStreamTracker } from "./cleared-stream-tracker.js";
5
+ import { InterruptManager } from "./interrupt-manager.js";
6
+ import { StreamProcessor, convertSchemaToJsonSchema, generateMessageId, isStandardSchema, normalizeToUIMessage, parseWithStandardSchema } from "@tanstack/ai/client";
7
+ //#region src/chat-client.ts
5
8
  function resolveTransport(transport) {
6
- const { connection, fetcher } = transport;
7
- if (connection && fetcher) {
8
- throw new Error(
9
- "ChatClient: pass either `connection` or `fetcher`, not both."
10
- );
11
- }
12
- if (connection) return connection;
13
- if (fetcher) return fetcherToConnectionAdapter(fetcher);
14
- throw new Error("ChatClient: either `connection` or `fetcher` is required.");
9
+ const { connection, fetcher } = transport;
10
+ if (connection && fetcher) throw new Error("ChatClient: pass either `connection` or `fetcher`, not both.");
11
+ if (connection) return connection;
12
+ if (fetcher) return fetcherToConnectionAdapter(fetcher);
13
+ throw new Error("ChatClient: either `connection` or `fetcher` is required.");
15
14
  }
16
15
  function normalizeQueueOption(option) {
17
- const base = {
18
- whenBusy: "queue",
19
- drain: "fifo",
20
- onOverflow: "reject"
21
- };
22
- if (!option) return base;
23
- if (typeof option === "string") return { ...base, whenBusy: option };
24
- if (typeof option === "function") return { ...base, strategy: option };
25
- const maxSize = option.maxSize;
26
- if (maxSize !== void 0) {
27
- if (!Number.isInteger(maxSize) || maxSize < 0) {
28
- throw new Error(
29
- "ChatClient: queue.maxSize must be a non-negative integer"
30
- );
31
- }
32
- }
33
- return {
34
- whenBusy: option.whenBusy ?? "queue",
35
- drain: option.drain ?? "fifo",
36
- onOverflow: option.onOverflow ?? "reject",
37
- ...maxSize !== void 0 ? { maxSize } : {}
38
- };
16
+ const base = {
17
+ whenBusy: "queue",
18
+ drain: "fifo",
19
+ onOverflow: "reject"
20
+ };
21
+ if (!option) return base;
22
+ if (typeof option === "string") return {
23
+ ...base,
24
+ whenBusy: option
25
+ };
26
+ if (typeof option === "function") return {
27
+ ...base,
28
+ strategy: option
29
+ };
30
+ const maxSize = option.maxSize;
31
+ if (maxSize !== void 0) {
32
+ if (!Number.isInteger(maxSize) || maxSize < 0) throw new Error("ChatClient: queue.maxSize must be a non-negative integer");
33
+ }
34
+ return {
35
+ whenBusy: option.whenBusy ?? "queue",
36
+ drain: option.drain ?? "fifo",
37
+ onOverflow: option.onOverflow ?? "reject",
38
+ ...maxSize !== void 0 ? { maxSize } : {}
39
+ };
39
40
  }
41
+ /**
42
+ * Merge a run of queued messages into a single send for `drain: 'batch'`.
43
+ * All-string content is joined with newlines; mixed/multimodal content is
44
+ * flattened into a single `ContentPart` array. The last item's `body` wins.
45
+ */
40
46
  function mergeQueuedMessages(items) {
41
- const body = items.at(-1)?.body;
42
- const stringContents = [];
43
- for (const item of items) {
44
- if (typeof item.content !== "string") {
45
- break;
46
- }
47
- stringContents.push(item.content);
48
- }
49
- if (stringContents.length === items.length) {
50
- return {
51
- content: stringContents.join("\n"),
52
- ...body !== void 0 ? { body } : {}
53
- };
54
- }
55
- const parts = [];
56
- for (const item of items) {
57
- if (typeof item.content === "string") {
58
- parts.push({ type: "text", content: item.content });
59
- } else if (typeof item.content.content === "string") {
60
- parts.push({ type: "text", content: item.content.content });
61
- } else {
62
- parts.push(...item.content.content);
63
- }
64
- }
65
- return {
66
- content: { content: parts },
67
- ...body !== void 0 ? { body } : {}
68
- };
47
+ const body = items.at(-1)?.body;
48
+ const stringContents = [];
49
+ for (const item of items) {
50
+ if (typeof item.content !== "string") break;
51
+ stringContents.push(item.content);
52
+ }
53
+ if (stringContents.length === items.length) return {
54
+ content: stringContents.join("\n"),
55
+ ...body !== void 0 ? { body } : {}
56
+ };
57
+ const parts = [];
58
+ for (const item of items) if (typeof item.content === "string") parts.push({
59
+ type: "text",
60
+ content: item.content
61
+ });
62
+ else if (typeof item.content.content === "string") parts.push({
63
+ type: "text",
64
+ content: item.content.content
65
+ });
66
+ else parts.push(...item.content.content);
67
+ return {
68
+ content: { content: parts },
69
+ ...body !== void 0 ? { body } : {}
70
+ };
69
71
  }
70
- class ChatClient {
71
- processor;
72
- connection;
73
- uniqueId;
74
- threadId;
75
- // All persistence concerns (hydrate / save / clear, plus suppression of late
76
- // chunks after a mid-stream clear) live in ChatPersistor so this class stays
77
- // focused on streaming. Undefined when no `persistence` adapter is configured.
78
- persistor;
79
- currentRunId = null;
80
- // Track the legacy `body` option and the canonical `forwardedProps`
81
- // option as separate slots so that `updateOptions({ forwardedProps })`
82
- // doesn't wipe a previously-set `body` (and vice versa). They are
83
- // merged on every send, with `forwardedProps` winning on key collision.
84
- bodyOption = {};
85
- forwardedPropsOption = {};
86
- context = void 0;
87
- pendingMessageBody = void 0;
88
- queueConfig;
89
- messageQueue = [];
90
- /**
91
- * True from the moment `sendMessage` claims the client until its
92
- * `streamResponse` settles. Closes the race where concurrent callers both
93
- * see `isLoading === false`, both append a user message, and only one stream
94
- * actually runs (leaving stranded user messages with no reply).
95
- */
96
- sendInFlight = false;
97
- /**
98
- * True while `drainQueue` is delivering queued messages. Concurrent
99
- * `sendMessage` calls during a drain are treated as busy and follow
100
- * `whenBusy` (default: queue).
101
- */
102
- messageQueueDraining = false;
103
- /**
104
- * Set by `whenBusy: 'interrupt'` so an in-progress FIFO drain loop stops
105
- * before starting the next queued item (the interrupting send owns the client).
106
- */
107
- stopMessageQueueDrain = false;
108
- /**
109
- * Sync claim held for the duration of `deliverMessage` so concurrent
110
- * deliverers cannot both append a user message before only one stream runs.
111
- */
112
- deliverClaim = false;
113
- isLoading = false;
114
- isSubscribed = false;
115
- error = void 0;
116
- status = "ready";
117
- connectionStatus = "disconnected";
118
- abortController = null;
119
- clientToolsRef;
120
- devtoolsBridge;
121
- /**
122
- * Alias for `this.events`. The bridge installs an
123
- * emitter that auto-attaches run/thread context and auto-emits a
124
- * snapshot after every event, so chat-client only ever calls
125
- * `this.events.X(...)` exactly like it did before devtools landed.
126
- */
127
- events;
128
- currentStreamId = null;
129
- currentMessageId = null;
130
- postStreamActions = [];
131
- // Track pending client tool executions to await them before stream finalization
132
- pendingToolExecutions = /* @__PURE__ */ new Map();
133
- activeClientTools = null;
134
- activeContext = void 0;
135
- // Flag to deduplicate continuation checks during action draining
136
- continuationPending = false;
137
- subscriptionAbortController = null;
138
- processingResolve = null;
139
- errorReportedGeneration = null;
140
- streamGeneration = 0;
141
- // Tracks whether a queued checkForContinuation was skipped because
142
- // continuationPending was true (chained approval scenario)
143
- continuationSkipped = false;
144
- draining = false;
145
- sessionGenerating = false;
146
- activeRunIds = /* @__PURE__ */ new Set();
147
- devtoolsMounted = false;
148
- callbacksRef;
149
- constructor(options) {
150
- this.uniqueId = options.id || this.generateUniqueId("chat");
151
- this.threadId = options.threadId || this.generateUniqueId("thread");
152
- if (options.persistence) {
153
- this.persistor = new ChatPersistor(
154
- options.persistence,
155
- this.uniqueId,
156
- (messages) => this.processor.setMessages(messages)
157
- );
158
- }
159
- this.bodyOption = options.body || {};
160
- this.forwardedPropsOption = options.forwardedProps || {};
161
- this.context = options.context;
162
- this.queueConfig = normalizeQueueOption(options.queue);
163
- this.connection = normalizeConnectionAdapter(resolveTransport(options));
164
- this.clientToolsRef = { current: /* @__PURE__ */ new Map() };
165
- if (options.tools) {
166
- for (const tool of options.tools) {
167
- this.clientToolsRef.current.set(tool.name, tool);
168
- }
169
- }
170
- this.devtoolsBridge = (options.devtoolsBridgeFactory ?? createNoOpChatDevtoolsBridge)(this.buildDevtoolsBridgeOptions(options.devtools));
171
- this.events = this.devtoolsBridge.events;
172
- this.callbacksRef = {
173
- current: {
174
- onResponse: options.onResponse || (() => {
175
- }),
176
- onChunk: options.onChunk || (() => {
177
- }),
178
- onFinish: options.onFinish || (() => {
179
- }),
180
- onError: options.onError || (() => {
181
- }),
182
- onMessagesChange: options.onMessagesChange || (() => {
183
- }),
184
- onLoadingChange: options.onLoadingChange || (() => {
185
- }),
186
- onErrorChange: options.onErrorChange || (() => {
187
- }),
188
- onStatusChange: options.onStatusChange || (() => {
189
- }),
190
- onSubscriptionChange: options.onSubscriptionChange || (() => {
191
- }),
192
- onConnectionStatusChange: options.onConnectionStatusChange || (() => {
193
- }),
194
- onSessionGeneratingChange: options.onSessionGeneratingChange || (() => {
195
- }),
196
- onQueueChange: options.onQueueChange || (() => {
197
- }),
198
- onCustomEvent: options.onCustomEvent || (() => {
199
- })
200
- }
201
- };
202
- const persistedMessages = this.persistor?.readInitial();
203
- const initialMessages = Array.isArray(persistedMessages) ? persistedMessages : options.initialMessages;
204
- this.processor = new StreamProcessor({
205
- ...options.streamProcessor?.chunkStrategy ? { chunkStrategy: options.streamProcessor.chunkStrategy } : {},
206
- ...initialMessages ? { initialMessages } : {},
207
- events: {
208
- onMessagesChange: (messages) => {
209
- this.persistor?.notifyMessagesChanged(messages);
210
- this.callbacksRef.current.onMessagesChange(messages);
211
- },
212
- onStreamStart: () => {
213
- this.setStatus("streaming");
214
- const assistantMessageId = this.processor.getCurrentAssistantMessageId();
215
- if (!assistantMessageId) {
216
- return;
217
- }
218
- const messages = this.processor.getMessages();
219
- const assistantMessage = messages.find(
220
- (m) => m.id === assistantMessageId
221
- );
222
- if (assistantMessage) {
223
- this.currentMessageId = assistantMessage.id;
224
- this.events.messageAppended(
225
- assistantMessage,
226
- this.currentStreamId || void 0
227
- );
228
- }
229
- },
230
- onStreamEnd: (message) => {
231
- this.callbacksRef.current.onFinish(message);
232
- this.setStatus("ready");
233
- this.resolveProcessing();
234
- },
235
- onError: (error) => {
236
- this.reportStreamError(error);
237
- },
238
- onTextUpdate: (messageId, content) => {
239
- if (this.currentStreamId) {
240
- this.events.textUpdated(this.currentStreamId, messageId, content);
241
- }
242
- },
243
- onThinkingUpdate: (messageId, content) => {
244
- if (this.currentStreamId) {
245
- this.events.thinkingUpdated(
246
- this.currentStreamId,
247
- messageId,
248
- content,
249
- void 0
250
- );
251
- }
252
- },
253
- onStructuredOutputChange: (args) => {
254
- const streamId = this.devtoolsBridge.resolveStreamId();
255
- const eventName = args.phase === "start" ? "structured-output:started" : args.phase === "complete" ? "structured-output:completed" : args.phase === "error" ? "structured-output:errored" : "structured-output:updated";
256
- this.currentMessageId = args.messageId;
257
- this.events.structuredOutputChanged(
258
- eventName,
259
- streamId,
260
- args.messageId,
261
- {
262
- status: args.status,
263
- raw: args.raw,
264
- ...args.partial !== void 0 ? { partial: args.partial } : {},
265
- ...args.data !== void 0 ? { data: args.data } : {},
266
- ...args.reasoning !== void 0 ? { reasoning: args.reasoning } : {},
267
- ...args.errorMessage !== void 0 ? { errorMessage: args.errorMessage } : {},
268
- ...args.delta !== void 0 ? { delta: args.delta } : {}
269
- }
270
- );
271
- },
272
- onToolCallStateChange: (messageId, toolCallId, state, args) => {
273
- const messages = this.processor.getMessages();
274
- const message = messages.find((m) => m.id === messageId);
275
- const toolCallPart = message?.parts.find(
276
- (p) => p.type === "tool-call" && p.id === toolCallId
277
- );
278
- const toolName = toolCallPart?.name || "unknown";
279
- if (this.currentStreamId) {
280
- this.events.toolCallStateChanged(
281
- this.currentStreamId,
282
- messageId,
283
- toolCallId,
284
- toolName,
285
- state,
286
- args
287
- );
288
- }
289
- },
290
- onToolCall: (args) => {
291
- const clientTools = this.activeClientTools ?? this.clientToolsRef.current;
292
- const clientTool = clientTools.get(args.toolName);
293
- const executeFunc = clientTool?.execute;
294
- if (executeFunc) {
295
- const runEventContext = this.devtoolsBridge.getCurrentRunEventContext();
296
- const executionPromise = (async () => {
297
- try {
298
- const context = this.activeClientTools === null ? this.context : this.activeContext;
299
- const output = await executeFunc(args.input, {
300
- toolCallId: args.toolCallId,
301
- context,
302
- emitCustomEvent: () => {
303
- }
304
- });
305
- await this.addToolResultForClientTool(
306
- {
307
- toolCallId: args.toolCallId,
308
- tool: args.toolName,
309
- output,
310
- state: "output-available"
311
- },
312
- clientTool,
313
- runEventContext
314
- );
315
- } catch (error) {
316
- await this.addToolResultForClientTool(
317
- {
318
- toolCallId: args.toolCallId,
319
- tool: args.toolName,
320
- output: null,
321
- state: "output-error",
322
- errorText: error.message
323
- },
324
- clientTool,
325
- runEventContext
326
- );
327
- } finally {
328
- this.pendingToolExecutions.delete(args.toolCallId);
329
- }
330
- })();
331
- this.pendingToolExecutions.set(args.toolCallId, executionPromise);
332
- }
333
- },
334
- onApprovalRequest: (args) => {
335
- const streamId = this.devtoolsBridge.resolveStreamId();
336
- const messageIdForApproval = this.findMessageIdForToolCall(args.toolCallId) ?? this.currentMessageId ?? "";
337
- this.events.approvalRequested(
338
- streamId,
339
- messageIdForApproval,
340
- args.toolCallId,
341
- args.toolName,
342
- args.input,
343
- args.approvalId
344
- );
345
- },
346
- onCustomEvent: (eventType, data, context) => {
347
- this.callbacksRef.current.onCustomEvent(eventType, data, context);
348
- }
349
- }
350
- });
351
- this.persistor?.hydrateAsync(persistedMessages);
352
- }
353
- mountDevtools() {
354
- if (this.devtoolsMounted) {
355
- return;
356
- }
357
- this.devtoolsMounted = true;
358
- this.devtoolsBridge.mountWithTools(this.processor.getMessages().length);
359
- }
360
- /**
361
- * Drain a runId-less RUN_ERROR that belongs to a cleared run the client is
362
- * still tracking. The persistor owns the cleared-run bookkeeping; the client
363
- * owns the active-run / session / processing state.
364
- */
365
- drainIgnoredRunlessChunk(chunk) {
366
- if (chunk.type !== "RUN_ERROR") return;
367
- const runId = this.persistor?.takeRunlessRunId();
368
- if (!runId) return;
369
- this.activeRunIds.delete(runId);
370
- this.setSessionGenerating(this.activeRunIds.size > 0);
371
- this.resolveProcessing();
372
- }
373
- updateRunLifecycle(chunk, options) {
374
- if (chunk.type === "RUN_STARTED") {
375
- const chunkRunId = getChunkRunId(chunk) ?? chunk.runId;
376
- this.activeRunIds.add(chunkRunId);
377
- this.persistor?.onRunStarted(chunkRunId);
378
- this.setSessionGenerating(true);
379
- return;
380
- }
381
- if (chunk.type !== "RUN_FINISHED" && chunk.type !== "RUN_ERROR") {
382
- return;
383
- }
384
- const runId = getChunkRunId(chunk);
385
- if (runId) {
386
- this.activeRunIds.delete(runId);
387
- this.persistor?.onRunSettled(runId);
388
- } else if (chunk.type === "RUN_ERROR") {
389
- this.activeRunIds.clear();
390
- this.persistor?.onSessionRunError();
391
- }
392
- this.setSessionGenerating(this.activeRunIds.size > 0);
393
- if (options?.resolveProcessing !== false) {
394
- this.resolveProcessing();
395
- }
396
- }
397
- generateUniqueId(prefix) {
398
- return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`;
399
- }
400
- setIsLoading(isLoading) {
401
- this.isLoading = isLoading;
402
- this.callbacksRef.current.onLoadingChange(isLoading);
403
- this.events.loadingChanged(isLoading);
404
- }
405
- setStatus(status) {
406
- this.status = status;
407
- this.callbacksRef.current.onStatusChange(status);
408
- this.devtoolsBridge.emitSnapshot();
409
- }
410
- setIsSubscribed(isSubscribed) {
411
- this.isSubscribed = isSubscribed;
412
- this.callbacksRef.current.onSubscriptionChange(isSubscribed);
413
- this.devtoolsBridge.emitSnapshot();
414
- }
415
- setConnectionStatus(status) {
416
- this.connectionStatus = status;
417
- this.callbacksRef.current.onConnectionStatusChange(status);
418
- this.devtoolsBridge.emitSnapshot();
419
- }
420
- setSessionGenerating(isGenerating) {
421
- if (this.sessionGenerating === isGenerating) return;
422
- this.sessionGenerating = isGenerating;
423
- this.callbacksRef.current.onSessionGeneratingChange(isGenerating);
424
- this.devtoolsBridge.emitSnapshot();
425
- }
426
- resetSessionGenerating() {
427
- this.activeRunIds.clear();
428
- this.persistor?.resetIgnored();
429
- this.setSessionGenerating(false);
430
- }
431
- setError(error) {
432
- this.error = error;
433
- this.callbacksRef.current.onErrorChange(error);
434
- this.events.errorChanged(error?.message || null);
435
- }
436
- buildDevtoolsBridgeOptions(devtools) {
437
- return {
438
- hookId: this.uniqueId,
439
- clientId: this.uniqueId,
440
- threadId: this.threadId,
441
- metadata: {
442
- hookName: devtools?.hookName ?? "useChat",
443
- outputKind: devtools?.outputKind ?? "chat",
444
- ...devtools?.framework ? { framework: devtools.framework } : {},
445
- ...devtools?.name ? { name: devtools.name } : {}
446
- },
447
- getSnapshot: () => this.getDevtoolsSnapshot(),
448
- getTools: () => this.clientToolsRef.current.values(),
449
- getMessages: () => this.processor.getMessages(),
450
- setMessages: (messages) => {
451
- this.processor.setMessages(messages);
452
- },
453
- addToolResult: (toolCallId, output, errorText) => {
454
- this.processor.addToolResult(toolCallId, output, errorText);
455
- },
456
- generateId: (prefix) => this.generateUniqueId(prefix)
457
- };
458
- }
459
- getDevtoolsSnapshot() {
460
- return {
461
- messages: this.processor.getMessages(),
462
- status: this.status,
463
- isLoading: this.isLoading,
464
- isSubscribed: this.isSubscribed,
465
- connectionStatus: this.connectionStatus,
466
- sessionGenerating: this.sessionGenerating,
467
- activeRunIds: Array.from(this.activeRunIds),
468
- queue: this.getQueue(),
469
- ...this.error ? { error: this.error.message } : {}
470
- };
471
- }
472
- findMessageIdForToolCall(toolCallId) {
473
- const messages = this.processor.getMessages();
474
- for (const message of messages) {
475
- const match = message.parts.find(
476
- (part) => part.type === "tool-call" && part.id === toolCallId
477
- );
478
- if (match) return message.id;
479
- }
480
- return void 0;
481
- }
482
- abortSubscriptionLoop() {
483
- this.subscriptionAbortController?.abort();
484
- this.subscriptionAbortController = null;
485
- }
486
- resolveProcessing() {
487
- this.processingResolve?.();
488
- this.processingResolve = null;
489
- }
490
- cancelInFlightStream(options) {
491
- this.abortController?.abort();
492
- this.abortController = null;
493
- if (options?.abortSubscription) {
494
- this.abortSubscriptionLoop();
495
- }
496
- this.resolveProcessing();
497
- this.setIsLoading(false);
498
- this.deliverClaim = false;
499
- if (options?.setReadyStatus) {
500
- this.setStatus("ready");
501
- }
502
- }
503
- reportStreamError(error) {
504
- const alreadyReported = this.errorReportedGeneration === this.streamGeneration;
505
- this.setError(error);
506
- if (this.isLoading || this.status === "submitted" || this.status === "streaming") {
507
- this.setStatus("error");
508
- }
509
- if (!alreadyReported) {
510
- this.errorReportedGeneration = this.streamGeneration;
511
- this.callbacksRef.current.onError(error);
512
- }
513
- }
514
- /**
515
- * Start the background subscription loop.
516
- */
517
- startSubscription() {
518
- this.subscriptionAbortController = new AbortController();
519
- const signal = this.subscriptionAbortController.signal;
520
- this.consumeSubscription(signal).catch((err) => {
521
- if (err instanceof Error && err.name !== "AbortError") {
522
- this.setConnectionStatus("error");
523
- this.resetSessionGenerating();
524
- this.setIsSubscribed(false);
525
- this.reportStreamError(err);
526
- }
527
- this.resolveProcessing();
528
- }).finally(() => {
529
- if (this.subscriptionAbortController?.signal !== signal) {
530
- return;
531
- }
532
- this.subscriptionAbortController = null;
533
- if (!signal.aborted && this.isSubscribed) {
534
- this.setIsSubscribed(false);
535
- if (this.connectionStatus !== "error") {
536
- this.setConnectionStatus("disconnected");
537
- }
538
- }
539
- });
540
- }
541
- /**
542
- * Consume chunks from the connection subscription.
543
- */
544
- async consumeSubscription(signal) {
545
- const stream = this.connection.subscribe(signal);
546
- for await (const chunk of stream) {
547
- if (signal.aborted) break;
548
- if (this.connectionStatus === "connecting") {
549
- this.setConnectionStatus("connected");
550
- }
551
- const shouldIgnore = this.persistor?.shouldIgnoreChunk(chunk) ?? false;
552
- if (shouldIgnore) {
553
- if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") {
554
- if (getChunkRunId(chunk)) {
555
- this.updateRunLifecycle(chunk, { resolveProcessing: false });
556
- } else {
557
- this.drainIgnoredRunlessChunk(chunk);
558
- }
559
- }
560
- continue;
561
- }
562
- this.callbacksRef.current.onChunk(chunk);
563
- this.devtoolsBridge.observeChunk(chunk);
564
- this.processor.processChunk(chunk);
565
- this.updateRunLifecycle(chunk);
566
- await new Promise((resolve) => setTimeout(resolve, 0));
567
- }
568
- }
569
- /**
570
- * Ensure subscription loop is running, starting it if needed.
571
- */
572
- ensureSubscription() {
573
- if (!this.isSubscribed) {
574
- this.subscribe();
575
- return;
576
- }
577
- if (!this.subscriptionAbortController || this.subscriptionAbortController.signal.aborted) {
578
- this.subscribe({ restart: true });
579
- }
580
- }
581
- /**
582
- * Create a promise that resolves when onStreamEnd fires.
583
- * Used by streamResponse to await processing completion.
584
- */
585
- waitForProcessing() {
586
- this.resolveProcessing();
587
- return new Promise((resolve) => {
588
- this.processingResolve = resolve;
589
- });
590
- }
591
- /**
592
- * Send a message and stream the response.
593
- * Supports both simple string content and multimodal content (images, audio, video, documents).
594
- *
595
- * @param content - The message content. Can be:
596
- * - A simple string for text-only messages
597
- * - A MultimodalContent object with content array and optional custom ID
598
- * @param body - Optional body parameters to merge with the client's base body for this request.
599
- * Uses shallow merge with per-message body taking priority.
600
- * @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
601
- * override the configured queue policy for this one send.
602
- *
603
- * @example
604
- * ```ts
605
- * // Simple text message
606
- * await client.sendMessage('Hello!')
607
- *
608
- * // Text message with custom body params
609
- * await client.sendMessage('Hello!', { temperature: 0.7 })
610
- *
611
- * // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
612
- * await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
613
- *
614
- * // Multimodal message with image
615
- * await client.sendMessage({
616
- * content: [
617
- * { type: 'text', content: 'What is in this image?' },
618
- * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }
619
- * ]
620
- * })
621
- *
622
- * // Multimodal message with custom ID and body params
623
- * await client.sendMessage(
624
- * {
625
- * content: [
626
- * { type: 'text', content: 'Describe this audio' },
627
- * { type: 'audio', source: { type: 'data', value: 'base64...' } }
628
- * ],
629
- * id: 'custom-message-id'
630
- * },
631
- * { model: 'gpt-4-audio' }
632
- * )
633
- * ```
634
- */
635
- async sendMessage(content, body, sendOptions) {
636
- this.mountDevtools();
637
- const emptyMessage = typeof content === "string" && !content.trim();
638
- if (emptyMessage) {
639
- return;
640
- }
641
- if (this.isSendBusy()) {
642
- const { action, id } = this.decideWhenBusy(content, sendOptions);
643
- if (action === "drop") {
644
- return;
645
- }
646
- if (action === "queue") {
647
- this.enqueueMessage(content, body, id);
648
- return;
649
- }
650
- this.stopMessageQueueDrain = true;
651
- this.sendInFlight = true;
652
- this.cancelInFlightStream({ setReadyStatus: true });
653
- this.resetSessionGenerating();
654
- } else {
655
- this.sendInFlight = true;
656
- }
657
- try {
658
- await this.deliverMessage(content, body);
659
- } finally {
660
- this.sendInFlight = false;
661
- }
662
- }
663
- /** True while a stream is active, a send is claiming the client, or the queue is draining. */
664
- isSendBusy() {
665
- return this.isLoading || this.sendInFlight || this.messageQueueDraining;
666
- }
667
- resolveBusyReason() {
668
- if (this.isLoading) return "streaming";
669
- if (this.messageQueueDraining) return "draining";
670
- return "sendInFlight";
671
- }
672
- /**
673
- * Append a user message and run the stream. Used by both direct sends and
674
- * queue drains — callers are responsible for busy/queue policy.
675
- *
676
- * Claims delivery synchronously before appending so concurrent callers
677
- * cannot both add a user message when only one stream can run.
678
- */
679
- async deliverMessage(content, body) {
680
- if (this.isLoading || this.deliverClaim) {
681
- return false;
682
- }
683
- this.deliverClaim = true;
684
- try {
685
- const normalizedContent = this.normalizeMessageInput(content);
686
- this.pendingMessageBody = body;
687
- const userMessage = this.processor.addUserMessage(
688
- normalizedContent.content,
689
- normalizedContent.id
690
- );
691
- this.events.messageSent(userMessage.id, normalizedContent.content);
692
- return await this.streamResponse();
693
- } finally {
694
- this.deliverClaim = false;
695
- }
696
- }
697
- /**
698
- * Resolve the effective action for a send that arrives while busy.
699
- * The returned `id` is the id that will be stored if the action is `queue`.
700
- */
701
- decideWhenBusy(content, sendOptions) {
702
- const id = this.generateUniqueId("queued");
703
- if (sendOptions?.whenBusy) {
704
- return { action: sendOptions.whenBusy, id };
705
- }
706
- const { strategy, whenBusy } = this.queueConfig;
707
- if (strategy) {
708
- const { action } = strategy({
709
- pending: {
710
- id,
711
- content,
712
- createdAt: Date.now()
713
- },
714
- busyReason: this.resolveBusyReason(),
715
- queued: this.getQueue()
716
- });
717
- return { action, id };
718
- }
719
- return { action: whenBusy, id };
720
- }
721
- enqueueMessage(content, body, id) {
722
- const { maxSize, onOverflow } = this.queueConfig;
723
- if (maxSize !== void 0 && this.messageQueue.length >= maxSize) {
724
- if (onOverflow === "reject" || maxSize === 0) {
725
- return;
726
- }
727
- this.messageQueue.shift();
728
- }
729
- this.messageQueue.push({
730
- id: id ?? this.generateUniqueId("queued"),
731
- content,
732
- createdAt: Date.now(),
733
- ...body !== void 0 ? { body } : {}
734
- });
735
- this.emitQueueChange();
736
- }
737
- /**
738
- * Normalize the message input to extract content and optional id.
739
- * Trims string content automatically.
740
- */
741
- normalizeMessageInput(input) {
742
- if (typeof input === "string") {
743
- return { content: input.trim() };
744
- }
745
- return { content: input.content, id: input.id };
746
- }
747
- /**
748
- * Append a message and stream the response
749
- */
750
- async append(message) {
751
- this.mountDevtools();
752
- const normalizedMessage = normalizeToUIMessage(message, generateMessageId);
753
- if (normalizedMessage.role === "system") {
754
- return;
755
- }
756
- const uiMessage = normalizedMessage;
757
- this.events.messageAppended(uiMessage);
758
- const messages = this.processor.getMessages();
759
- this.processor.setMessages([...messages, uiMessage]);
760
- this.devtoolsBridge.emitSnapshot();
761
- if (this.isLoading) {
762
- this.queuePostStreamAction(async () => {
763
- await this.streamResponse();
764
- });
765
- return;
766
- }
767
- await this.streamResponse();
768
- }
769
- /**
770
- * Stream a response from the LLM.
771
- * Returns true if the stream completed successfully, false on abort or error.
772
- */
773
- async streamResponse() {
774
- if (this.isLoading) {
775
- return false;
776
- }
777
- const generation = ++this.streamGeneration;
778
- const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
779
- this.currentRunId = runId;
780
- this.setIsLoading(true);
781
- this.deliverClaim = false;
782
- this.setStatus("submitted");
783
- this.setError(void 0);
784
- this.errorReportedGeneration = null;
785
- this.abortController = new AbortController();
786
- const signal = this.abortController.signal;
787
- this.pendingToolExecutions.clear();
788
- let streamCompletedSuccessfully = false;
789
- let activeDevtoolsRunId = null;
790
- let runTerminalEventEmitted = false;
791
- try {
792
- const messages = this.processor.getMessages();
793
- const clientTools = new Map(this.clientToolsRef.current);
794
- const runtimeContext = this.context;
795
- await this.callbacksRef.current.onResponse();
796
- if (signal.aborted) {
797
- return false;
798
- }
799
- const mergedBody = {
800
- ...this.bodyOption,
801
- ...this.forwardedPropsOption,
802
- ...this.pendingMessageBody
803
- };
804
- this.pendingMessageBody = void 0;
805
- this.currentStreamId = this.generateUniqueId("stream");
806
- this.devtoolsBridge.setCurrentStreamId(this.currentStreamId);
807
- this.currentMessageId = null;
808
- this.activeClientTools = clientTools;
809
- this.activeContext = runtimeContext;
810
- this.processor.prepareAssistantMessage();
811
- this.ensureSubscription();
812
- const processingComplete = this.waitForProcessing();
813
- const runContext = {
814
- threadId: this.threadId,
815
- runId,
816
- clientTools: Array.from(clientTools.values()).map((t) => ({
817
- name: t.name,
818
- description: t.description,
819
- parameters: t.inputSchema ? convertSchemaToJsonSchema(t.inputSchema) : { type: "object" }
820
- })),
821
- forwardedProps: { ...mergedBody }
822
- };
823
- this.devtoolsBridge.beginRun(runContext.runId, this.threadId);
824
- activeDevtoolsRunId = runContext.runId;
825
- this.devtoolsBridge.emitRunLifecycle(
826
- "run:created",
827
- runContext.runId,
828
- "created"
829
- );
830
- this.devtoolsBridge.emitRunLifecycle(
831
- "run:started",
832
- runContext.runId,
833
- "started"
834
- );
835
- this.devtoolsBridge.emitSnapshot();
836
- await this.connection.send(messages, mergedBody, signal, runContext);
837
- await processingComplete;
838
- if (generation !== this.streamGeneration) {
839
- return false;
840
- }
841
- if (this.status === "error") {
842
- if (activeDevtoolsRunId) {
843
- this.devtoolsBridge.emitRunLifecycle(
844
- "run:errored",
845
- activeDevtoolsRunId,
846
- "errored",
847
- this.error ? { error: this.error.message } : {}
848
- );
849
- runTerminalEventEmitted = true;
850
- }
851
- return false;
852
- }
853
- if (this.pendingToolExecutions.size > 0) {
854
- await Promise.all(this.pendingToolExecutions.values());
855
- }
856
- this.processor.finalizeStream();
857
- streamCompletedSuccessfully = true;
858
- } catch (err) {
859
- if (err instanceof Error) {
860
- if (err.name === "AbortError") {
861
- if (activeDevtoolsRunId) {
862
- this.devtoolsBridge.emitRunLifecycle(
863
- "run:cancelled",
864
- activeDevtoolsRunId,
865
- "cancelled"
866
- );
867
- runTerminalEventEmitted = true;
868
- }
869
- return false;
870
- }
871
- if (generation === this.streamGeneration) {
872
- this.reportStreamError(err);
873
- if (activeDevtoolsRunId) {
874
- this.devtoolsBridge.emitRunLifecycle(
875
- "run:errored",
876
- activeDevtoolsRunId,
877
- "errored",
878
- { error: err.message }
879
- );
880
- runTerminalEventEmitted = true;
881
- }
882
- }
883
- }
884
- } finally {
885
- if (generation === this.streamGeneration) {
886
- this.currentStreamId = null;
887
- this.devtoolsBridge.setCurrentStreamId(null);
888
- this.currentMessageId = null;
889
- this.currentRunId = null;
890
- this.activeClientTools = null;
891
- this.activeContext = void 0;
892
- this.abortController = null;
893
- this.setIsLoading(false);
894
- this.pendingMessageBody = void 0;
895
- if (activeDevtoolsRunId && !runTerminalEventEmitted) {
896
- if (streamCompletedSuccessfully) {
897
- this.devtoolsBridge.emitRunLifecycle(
898
- "run:completed",
899
- activeDevtoolsRunId,
900
- "completed"
901
- );
902
- } else if (signal.aborted) {
903
- this.devtoolsBridge.emitRunLifecycle(
904
- "run:cancelled",
905
- activeDevtoolsRunId,
906
- "cancelled"
907
- );
908
- }
909
- }
910
- await this.drainPostStreamActions();
911
- if (streamCompletedSuccessfully) {
912
- const messages = this.processor.getMessages();
913
- const lastPart = messages.at(-1)?.parts.at(-1);
914
- const { finishReason } = this.processor.getState();
915
- if (lastPart?.type === "tool-result" && finishReason !== "stop" && this.shouldAutoSend()) {
916
- try {
917
- await this.checkForContinuation();
918
- } catch (error) {
919
- console.error("Failed to continue flow after tool result:", error);
920
- await this.drainQueue();
921
- }
922
- } else {
923
- if (this.status !== "ready") {
924
- this.setStatus("ready");
925
- }
926
- if (!this.messageQueueDraining) {
927
- await this.drainQueue();
928
- }
929
- }
930
- } else {
931
- this.flushQueue();
932
- }
933
- }
934
- }
935
- return streamCompletedSuccessfully;
936
- }
937
- /**
938
- * Start the client subscription loop.
939
- * This controls the connection lifecycle independently from request lifecycle.
940
- */
941
- subscribe(options) {
942
- const restart = options?.restart === true;
943
- if (this.isSubscribed && !restart) {
944
- return;
945
- }
946
- if (this.isSubscribed && restart) {
947
- this.abortSubscriptionLoop();
948
- }
949
- this.setIsSubscribed(true);
950
- this.setConnectionStatus("connecting");
951
- this.startSubscription();
952
- }
953
- /**
954
- * Unsubscribe and fully tear down live behavior.
955
- * This aborts an in-flight request and the subscription loop.
956
- */
957
- unsubscribe() {
958
- this.cancelInFlightStream({
959
- setReadyStatus: true,
960
- abortSubscription: true
961
- });
962
- this.discardPendingSends();
963
- this.resetSessionGenerating();
964
- this.setIsSubscribed(false);
965
- this.setConnectionStatus("disconnected");
966
- }
967
- /**
968
- * Reload the last assistant message
969
- */
970
- async reload() {
971
- const messages = this.processor.getMessages();
972
- if (messages.length === 0) return;
973
- const lastUserMessageIndex = messages.findLastIndex(
974
- (m) => m.role === "user"
975
- );
976
- if (lastUserMessageIndex === -1) return;
977
- if (this.isLoading) {
978
- this.cancelInFlightStream();
979
- }
980
- this.discardPendingSends();
981
- this.events.reloaded(lastUserMessageIndex);
982
- this.processor.removeMessagesAfter(lastUserMessageIndex);
983
- this.devtoolsBridge.emitSnapshot();
984
- await this.streamResponse();
985
- }
986
- /**
987
- * Stop the current stream
988
- */
989
- stop() {
990
- const hadLocalStream = this.abortController !== null;
991
- this.cancelInFlightStream({ setReadyStatus: true });
992
- this.discardPendingSends();
993
- if (hadLocalStream) {
994
- this.resetSessionGenerating();
995
- }
996
- this.events.stopped();
997
- }
998
- /**
999
- * Clear all messages
1000
- */
1001
- clear() {
1002
- if (this.persistor) {
1003
- this.persistor.snapshotClear({
1004
- messages: this.processor.getMessages(),
1005
- activeRunIds: this.activeRunIds,
1006
- currentRunId: this.currentRunId
1007
- });
1008
- if (this.isLoading) {
1009
- this.cancelInFlightStream({ setReadyStatus: true });
1010
- this.resetSessionGenerating();
1011
- } else if (this.activeRunIds.size > 0) {
1012
- this.resetSessionGenerating();
1013
- }
1014
- this.persistor.beginClear();
1015
- }
1016
- this.processor.clearMessages();
1017
- this.discardPendingSends();
1018
- this.persistor?.remove();
1019
- this.setError(void 0);
1020
- this.events.messagesCleared();
1021
- }
1022
- /**
1023
- * Add the result of a client-side tool execution
1024
- */
1025
- async addToolResult(result) {
1026
- const clientTool = this.clientToolsRef.current.get(result.tool);
1027
- await this.addToolResultForClientTool(result, clientTool);
1028
- }
1029
- async addToolResultForClientTool(result, clientTool, context) {
1030
- if (clientTool && result.state !== "output-error") {
1031
- try {
1032
- result = {
1033
- ...result,
1034
- output: this.validateClientToolOutput(clientTool, result.output)
1035
- };
1036
- } catch (error) {
1037
- result = {
1038
- ...result,
1039
- output: null,
1040
- state: "output-error",
1041
- errorText: error.message
1042
- };
1043
- }
1044
- }
1045
- this.events.toolResultAdded(
1046
- result.toolCallId,
1047
- result.tool,
1048
- result.output,
1049
- result.state || "output-available",
1050
- context
1051
- );
1052
- this.processor.addToolResult(
1053
- result.toolCallId,
1054
- result.output,
1055
- result.state === "output-error" ? result.errorText || "Tool execution failed" : void 0
1056
- );
1057
- if (this.isLoading) {
1058
- this.queuePostStreamAction(() => this.checkForContinuation());
1059
- return;
1060
- }
1061
- await this.checkForContinuation();
1062
- }
1063
- validateClientToolOutput(clientTool, output) {
1064
- if (clientTool.outputSchema && isStandardSchema(clientTool.outputSchema)) {
1065
- return parseWithStandardSchema(clientTool.outputSchema, output);
1066
- }
1067
- return output;
1068
- }
1069
- /**
1070
- * Respond to a tool approval request
1071
- */
1072
- async addToolApprovalResponse(response) {
1073
- const messages = this.processor.getMessages();
1074
- let foundToolCallId;
1075
- for (const msg of messages) {
1076
- const toolCallPart = msg.parts.find(
1077
- (p) => p.type === "tool-call" && p.approval?.id === response.id
1078
- );
1079
- if (toolCallPart) {
1080
- foundToolCallId = toolCallPart.id;
1081
- break;
1082
- }
1083
- }
1084
- if (foundToolCallId) {
1085
- this.events.toolApprovalResponded(
1086
- response.id,
1087
- foundToolCallId,
1088
- response.approved
1089
- );
1090
- }
1091
- this.processor.addToolApprovalResponse(response.id, response.approved);
1092
- this.devtoolsBridge.emitSnapshot();
1093
- if (this.isLoading) {
1094
- this.queuePostStreamAction(() => this.checkForContinuation());
1095
- return;
1096
- }
1097
- await this.checkForContinuation();
1098
- }
1099
- /**
1100
- * Queue an action to be executed after the current stream ends
1101
- */
1102
- queuePostStreamAction(action) {
1103
- this.postStreamActions.push(action);
1104
- }
1105
- /**
1106
- * Drain and execute all queued post-stream actions
1107
- */
1108
- async drainPostStreamActions() {
1109
- if (this.draining) return;
1110
- this.draining = true;
1111
- try {
1112
- let action;
1113
- while ((action = this.postStreamActions.shift()) !== void 0) {
1114
- await action();
1115
- }
1116
- } finally {
1117
- this.draining = false;
1118
- }
1119
- }
1120
- /**
1121
- * Check if we should continue the flow and do so if needed
1122
- */
1123
- async checkForContinuation() {
1124
- if (this.continuationPending || this.isLoading) {
1125
- this.continuationSkipped = true;
1126
- return;
1127
- }
1128
- if (this.shouldAutoSend()) {
1129
- this.continuationPending = true;
1130
- this.continuationSkipped = false;
1131
- let succeeded = false;
1132
- try {
1133
- succeeded = await this.streamResponse();
1134
- } finally {
1135
- this.continuationPending = false;
1136
- }
1137
- if (this.continuationSkipped && succeeded) {
1138
- this.continuationSkipped = false;
1139
- await this.checkForContinuation();
1140
- }
1141
- }
1142
- }
1143
- /**
1144
- * Check if all tool calls are complete and we should auto-send.
1145
- * Requires that there is at least one tool call in the last assistant message;
1146
- * a text-only response has nothing to auto-send.
1147
- */
1148
- shouldAutoSend() {
1149
- const messages = this.processor.getMessages();
1150
- const lastAssistant = messages.findLast(
1151
- (m) => m.role === "assistant"
1152
- );
1153
- if (!lastAssistant) return false;
1154
- const hasToolCalls = lastAssistant.parts.some(
1155
- (p) => p.type === "tool-call"
1156
- );
1157
- if (!hasToolCalls) return false;
1158
- return this.processor.areAllToolsComplete();
1159
- }
1160
- /**
1161
- * Get current messages
1162
- */
1163
- getMessages() {
1164
- return this.processor.getMessages();
1165
- }
1166
- /**
1167
- * True when an interrupt (or another direct send) claimed the client during
1168
- * a drain. Read via a method so cross-await mutations are not constant-folded
1169
- * by control-flow analysis.
1170
- */
1171
- shouldAbortMessageQueueDrain() {
1172
- return this.isLoading || this.stopMessageQueueDrain;
1173
- }
1174
- /**
1175
- * Deliver queued messages after a successful settle.
1176
- * - `batch`: merge everything currently queued into one send, looping so
1177
- * messages enqueued during that batch stream are not stranded.
1178
- * - `fifo`: walk the queue in a loop, one stream at a time, until empty
1179
- * (or until another send claims the client via interrupt).
1180
- *
1181
- * Uses `deliverMessage` directly so drains do not re-enter `sendMessage`'s
1182
- * busy/queue policy (which would re-queue items and strand the rest).
1183
- */
1184
- async drainQueue() {
1185
- if (this.messageQueueDraining || this.isLoading || this.messageQueue.length === 0) {
1186
- return;
1187
- }
1188
- this.messageQueueDraining = true;
1189
- this.stopMessageQueueDrain = false;
1190
- try {
1191
- if (this.queueConfig.drain === "batch") {
1192
- while (this.messageQueue.length > 0) {
1193
- if (this.shouldAbortMessageQueueDrain()) {
1194
- return;
1195
- }
1196
- const items = this.messageQueue.splice(0);
1197
- this.emitQueueChange();
1198
- const merged = mergeQueuedMessages(items);
1199
- const completed = await this.deliverMessage(
1200
- merged.content,
1201
- merged.body
1202
- );
1203
- if (!completed || this.shouldAbortMessageQueueDrain()) {
1204
- return;
1205
- }
1206
- }
1207
- return;
1208
- }
1209
- while (this.messageQueue.length > 0) {
1210
- if (this.shouldAbortMessageQueueDrain()) {
1211
- return;
1212
- }
1213
- const next = this.messageQueue.shift();
1214
- if (next === void 0) {
1215
- return;
1216
- }
1217
- this.emitQueueChange();
1218
- const completed = await this.deliverMessage(next.content, next.body);
1219
- if (!completed || this.shouldAbortMessageQueueDrain()) {
1220
- return;
1221
- }
1222
- }
1223
- } finally {
1224
- this.messageQueueDraining = false;
1225
- this.stopMessageQueueDrain = false;
1226
- }
1227
- }
1228
- /**
1229
- * Drop any in-flight send claim and discard pending queued messages
1230
- * (stop / error / clear / unsubscribe / reload).
1231
- */
1232
- discardPendingSends() {
1233
- this.sendInFlight = false;
1234
- this.flushQueue();
1235
- }
1236
- /**
1237
- * Get the current send queue (messages held while a stream was in flight).
1238
- */
1239
- getQueue() {
1240
- return this.messageQueue.map(({ id, content, createdAt }) => ({
1241
- id,
1242
- content,
1243
- createdAt
1244
- }));
1245
- }
1246
- emitQueueChange() {
1247
- this.callbacksRef.current.onQueueChange(this.getQueue());
1248
- this.devtoolsBridge.emitSnapshot();
1249
- }
1250
- /**
1251
- * Remove a queued message by id before it drains.
1252
- */
1253
- cancelQueued(id) {
1254
- const index = this.messageQueue.findIndex((m) => m.id === id);
1255
- if (index === -1) return;
1256
- this.messageQueue.splice(index, 1);
1257
- this.emitQueueChange();
1258
- }
1259
- /**
1260
- * Discard all pending queued messages (stop / error / clear / unsubscribe /
1261
- * reload). Does not send them. Emits `onQueueChange([])` when anything was
1262
- * removed.
1263
- */
1264
- flushQueue() {
1265
- if (this.messageQueue.length === 0) return;
1266
- this.messageQueue = [];
1267
- this.emitQueueChange();
1268
- }
1269
- /**
1270
- * Get loading state
1271
- */
1272
- getIsLoading() {
1273
- return this.isLoading;
1274
- }
1275
- /**
1276
- * Get current status
1277
- */
1278
- getStatus() {
1279
- return this.status;
1280
- }
1281
- /**
1282
- * Get whether the subscription loop is active
1283
- */
1284
- getIsSubscribed() {
1285
- return this.isSubscribed;
1286
- }
1287
- /**
1288
- * Get current connection lifecycle status
1289
- */
1290
- getConnectionStatus() {
1291
- return this.connectionStatus;
1292
- }
1293
- /**
1294
- * Whether the shared session is actively generating.
1295
- * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).
1296
- * Unlike `isLoading` (request-local), this reflects shared generation
1297
- * activity visible to all subscribers (e.g. across tabs/devices).
1298
- */
1299
- getSessionGenerating() {
1300
- return this.sessionGenerating;
1301
- }
1302
- /**
1303
- * Get current error
1304
- */
1305
- getError() {
1306
- return this.error;
1307
- }
1308
- /**
1309
- * Manually set messages
1310
- */
1311
- setMessagesManually(messages) {
1312
- this.processor.setMessages(messages);
1313
- this.devtoolsBridge.emitSnapshot();
1314
- }
1315
- updateOptions(options) {
1316
- if (options.connection !== void 0 || options.fetcher !== void 0) {
1317
- const wasSubscribed = this.isSubscribed;
1318
- if (this.isLoading) {
1319
- this.cancelInFlightStream({
1320
- setReadyStatus: true,
1321
- abortSubscription: true
1322
- });
1323
- } else if (wasSubscribed) {
1324
- this.abortSubscriptionLoop();
1325
- }
1326
- this.resetSessionGenerating();
1327
- this.setIsSubscribed(false);
1328
- this.setConnectionStatus("disconnected");
1329
- this.connection = normalizeConnectionAdapter(
1330
- resolveTransport({
1331
- connection: options.connection,
1332
- fetcher: options.fetcher
1333
- })
1334
- );
1335
- if (wasSubscribed) {
1336
- this.subscribe();
1337
- }
1338
- }
1339
- if (options.body !== void 0) {
1340
- this.bodyOption = options.body;
1341
- }
1342
- if (options.forwardedProps !== void 0) {
1343
- this.forwardedPropsOption = options.forwardedProps;
1344
- }
1345
- if ("context" in options) {
1346
- this.context = options.context;
1347
- }
1348
- if (options.tools !== void 0) {
1349
- this.clientToolsRef.current = /* @__PURE__ */ new Map();
1350
- for (const tool of options.tools) {
1351
- this.clientToolsRef.current.set(tool.name, tool);
1352
- }
1353
- this.devtoolsBridge.notifyToolsChanged();
1354
- }
1355
- if (options.queue !== void 0) {
1356
- this.queueConfig = normalizeQueueOption(options.queue);
1357
- }
1358
- if (options.onResponse !== void 0) {
1359
- this.callbacksRef.current.onResponse = options.onResponse;
1360
- }
1361
- if (options.onChunk !== void 0) {
1362
- this.callbacksRef.current.onChunk = options.onChunk;
1363
- }
1364
- if (options.onFinish !== void 0) {
1365
- this.callbacksRef.current.onFinish = options.onFinish;
1366
- }
1367
- if (options.onError !== void 0) {
1368
- this.callbacksRef.current.onError = options.onError;
1369
- }
1370
- if (options.onSubscriptionChange !== void 0) {
1371
- this.callbacksRef.current.onSubscriptionChange = options.onSubscriptionChange;
1372
- }
1373
- if (options.onConnectionStatusChange !== void 0) {
1374
- this.callbacksRef.current.onConnectionStatusChange = options.onConnectionStatusChange;
1375
- }
1376
- if (options.onSessionGeneratingChange !== void 0) {
1377
- this.callbacksRef.current.onSessionGeneratingChange = options.onSessionGeneratingChange;
1378
- }
1379
- if (options.onQueueChange !== void 0) {
1380
- this.callbacksRef.current.onQueueChange = options.onQueueChange;
1381
- }
1382
- if (options.onCustomEvent !== void 0) {
1383
- this.callbacksRef.current.onCustomEvent = options.onCustomEvent;
1384
- }
1385
- }
1386
- dispose() {
1387
- this.unsubscribe();
1388
- this.devtoolsBridge.dispose();
1389
- this.devtoolsMounted = false;
1390
- }
72
+ /**
73
+ * Extract a boolean approval decision from an AG-UI resume payload, if present.
74
+ * Tool-approval resolutions carry `{ approved: boolean, ... }`; generic
75
+ * interrupt payloads do not.
76
+ */
77
+ function readApprovalApproved(payload) {
78
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return;
79
+ if (!("approved" in payload) || typeof payload.approved !== "boolean") return;
80
+ return payload.approved;
1391
81
  }
1392
- export {
1393
- ChatClient,
1394
- normalizeQueueOption
82
+ function readResumeState(snapshot) {
83
+ const value = snapshot;
84
+ if (value === null || typeof value !== "object" || !("resumeState" in value)) return;
85
+ const resumeState = value.resumeState;
86
+ if (resumeState === null || typeof resumeState !== "object" || !("threadId" in resumeState) || typeof resumeState.threadId !== "string" || resumeState.threadId.length === 0 || !("runId" in resumeState) || typeof resumeState.runId !== "string" || resumeState.runId.length === 0) return;
87
+ return {
88
+ threadId: resumeState.threadId,
89
+ runId: resumeState.runId
90
+ };
91
+ }
92
+ /**
93
+ * How long a reload rejoin waits for its first chunk before giving up. A durable
94
+ * backend keeps a from-start join open waiting for a producer; without this
95
+ * bound a stale pointer to an unknown/evicted run would pin the UI loading for
96
+ * the backend's full first-chunk deadline (tens of seconds). Kept short so the
97
+ * client decides "reachable or not" quickly.
98
+ */
99
+ var REJOIN_CONNECT_DEADLINE_MS = 2e3;
100
+ /**
101
+ * Chunk types that (re)build the assistant message on a rejoin. The hydrated
102
+ * in-flight partial is dropped only when one of these arrives — never on
103
+ * `RUN_STARTED` alone — so a rejoin that connects but delivers no content cannot
104
+ * leave an empty assistant bubble.
105
+ */
106
+ var REJOIN_REBUILD_TRIGGERS = /* @__PURE__ */ new Set([
107
+ "TEXT_MESSAGE_START",
108
+ "TEXT_MESSAGE_CONTENT",
109
+ "TOOL_CALL_START",
110
+ "MESSAGES_SNAPSHOT"
111
+ ]);
112
+ var ChatClient = class {
113
+ processor;
114
+ connection;
115
+ uniqueId;
116
+ threadId;
117
+ persistor;
118
+ clearedStreamTracker = new ClearedStreamTracker();
119
+ currentRunId = null;
120
+ lastResume = null;
121
+ rejoinedRunId = null;
122
+ interruptManager;
123
+ activeInterruptSubmission;
124
+ interruptSubmissionFailure;
125
+ joinedRunWaiters = /* @__PURE__ */ new Map();
126
+ pendingResumeParentRunId = null;
127
+ pendingResumeThreadId = null;
128
+ pendingResumeItems = null;
129
+ activeResumeThreadId = null;
130
+ activeResumeRunId = null;
131
+ bodyOption = {};
132
+ forwardedPropsOption = {};
133
+ context = void 0;
134
+ pendingMessageBody = void 0;
135
+ queueConfig;
136
+ messageQueue = [];
137
+ /**
138
+ * True from the moment `sendMessage` claims the client until its
139
+ * `streamResponse` settles. Closes the race where concurrent callers both
140
+ * see `isLoading === false`, both append a user message, and only one stream
141
+ * actually runs (leaving stranded user messages with no reply).
142
+ */
143
+ sendInFlight = false;
144
+ /**
145
+ * True while `drainQueue` is delivering queued messages. Concurrent
146
+ * `sendMessage` calls during a drain are treated as busy and follow
147
+ * `whenBusy` (default: queue).
148
+ */
149
+ messageQueueDraining = false;
150
+ /**
151
+ * Set by `whenBusy: 'interrupt'` so an in-progress FIFO drain loop stops
152
+ * before starting the next queued item (the interrupting send owns the client).
153
+ */
154
+ stopMessageQueueDrain = false;
155
+ /**
156
+ * Sync claim held for the duration of `deliverMessage` so concurrent
157
+ * deliverers cannot both append a user message before only one stream runs.
158
+ */
159
+ deliverClaim = false;
160
+ isLoading = false;
161
+ isSubscribed = false;
162
+ error = void 0;
163
+ status = "ready";
164
+ connectionStatus = "disconnected";
165
+ abortController = null;
166
+ clientToolsRef;
167
+ devtoolsBridge;
168
+ /**
169
+ * Alias for `this.events`. The bridge installs an
170
+ * emitter that auto-attaches run/thread context and auto-emits a
171
+ * snapshot after every event, so chat-client only ever calls
172
+ * `this.events.X(...)` exactly like it did before devtools landed.
173
+ */
174
+ events;
175
+ currentStreamId = null;
176
+ currentMessageId = null;
177
+ postStreamActions = [];
178
+ pendingToolExecutions = /* @__PURE__ */ new Map();
179
+ activeClientTools = null;
180
+ activeContext = void 0;
181
+ continuationPending = false;
182
+ subscriptionAbortController = null;
183
+ processingResolve = null;
184
+ errorReportedGeneration = null;
185
+ streamGeneration = 0;
186
+ continuationSkipped = false;
187
+ draining = false;
188
+ sessionGenerating = false;
189
+ activeRunIds = /* @__PURE__ */ new Set();
190
+ /** Latched by `dispose()`; stops any late async callback starting new work. */
191
+ disposed = false;
192
+ /** Whether a view is currently watching. See `attach` / `detach`. */
193
+ tailing = false;
194
+ /** Constructor inputs `attach()` needs on every re-attach, not just the first. */
195
+ rejoinRunId;
196
+ cachesMessages;
197
+ devtoolsMounted = false;
198
+ callbacksRef;
199
+ constructor(options) {
200
+ this.threadId = options.threadId || this.generateUniqueId("thread");
201
+ this.uniqueId = options.id || this.threadId;
202
+ 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));
207
+ }
208
+ this.bodyOption = options.body || {};
209
+ this.forwardedPropsOption = options.forwardedProps || {};
210
+ this.context = options.context;
211
+ this.queueConfig = normalizeQueueOption(options.queue);
212
+ this.connection = normalizeConnectionAdapter(resolveTransport(options));
213
+ this.clientToolsRef = { current: /* @__PURE__ */ new Map() };
214
+ if (options.tools) for (const tool of options.tools) this.clientToolsRef.current.set(tool.name, tool);
215
+ this.devtoolsBridge = (options.devtoolsBridgeFactory ?? createNoOpChatDevtoolsBridge)(this.buildDevtoolsBridgeOptions(options.devtools));
216
+ this.events = this.devtoolsBridge.events;
217
+ this.callbacksRef = { current: {
218
+ onResponse: options.onResponse || (() => {}),
219
+ onChunk: options.onChunk || (() => {}),
220
+ onFinish: options.onFinish || (() => {}),
221
+ onError: options.onError || (() => {}),
222
+ onMessagesChange: options.onMessagesChange || (() => {}),
223
+ onLoadingChange: options.onLoadingChange || (() => {}),
224
+ onErrorChange: options.onErrorChange || (() => {}),
225
+ onStatusChange: options.onStatusChange || (() => {}),
226
+ onSubscriptionChange: options.onSubscriptionChange || (() => {}),
227
+ onConnectionStatusChange: options.onConnectionStatusChange || (() => {}),
228
+ onSessionGeneratingChange: options.onSessionGeneratingChange || (() => {}),
229
+ onQueueChange: options.onQueueChange || (() => {}),
230
+ onResumeStateChange: options.onResumeStateChange || (() => {}),
231
+ onRunIdChange: options.onRunIdChange || (() => {}),
232
+ onInterruptStateChange: options.onInterruptStateChange || (() => {}),
233
+ onCustomEvent: options.onCustomEvent || (() => {})
234
+ } };
235
+ this.interruptManager = new InterruptManager({
236
+ ...options.tools !== void 0 ? { tools: options.tools } : {},
237
+ submit: (submission) => this.submitInterruptBatch(submission),
238
+ onChange: () => this.notifyResumeStateChange()
239
+ });
240
+ if (options.initialResumeSnapshot) this.applyResumeSnapshot(options.initialResumeSnapshot);
241
+ const persistedState = this.persistor?.readInitial();
242
+ const syncPersistedState = persistedState instanceof Promise ? void 0 : persistedState;
243
+ const initialMessages = syncPersistedState ? syncPersistedState.messages : options.initialMessages;
244
+ let rejoinRunId = null;
245
+ if (syncPersistedState?.resume) {
246
+ const snapshot = syncPersistedState.resume;
247
+ if (Array.isArray(snapshot.pendingInterrupts) && snapshot.pendingInterrupts.length > 0) this.applyResumeSnapshot(snapshot);
248
+ else if (snapshot.resumeState.runId) rejoinRunId = snapshot.resumeState.runId;
249
+ }
250
+ if (!rejoinRunId && options.initialResumeSnapshot) {
251
+ const snapshot = options.initialResumeSnapshot;
252
+ if (!(Array.isArray(snapshot.pendingInterrupts) && snapshot.pendingInterrupts.length > 0) && snapshot.resumeState.runId) rejoinRunId = snapshot.resumeState.runId;
253
+ }
254
+ this.processor = new StreamProcessor({
255
+ ...options.streamProcessor?.chunkStrategy ? { chunkStrategy: options.streamProcessor.chunkStrategy } : {},
256
+ ...initialMessages ? { initialMessages } : {},
257
+ events: {
258
+ onMessagesChange: (messages) => {
259
+ this.persistor?.notifyMessagesChanged(messages);
260
+ this.callbacksRef.current.onMessagesChange(messages);
261
+ },
262
+ onStreamStart: () => {
263
+ this.setStatus("streaming");
264
+ const assistantMessageId = this.processor.getCurrentAssistantMessageId();
265
+ if (!assistantMessageId) return;
266
+ const assistantMessage = this.processor.getMessages().find((m) => m.id === assistantMessageId);
267
+ if (assistantMessage) {
268
+ this.currentMessageId = assistantMessage.id;
269
+ this.events.messageAppended(assistantMessage, this.currentStreamId || void 0);
270
+ }
271
+ },
272
+ onStreamEnd: (message) => {
273
+ this.callbacksRef.current.onFinish(message);
274
+ this.setStatus("ready");
275
+ this.resolveProcessing();
276
+ },
277
+ onError: (error) => {
278
+ this.reportStreamError(error);
279
+ },
280
+ onTextUpdate: (messageId, content) => {
281
+ if (this.currentStreamId) this.events.textUpdated(this.currentStreamId, messageId, content);
282
+ },
283
+ onThinkingUpdate: (messageId, content) => {
284
+ if (this.currentStreamId) this.events.thinkingUpdated(this.currentStreamId, messageId, content, void 0);
285
+ },
286
+ onStructuredOutputChange: (args) => {
287
+ const streamId = this.devtoolsBridge.resolveStreamId();
288
+ const eventName = args.phase === "start" ? "structured-output:started" : args.phase === "complete" ? "structured-output:completed" : args.phase === "error" ? "structured-output:errored" : "structured-output:updated";
289
+ this.currentMessageId = args.messageId;
290
+ this.events.structuredOutputChanged(eventName, streamId, args.messageId, {
291
+ status: args.status,
292
+ raw: args.raw,
293
+ ...args.partial !== void 0 ? { partial: args.partial } : {},
294
+ ...args.data !== void 0 ? { data: args.data } : {},
295
+ ...args.reasoning !== void 0 ? { reasoning: args.reasoning } : {},
296
+ ...args.errorMessage !== void 0 ? { errorMessage: args.errorMessage } : {},
297
+ ...args.delta !== void 0 ? { delta: args.delta } : {}
298
+ });
299
+ },
300
+ onToolCallStateChange: (messageId, toolCallId, state, args) => {
301
+ const toolName = (this.processor.getMessages().find((m) => m.id === messageId)?.parts.find((p) => p.type === "tool-call" && p.id === toolCallId))?.name || "unknown";
302
+ if (this.currentStreamId) this.events.toolCallStateChanged(this.currentStreamId, messageId, toolCallId, toolName, state, args);
303
+ },
304
+ onToolCall: (args) => {
305
+ const clientTool = (this.activeClientTools ?? this.clientToolsRef.current).get(args.toolName);
306
+ const executeFunc = clientTool?.execute;
307
+ if (executeFunc) {
308
+ const runEventContext = this.devtoolsBridge.getCurrentRunEventContext();
309
+ const executionPromise = (async () => {
310
+ try {
311
+ const context = this.activeClientTools === null ? this.context : this.activeContext;
312
+ const output = await executeFunc(args.input, {
313
+ toolCallId: args.toolCallId,
314
+ context,
315
+ emitCustomEvent: () => {}
316
+ });
317
+ await this.addToolResultForClientTool({
318
+ toolCallId: args.toolCallId,
319
+ tool: args.toolName,
320
+ output,
321
+ state: "output-available"
322
+ }, clientTool, runEventContext);
323
+ } catch (error) {
324
+ await this.addToolResultForClientTool({
325
+ toolCallId: args.toolCallId,
326
+ tool: args.toolName,
327
+ output: null,
328
+ state: "output-error",
329
+ errorText: error.message
330
+ }, clientTool, runEventContext);
331
+ } finally {
332
+ this.pendingToolExecutions.delete(args.toolCallId);
333
+ }
334
+ })();
335
+ this.pendingToolExecutions.set(args.toolCallId, executionPromise);
336
+ }
337
+ },
338
+ onApprovalRequest: (args) => {
339
+ const streamId = this.devtoolsBridge.resolveStreamId();
340
+ const messageIdForApproval = this.findMessageIdForToolCall(args.toolCallId) ?? this.currentMessageId ?? "";
341
+ this.events.approvalRequested(streamId, messageIdForApproval, args.toolCallId, args.toolName, args.input, args.approvalId);
342
+ },
343
+ onCustomEvent: (eventType, data, context) => {
344
+ if (eventType === "memory:state") this.devtoolsBridge.recordMemoryState(data);
345
+ this.callbacksRef.current.onCustomEvent(eventType, data, context);
346
+ }
347
+ }
348
+ });
349
+ this.persistor?.hydrateAsync(persistedState);
350
+ this.rejoinRunId = rejoinRunId;
351
+ this.cachesMessages = cachesMessages;
352
+ }
353
+ /**
354
+ * START TAILING: re-attach to an in-flight run so its chunks arrive here.
355
+ *
356
+ * Called by the constructor, and again by a UI wrapper every time its view
357
+ * mounts. Idempotent — attaching while already attached does nothing — so the
358
+ * constructor call and a wrapper's first mount cost one attach between them.
359
+ *
360
+ * Pairs with {@link detach}. The pair exists because tailing used to begin ONLY
361
+ * in the constructor, which meant a view could never stop tailing and then
362
+ * resume: unmount had to either keep the connection open or lose it for good.
363
+ * Keeping it open is what starved the page — a browser allows ~6 connections per
364
+ * origin, and one long-lived stream per view reaches that after a handful of
365
+ * views, after which every other request queues (measured: an in-page fetch took
366
+ * over two minutes while the same request from outside the browser took 17ms).
367
+ */
368
+ attach() {
369
+ if (this.disposed || this.tailing) return;
370
+ this.tailing = true;
371
+ if (this.rejoinRunId) this.maybeRejoinInFlight(this.rejoinRunId);
372
+ if (!this.cachesMessages && this.connection.hydrate) this.hydrateFromServer();
373
+ }
374
+ /**
375
+ * STOP TAILING: drop the connection, keep everything else.
376
+ *
377
+ * Called by a UI wrapper when its view unmounts. The transcript, the resume
378
+ * pointer and the run id all stay, so a later {@link attach} repaints instantly
379
+ * and re-tails from the durable log — nothing is lost, because the run keeps
380
+ * going server-side and its log holds every chunk.
381
+ *
382
+ * Deliberately NOT `dispose()`: this client is expected back. And deliberately
383
+ * not `stop()`, which means "the user ended this run" — detaching says only that
384
+ * nobody is watching right now.
385
+ *
386
+ * `rejoinedRunId` is cleared so the next `attach` can re-join the same run;
387
+ * without that reset the guard in {@link maybeRejoinInFlight} would treat the
388
+ * run as already joined and the view would come back silent.
389
+ */
390
+ detach() {
391
+ if (!this.tailing) return;
392
+ this.tailing = false;
393
+ this.cancelInFlightStream({ setReadyStatus: true });
394
+ this.rejoinedRunId = null;
395
+ }
396
+ applyResumeSnapshot(snapshot) {
397
+ const resumeState = readResumeState(snapshot);
398
+ if (resumeState === void 0) {
399
+ this.interruptManager.reset();
400
+ return;
401
+ }
402
+ this.lastResume = resumeState;
403
+ const pendingInterrupts = Array.isArray(snapshot.pendingInterrupts) ? snapshot.pendingInterrupts : [];
404
+ if (pendingInterrupts.length === 0) {
405
+ this.interruptManager.reset();
406
+ return;
407
+ }
408
+ const generation = this.interruptGeneration(pendingInterrupts);
409
+ this.interruptManager.hydrate({
410
+ threadId: resumeState.threadId,
411
+ interruptedRunId: resumeState.runId,
412
+ generation,
413
+ interrupts: pendingInterrupts
414
+ });
415
+ }
416
+ /**
417
+ * Apply a resume snapshot read from durable storage. Restores interrupt state,
418
+ * and for a bare in-flight run (no pending interrupts) also rejoins it. This is
419
+ * the async-store counterpart to the synchronous rejoin in the constructor:
420
+ * `applyResumeSnapshot` alone only handles interrupts, so an async store
421
+ * (`indexedDBPersistence`) would otherwise never rejoin a mid-stream run.
422
+ */
423
+ applyPersistedResume(snapshot) {
424
+ this.applyResumeSnapshot(snapshot);
425
+ const hasInterrupts = Array.isArray(snapshot.pendingInterrupts) && snapshot.pendingInterrupts.length > 0;
426
+ const runId = snapshot.resumeState?.runId;
427
+ if (!hasInterrupts && runId) this.maybeRejoinInFlight(runId);
428
+ }
429
+ /**
430
+ * Rejoin a persisted in-flight run, guarded so it fires at most once and never
431
+ * while another run is already active. Skipped when the connection is not
432
+ * resumable (`joinRun` absent), so a non-durable transport is a no-op.
433
+ */
434
+ maybeRejoinInFlight(runId) {
435
+ if (!this.connection.joinRun) return;
436
+ if (this.disposed || !this.tailing) return;
437
+ if (this.rejoinedRunId === runId) return;
438
+ if (this.isLoading || this.abortController) return;
439
+ this.rejoinedRunId = runId;
440
+ this.resumeInFlightRun(runId);
441
+ }
442
+ /**
443
+ * Server-authoritative mount hydration (`persistence: true`). The client holds
444
+ * no transcript and no run pointer; on mount it asks the server — keyed by the
445
+ * stable threadId — for the stored transcript and whether a run is still
446
+ * generating. The transcript repaints immediately; an in-flight run is tailed
447
+ * through the same durability rejoin as a reload. Best-effort and
448
+ * non-blocking: a failure leaves the client empty rather than throwing, and a
449
+ * send that starts first owns the client (hydration then backs off).
450
+ */
451
+ hydrateFromServer() {
452
+ const hydrate = this.connection.hydrate;
453
+ if (!hydrate) return;
454
+ if (this.isLoading || this.abortController) return;
455
+ if (this.disposed) return;
456
+ (async () => {
457
+ let result;
458
+ try {
459
+ result = await hydrate(this.threadId);
460
+ } catch {
461
+ return;
462
+ }
463
+ if (this.disposed || !this.tailing) return;
464
+ if (this.isLoading || this.abortController) return;
465
+ if (result.messages.length > 0) this.processor.setMessages(result.messages);
466
+ if (result.interrupts && result.interrupts.pending.length > 0) this.applyResumeSnapshot({
467
+ resumeState: {
468
+ threadId: this.threadId,
469
+ runId: result.interrupts.runId
470
+ },
471
+ pendingInterrupts: result.interrupts.pending
472
+ });
473
+ else if (result.activeRun?.runId) this.maybeRejoinInFlight(result.activeRun.runId);
474
+ })();
475
+ }
476
+ mountDevtools() {
477
+ if (this.devtoolsMounted) return;
478
+ this.devtoolsMounted = true;
479
+ this.devtoolsBridge.mountWithTools(this.processor.getMessages().length);
480
+ }
481
+ /**
482
+ * Drain a runId-less RUN_ERROR that belongs to a cleared run the client is
483
+ * still tracking. The persistor owns the cleared-run bookkeeping; the client
484
+ * owns the active-run / session / processing state.
485
+ */
486
+ drainIgnoredRunlessChunk(chunk) {
487
+ if (chunk.type !== "RUN_ERROR") return;
488
+ const runId = this.clearedStreamTracker.takeRunlessRunId();
489
+ if (!runId) return;
490
+ this.activeRunIds.delete(runId);
491
+ this.setSessionGenerating(this.activeRunIds.size > 0);
492
+ this.resolveProcessing();
493
+ }
494
+ retireIgnoredClearedTerminalChunk(chunk) {
495
+ if (chunk.type !== "RUN_FINISHED" && chunk.type !== "RUN_ERROR") return;
496
+ const runId = getChunkRunId(chunk) ?? this.clearedStreamTracker.takeRunlessRunId();
497
+ if (!runId) return;
498
+ this.activeRunIds.delete(runId);
499
+ this.setSessionGenerating(this.activeRunIds.size > 0);
500
+ if (!getChunkRunId(chunk)) this.resolveProcessing();
501
+ }
502
+ updateRunLifecycle(chunk, options) {
503
+ if (chunk.type === "RUN_STARTED") {
504
+ const chunkRunId = getChunkRunId(chunk) ?? chunk.runId;
505
+ this.activeResumeThreadId = "threadId" in chunk && typeof chunk.threadId === "string" ? chunk.threadId : this.activeResumeThreadId;
506
+ this.activeResumeRunId = chunkRunId;
507
+ this.activeRunIds.add(chunkRunId);
508
+ this.clearedStreamTracker.onRunStarted(chunkRunId);
509
+ this.setSessionGenerating(true);
510
+ if (this.persistor && this.connection.joinRun && !this.lastResume) this.persistResumeSnapshot({
511
+ threadId: this.activeResumeThreadId ?? this.threadId,
512
+ runId: chunkRunId
513
+ });
514
+ return;
515
+ }
516
+ if (chunk.type !== "RUN_FINISHED" && chunk.type !== "RUN_ERROR") return;
517
+ const runId = getChunkRunId(chunk);
518
+ if (runId) {
519
+ this.activeRunIds.delete(runId);
520
+ this.clearedStreamTracker.onRunSettled(runId);
521
+ } else if (chunk.type === "RUN_ERROR") {
522
+ this.activeRunIds.clear();
523
+ this.clearedStreamTracker.onSessionRunError();
524
+ }
525
+ this.setSessionGenerating(this.activeRunIds.size > 0);
526
+ if (options?.resolveProcessing !== false) this.resolveProcessing();
527
+ }
528
+ /**
529
+ * Track interrupt state off the stream's terminal events. A RUN_FINISHED with
530
+ * an interrupt outcome records the pending interrupts + the run/thread to
531
+ * resume; any other terminal event for the tracked/current run clears that
532
+ * state. This is interrupt (state) resume — there is no delivery cursor.
533
+ */
534
+ observeInterruptState(chunk) {
535
+ if (chunk.type !== "RUN_FINISHED" && chunk.type !== "RUN_ERROR") return;
536
+ if (this.activeInterruptSubmission && chunk.type === "RUN_ERROR") return;
537
+ const runId = getChunkRunId(chunk);
538
+ const threadId = "threadId" in chunk && typeof chunk.threadId === "string" ? chunk.threadId : this.activeResumeThreadId;
539
+ if (chunk.type === "RUN_FINISHED" && chunk.outcome?.type === "interrupt") {
540
+ const interruptedRunId = this.currentRunId ?? runId ?? this.activeResumeRunId ?? "";
541
+ this.lastResume = {
542
+ threadId: threadId ?? this.threadId,
543
+ runId: interruptedRunId
544
+ };
545
+ this.interruptManager.hydrate({
546
+ threadId: this.lastResume.threadId,
547
+ interruptedRunId,
548
+ generation: this.interruptGeneration(chunk.outcome.interrupts),
549
+ interrupts: chunk.outcome.interrupts
550
+ });
551
+ return;
552
+ }
553
+ const isRunlessSessionError = chunk.type === "RUN_ERROR" && !runId;
554
+ const isTrackedRunTerminal = Boolean(runId && this.lastResume?.runId === runId);
555
+ const isCurrentRunTerminal = Boolean(runId && this.currentRunId === runId || this.currentRunId && this.lastResume?.runId === this.currentRunId);
556
+ const isActiveStreamRunTerminal = Boolean(this.isLoading && runId && (runId === this.activeResumeRunId || runId === this.currentRunId));
557
+ const isCurrentStreamTerminal = this.isLoading && chunk.type === "RUN_FINISHED" && !runId;
558
+ const isActiveInterruptSubmissionTerminal = Boolean(this.activeInterruptSubmission && this.isLoading && chunk.type === "RUN_FINISHED" && chunk.outcome?.type !== "interrupt");
559
+ if (isRunlessSessionError || isTrackedRunTerminal || isCurrentRunTerminal || isActiveStreamRunTerminal || isCurrentStreamTerminal || isActiveInterruptSubmissionTerminal) {
560
+ this.lastResume = null;
561
+ this.persistor?.persistResumeSnapshot(null);
562
+ this.interruptManager.reset();
563
+ return;
564
+ }
565
+ this.notifyResumeStateChange();
566
+ }
567
+ /**
568
+ * The interrupt-resume state for the active/interrupted run (its run/thread
569
+ * ids), or null when there is nothing to resume. Apps can persist this to
570
+ * resume interrupts across a full reload.
571
+ */
572
+ getResumeState() {
573
+ return this.lastResume ? { ...this.lastResume } : null;
574
+ }
575
+ /**
576
+ * The id of the run this client has in flight — one it started via a send or
577
+ * rejoined via `joinRun` — or null when there is none. Unlike
578
+ * {@link getResumeState}, this tracks ordinary runs too, not only one that is
579
+ * interrupted or being resumed. A run another client started and that arrives
580
+ * over a live subscription is not this client's run and is not reported here.
581
+ */
582
+ getCurrentRunId() {
583
+ return this.currentRunId;
584
+ }
585
+ setCurrentRunId(runId) {
586
+ if (this.currentRunId === runId) return;
587
+ this.currentRunId = runId;
588
+ this.callbacksRef.current.onRunIdChange(runId);
589
+ }
590
+ getInterruptState() {
591
+ return this.interruptManager.getState();
592
+ }
593
+ getInterrupts() {
594
+ return this.interruptManager.getInterrupts();
595
+ }
596
+ /** @deprecated Use getInterrupts(). */
597
+ getPendingInterrupts() {
598
+ return this.interruptManager.getInterrupts();
599
+ }
600
+ resolveInterrupts(resolution) {
601
+ if (typeof resolution === "boolean") {
602
+ this.interruptManager.resolve(resolution);
603
+ return;
604
+ }
605
+ this.interruptManager.resolve(resolution);
606
+ }
607
+ cancelInterrupts() {
608
+ this.interruptManager.cancel();
609
+ }
610
+ retryInterrupts() {
611
+ this.interruptManager.retry();
612
+ }
613
+ /** Unsafe low-level resume escape hatch. Prefer bound interrupt methods. */
614
+ resumeInterruptsUnsafe(resume, state) {
615
+ const target = state ?? this.lastResume;
616
+ if (!target) return Promise.resolve(false);
617
+ if (this.isLoading) return new Promise((resolve, reject) => {
618
+ this.queuePostStreamAction(async () => {
619
+ try {
620
+ resolve(await this.resumeInterruptsUnsafe(resume, target));
621
+ } catch (error) {
622
+ reject(error);
623
+ }
624
+ });
625
+ });
626
+ this.pendingResumeThreadId = target.threadId;
627
+ this.pendingResumeParentRunId = target.runId;
628
+ this.pendingResumeItems = [...resume];
629
+ return this.streamResponse();
630
+ }
631
+ /** @deprecated Use bound interrupt methods or resumeInterruptsUnsafe(). */
632
+ resumeInterrupts(resume, state) {
633
+ return this.resumeInterruptsUnsafe(resume, state);
634
+ }
635
+ async submitInterruptBatch(submission) {
636
+ this.activeInterruptSubmission = submission;
637
+ this.interruptSubmissionFailure = void 0;
638
+ for (const resolution of submission.resolutions) {
639
+ const approved = readApprovalApproved(resolution.payload);
640
+ if (approved === void 0) continue;
641
+ const approvalId = resolution.interruptId;
642
+ this.processor.addToolApprovalResponse(approvalId, approved);
643
+ }
644
+ const resumed = await this.resumeInterruptsUnsafe([...submission.resolutions], {
645
+ threadId: submission.threadId,
646
+ runId: submission.interruptedRunId
647
+ }).finally(() => {
648
+ this.activeInterruptSubmission = void 0;
649
+ });
650
+ const failure = this.takeInterruptSubmissionFailure();
651
+ if (failure !== void 0) throw { errors: failure.errors };
652
+ if (!resumed) throw new Error("Interrupt continuation could not be started.");
653
+ if (this.lastResume?.runId === submission.interruptedRunId) {
654
+ this.lastResume = null;
655
+ this.interruptManager.reset();
656
+ }
657
+ }
658
+ takeInterruptSubmissionFailure() {
659
+ const failure = this.interruptSubmissionFailure;
660
+ this.interruptSubmissionFailure = void 0;
661
+ return failure;
662
+ }
663
+ interruptGeneration(interrupts) {
664
+ let generation;
665
+ for (const interrupt of interrupts) {
666
+ const candidate = interrupt.metadata?.["tanstack:interruptBinding"];
667
+ if (candidate === null || typeof candidate !== "object" || !("generation" in candidate) || typeof candidate.generation !== "number" || !Number.isInteger(candidate.generation) || candidate.generation < 0) return 0;
668
+ if (generation !== void 0 && generation !== candidate.generation) return 0;
669
+ generation = candidate.generation;
670
+ }
671
+ return generation ?? 0;
672
+ }
673
+ generateUniqueId(prefix) {
674
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`;
675
+ }
676
+ setIsLoading(isLoading) {
677
+ this.isLoading = isLoading;
678
+ this.callbacksRef.current.onLoadingChange(isLoading);
679
+ this.events.loadingChanged(isLoading);
680
+ }
681
+ setStatus(status) {
682
+ this.status = status;
683
+ this.callbacksRef.current.onStatusChange(status);
684
+ this.devtoolsBridge.emitSnapshot();
685
+ }
686
+ setIsSubscribed(isSubscribed) {
687
+ this.isSubscribed = isSubscribed;
688
+ this.callbacksRef.current.onSubscriptionChange(isSubscribed);
689
+ this.devtoolsBridge.emitSnapshot();
690
+ }
691
+ setConnectionStatus(status) {
692
+ this.connectionStatus = status;
693
+ this.callbacksRef.current.onConnectionStatusChange(status);
694
+ this.devtoolsBridge.emitSnapshot();
695
+ }
696
+ setSessionGenerating(isGenerating) {
697
+ if (this.sessionGenerating === isGenerating) return;
698
+ this.sessionGenerating = isGenerating;
699
+ this.callbacksRef.current.onSessionGeneratingChange(isGenerating);
700
+ this.devtoolsBridge.emitSnapshot();
701
+ }
702
+ notifyResumeStateChange() {
703
+ const resumeState = this.getResumeState();
704
+ this.persistResumeSnapshot(resumeState);
705
+ this.callbacksRef.current.onResumeStateChange(resumeState, this.interruptManager.getInterrupts());
706
+ this.callbacksRef.current.onInterruptStateChange(this.interruptManager.getState());
707
+ }
708
+ /**
709
+ * Build the durable resume snapshot from the current resume state + pending
710
+ * interrupt descriptors and hand it to the persistor (null clears it).
711
+ */
712
+ persistResumeSnapshot(resumeState) {
713
+ if (!this.persistor) return;
714
+ if (!resumeState) {
715
+ this.persistor.persistResumeSnapshot(null);
716
+ return;
717
+ }
718
+ const descriptors = this.interruptManager.getDescriptors();
719
+ this.persistor.persistResumeSnapshot({
720
+ resumeState,
721
+ ...descriptors.length > 0 ? { pendingInterrupts: [...descriptors] } : {}
722
+ });
723
+ }
724
+ resetSessionGenerating(options) {
725
+ this.activeRunIds.clear();
726
+ if (!options?.preserveClearedStreamTracking) this.clearedStreamTracker.resetActiveRuns();
727
+ this.setSessionGenerating(false);
728
+ }
729
+ setError(error) {
730
+ this.error = error;
731
+ this.callbacksRef.current.onErrorChange(error);
732
+ this.events.errorChanged(error?.message || null);
733
+ }
734
+ buildDevtoolsBridgeOptions(devtools) {
735
+ return {
736
+ hookId: this.uniqueId,
737
+ clientId: this.uniqueId,
738
+ threadId: this.threadId,
739
+ metadata: {
740
+ hookName: devtools?.hookName ?? "useChat",
741
+ outputKind: devtools?.outputKind ?? "chat",
742
+ ...devtools?.framework ? { framework: devtools.framework } : {},
743
+ ...devtools?.name ? { name: devtools.name } : {}
744
+ },
745
+ getSnapshot: () => this.getDevtoolsSnapshot(),
746
+ getTools: () => this.clientToolsRef.current.values(),
747
+ getMessages: () => this.processor.getMessages(),
748
+ setMessages: (messages) => {
749
+ this.processor.setMessages(messages);
750
+ },
751
+ addToolResult: (toolCallId, output, errorText) => {
752
+ this.processor.addToolResult(toolCallId, output, errorText);
753
+ },
754
+ generateId: (prefix) => this.generateUniqueId(prefix)
755
+ };
756
+ }
757
+ getDevtoolsSnapshot() {
758
+ return {
759
+ messages: this.processor.getMessages(),
760
+ status: this.status,
761
+ isLoading: this.isLoading,
762
+ isSubscribed: this.isSubscribed,
763
+ connectionStatus: this.connectionStatus,
764
+ sessionGenerating: this.sessionGenerating,
765
+ activeRunIds: Array.from(this.activeRunIds),
766
+ queue: this.getQueue(),
767
+ ...this.error ? { error: this.error.message } : {}
768
+ };
769
+ }
770
+ findMessageIdForToolCall(toolCallId) {
771
+ const messages = this.processor.getMessages();
772
+ for (const message of messages) if (message.parts.find((part) => part.type === "tool-call" && part.id === toolCallId)) return message.id;
773
+ }
774
+ abortSubscriptionLoop() {
775
+ this.subscriptionAbortController?.abort();
776
+ this.subscriptionAbortController = null;
777
+ }
778
+ resolveProcessing() {
779
+ this.processingResolve?.();
780
+ this.processingResolve = null;
781
+ }
782
+ cancelInFlightStream(options) {
783
+ this.abortController?.abort();
784
+ this.abortController = null;
785
+ if (options?.abortSubscription) this.abortSubscriptionLoop();
786
+ this.resolveProcessing();
787
+ this.setIsLoading(false);
788
+ this.deliverClaim = false;
789
+ if (options?.setReadyStatus) this.setStatus("ready");
790
+ }
791
+ reportStreamError(error) {
792
+ const alreadyReported = this.errorReportedGeneration === this.streamGeneration;
793
+ this.setError(error);
794
+ if (this.isLoading || this.status === "submitted" || this.status === "streaming") this.setStatus("error");
795
+ if (!alreadyReported) {
796
+ this.errorReportedGeneration = this.streamGeneration;
797
+ this.callbacksRef.current.onError(error);
798
+ }
799
+ }
800
+ /**
801
+ * Start the background subscription loop.
802
+ */
803
+ startSubscription() {
804
+ this.subscriptionAbortController = new AbortController();
805
+ const signal = this.subscriptionAbortController.signal;
806
+ this.consumeSubscription(signal).catch((err) => {
807
+ if (err instanceof Error && err.name !== "AbortError") {
808
+ this.setConnectionStatus("error");
809
+ this.resetSessionGenerating();
810
+ this.setIsSubscribed(false);
811
+ this.reportStreamError(err);
812
+ }
813
+ this.resolveProcessing();
814
+ }).finally(() => {
815
+ if (this.subscriptionAbortController?.signal !== signal) return;
816
+ this.subscriptionAbortController = null;
817
+ if (!signal.aborted && this.isSubscribed) {
818
+ this.setIsSubscribed(false);
819
+ if (this.connectionStatus !== "error") this.setConnectionStatus("disconnected");
820
+ }
821
+ });
822
+ }
823
+ /**
824
+ * Consume chunks from the connection subscription.
825
+ */
826
+ async consumeSubscription(signal) {
827
+ const stream = this.connection.subscribe(signal);
828
+ for await (const chunk of stream) {
829
+ if (signal.aborted) break;
830
+ await this.processIncomingChunk(chunk);
831
+ }
832
+ }
833
+ /**
834
+ * Re-attach to an in-flight run after a full page reload, replaying its stream
835
+ * from the server's delivery-durability log via `joinRun` (which returns the
836
+ * whole run so far, then tails live to completion).
837
+ *
838
+ * The log is the single source of truth for the run, so we rebuild the
839
+ * in-flight assistant bubble from it rather than trying to reconcile the
840
+ * server-hydrated partial with the replay: on the first chunk that actually
841
+ * (re)builds a message we drop the hydrated in-flight assistant, and the
842
+ * replay reconstructs one clean bubble. Dropping only on real content (not on
843
+ * `RUN_STARTED`) means a rejoin that connects but delivers nothing can never
844
+ * leave an empty bubble behind.
845
+ *
846
+ * Bounded connect: a durable backend keeps a from-start join open waiting for
847
+ * a producer, so a stale pointer to an unknown/evicted run would otherwise pin
848
+ * the UI in a loading state for the backend's full first-chunk deadline. We
849
+ * give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and
850
+ * clear the dead pointer so it does not retry on the next load.
851
+ *
852
+ * Replay chunks are processed WITHOUT the per-chunk yield the live path uses,
853
+ * so the buffered prefix snaps in and only the genuinely-live tail streams at
854
+ * network speed — a reload looks like the run continued, not like it re-typed.
855
+ */
856
+ resumeInFlightRun(runId) {
857
+ const joinRun = this.connection.joinRun;
858
+ if (!joinRun) return;
859
+ const controller = new AbortController();
860
+ this.abortController = controller;
861
+ this.setCurrentRunId(runId);
862
+ this.lastResume = {
863
+ threadId: this.threadId,
864
+ runId
865
+ };
866
+ this.setIsLoading(true);
867
+ this.setStatus("streaming");
868
+ (async () => {
869
+ let rebuilt = false;
870
+ let attached = false;
871
+ let refused = false;
872
+ const connectTimer = setTimeout(() => {
873
+ if (!attached) controller.abort();
874
+ }, REJOIN_CONNECT_DEADLINE_MS);
875
+ try {
876
+ for await (const chunk of joinRun(runId, controller.signal)) {
877
+ if (controller.signal.aborted) break;
878
+ if (!attached) {
879
+ attached = true;
880
+ clearTimeout(connectTimer);
881
+ }
882
+ if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) {
883
+ rebuilt = true;
884
+ this.dropTrailingInFlightAssistant();
885
+ }
886
+ await this.processIncomingChunk(chunk, { defer: false });
887
+ }
888
+ } catch (error) {
889
+ const isAbort = error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
890
+ if (!attached && !isAbort) refused = true;
891
+ if (attached && !isAbort) this.reportStreamError(error instanceof Error ? error : new Error(String(error)));
892
+ } finally {
893
+ clearTimeout(connectTimer);
894
+ if (!attached && refused && this.tailing && !this.disposed) {
895
+ this.lastResume = null;
896
+ this.persistor?.persistResumeSnapshot(null);
897
+ }
898
+ if (this.abortController === controller) {
899
+ this.abortController = null;
900
+ this.setIsLoading(false);
901
+ if (this.status === "streaming") this.setStatus("ready");
902
+ }
903
+ }
904
+ })();
905
+ }
906
+ /**
907
+ * Drop a hydrated, still-in-flight assistant turn so a resume replay can
908
+ * rebuild it cleanly. Only touches a trailing assistant message (the shape a
909
+ * reload-mid-stream leaves); a thread whose last turn is a user message (run
910
+ * never produced, or already settled) is left untouched.
911
+ */
912
+ dropTrailingInFlightAssistant() {
913
+ const messages = this.processor.getMessages();
914
+ const last = messages[messages.length - 1];
915
+ if (last && last.role === "assistant") this.processor.setMessages(messages.slice(0, -1));
916
+ }
917
+ async processIncomingChunk(chunk, options) {
918
+ if (chunk.type === "RUN_ERROR" && this.isActiveInterruptSubmissionFailure(chunk)) this.interruptSubmissionFailure = { errors: chunk["tanstack:interruptErrors"] ?? [] };
919
+ if (this.connectionStatus === "connecting") this.setConnectionStatus("connected");
920
+ if (this.clearedStreamTracker.shouldIgnoreChunk(chunk)) {
921
+ if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") {
922
+ if (getChunkRunId(chunk)) this.updateRunLifecycle(chunk, { resolveProcessing: false });
923
+ else this.drainIgnoredRunlessChunk(chunk);
924
+ this.retireIgnoredClearedTerminalChunk(chunk);
925
+ this.resolveJoinedRun(chunk);
926
+ }
927
+ return;
928
+ }
929
+ this.callbacksRef.current.onChunk(chunk);
930
+ this.devtoolsBridge.observeChunk(chunk);
931
+ this.processor.processChunk(chunk);
932
+ this.updateRunLifecycle(chunk);
933
+ this.observeInterruptState(chunk);
934
+ if (options?.defer !== false) await new Promise((resolve) => setTimeout(resolve, 0));
935
+ this.resolveJoinedRun(chunk);
936
+ }
937
+ isActiveInterruptSubmissionFailure(chunk) {
938
+ const submission = this.activeInterruptSubmission;
939
+ const errors = chunk["tanstack:interruptErrors"];
940
+ if (!submission || !errors || errors.length === 0) return false;
941
+ const runId = getChunkRunId(chunk);
942
+ if (runId !== void 0 && runId !== this.currentRunId) return false;
943
+ if (typeof chunk.threadId === "string" && chunk.threadId !== submission.threadId) return false;
944
+ return errors.every((error) => error.threadId === submission.threadId && error.interruptedRunId === submission.interruptedRunId && error.generation === submission.generation);
945
+ }
946
+ resolveJoinedRun(chunk) {
947
+ if (chunk.type !== "RUN_FINISHED" && chunk.type !== "RUN_ERROR") return;
948
+ const runId = getChunkRunId(chunk);
949
+ if (runId === void 0) return;
950
+ const resolve = this.joinedRunWaiters.get(runId);
951
+ if (resolve === void 0) return;
952
+ this.joinedRunWaiters.delete(runId);
953
+ resolve();
954
+ }
955
+ /**
956
+ * Ensure subscription loop is running, starting it if needed.
957
+ */
958
+ ensureSubscription() {
959
+ if (!this.isSubscribed) {
960
+ this.subscribe();
961
+ return;
962
+ }
963
+ if (!this.subscriptionAbortController || this.subscriptionAbortController.signal.aborted) this.subscribe({ restart: true });
964
+ }
965
+ /**
966
+ * Create a promise that resolves when onStreamEnd fires.
967
+ * Used by streamResponse to await processing completion.
968
+ */
969
+ waitForProcessing() {
970
+ this.resolveProcessing();
971
+ return new Promise((resolve) => {
972
+ this.processingResolve = resolve;
973
+ });
974
+ }
975
+ /**
976
+ * Send a message and stream the response.
977
+ * Supports both simple string content and multimodal content (images, audio, video, documents).
978
+ *
979
+ * @param content - The message content. Can be:
980
+ * - A simple string for text-only messages
981
+ * - A MultimodalContent object with content array and optional custom ID
982
+ * @param body - Optional body parameters to merge with the client's base body for this request.
983
+ * Uses shallow merge with per-message body taking priority.
984
+ * @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
985
+ * override the configured queue policy for this one send.
986
+ *
987
+ * @example
988
+ * ```ts
989
+ * // Simple text message
990
+ * await client.sendMessage('Hello!')
991
+ *
992
+ * // Text message with custom body params
993
+ * await client.sendMessage('Hello!', { temperature: 0.7 })
994
+ *
995
+ * // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
996
+ * await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
997
+ *
998
+ * // Multimodal message with image
999
+ * await client.sendMessage({
1000
+ * content: [
1001
+ * { type: 'text', content: 'What is in this image?' },
1002
+ * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }
1003
+ * ]
1004
+ * })
1005
+ *
1006
+ * // Multimodal message with custom ID and body params
1007
+ * await client.sendMessage(
1008
+ * {
1009
+ * content: [
1010
+ * { type: 'text', content: 'Describe this audio' },
1011
+ * { type: 'audio', source: { type: 'data', value: 'base64...' } }
1012
+ * ],
1013
+ * id: 'custom-message-id'
1014
+ * },
1015
+ * { model: 'gpt-5.5' }
1016
+ * )
1017
+ * ```
1018
+ */
1019
+ async sendMessage(content, body, sendOptions) {
1020
+ this.mountDevtools();
1021
+ if (typeof content === "string" && !content.trim()) return;
1022
+ if (this.hasBlockingInterrupts()) throw new Error("ChatClient: cannot send normal input while pending interrupts exist. Use resumeInterrupts() instead.");
1023
+ if (this.isSendBusy()) {
1024
+ const { action, id } = this.decideWhenBusy(content, sendOptions);
1025
+ if (action === "drop") return;
1026
+ if (action === "queue") {
1027
+ this.enqueueMessage(content, body, id);
1028
+ return;
1029
+ }
1030
+ this.stopMessageQueueDrain = true;
1031
+ this.sendInFlight = true;
1032
+ this.cancelInFlightStream({ setReadyStatus: true });
1033
+ this.resetSessionGenerating();
1034
+ } else this.sendInFlight = true;
1035
+ try {
1036
+ await this.deliverMessage(content, body);
1037
+ } finally {
1038
+ this.sendInFlight = false;
1039
+ }
1040
+ }
1041
+ /**
1042
+ * True when the client still has user-actionable interrupts (or is mid
1043
+ * resume submission). Staged/submitting items that are already being
1044
+ * continued do not block a later turn once the resume stream has cleared
1045
+ * resume state.
1046
+ */
1047
+ hasBlockingInterrupts() {
1048
+ if (!this.lastResume && !this.activeInterruptSubmission) return false;
1049
+ if (this.activeInterruptSubmission) return true;
1050
+ return this.interruptManager.getInterrupts().some((item) => item.status === "pending" || item.status === "validating" || item.status === "error" || item.status === "staged");
1051
+ }
1052
+ /** True while a stream is active, a send is claiming the client, or the queue is draining. */
1053
+ isSendBusy() {
1054
+ return this.isLoading || this.sendInFlight || this.messageQueueDraining;
1055
+ }
1056
+ resolveBusyReason() {
1057
+ if (this.isLoading) return "streaming";
1058
+ if (this.messageQueueDraining) return "draining";
1059
+ return "sendInFlight";
1060
+ }
1061
+ /**
1062
+ * Append a user message and run the stream. Used by both direct sends and
1063
+ * queue drains — callers are responsible for busy/queue policy.
1064
+ *
1065
+ * Claims delivery synchronously before appending so concurrent callers
1066
+ * cannot both add a user message when only one stream can run.
1067
+ */
1068
+ async deliverMessage(content, body) {
1069
+ if (this.isLoading || this.deliverClaim) return false;
1070
+ this.deliverClaim = true;
1071
+ try {
1072
+ const normalizedContent = this.normalizeMessageInput(content);
1073
+ this.pendingMessageBody = body;
1074
+ const userMessage = this.processor.addUserMessage(normalizedContent.content, normalizedContent.id);
1075
+ this.events.messageSent(userMessage.id, normalizedContent.content);
1076
+ return await this.streamResponse();
1077
+ } finally {
1078
+ this.deliverClaim = false;
1079
+ }
1080
+ }
1081
+ /**
1082
+ * Resolve the effective action for a send that arrives while busy.
1083
+ * The returned `id` is the id that will be stored if the action is `queue`.
1084
+ */
1085
+ decideWhenBusy(content, sendOptions) {
1086
+ const id = this.generateUniqueId("queued");
1087
+ if (sendOptions?.whenBusy) return {
1088
+ action: sendOptions.whenBusy,
1089
+ id
1090
+ };
1091
+ const { strategy, whenBusy } = this.queueConfig;
1092
+ if (strategy) {
1093
+ const { action } = strategy({
1094
+ pending: {
1095
+ id,
1096
+ content,
1097
+ createdAt: Date.now()
1098
+ },
1099
+ busyReason: this.resolveBusyReason(),
1100
+ queued: this.getQueue()
1101
+ });
1102
+ return {
1103
+ action,
1104
+ id
1105
+ };
1106
+ }
1107
+ return {
1108
+ action: whenBusy,
1109
+ id
1110
+ };
1111
+ }
1112
+ enqueueMessage(content, body, id) {
1113
+ const { maxSize, onOverflow } = this.queueConfig;
1114
+ if (maxSize !== void 0 && this.messageQueue.length >= maxSize) {
1115
+ if (onOverflow === "reject" || maxSize === 0) return;
1116
+ this.messageQueue.shift();
1117
+ }
1118
+ this.messageQueue.push({
1119
+ id: id ?? this.generateUniqueId("queued"),
1120
+ content,
1121
+ createdAt: Date.now(),
1122
+ ...body !== void 0 ? { body } : {}
1123
+ });
1124
+ this.emitQueueChange();
1125
+ }
1126
+ /**
1127
+ * Normalize the message input to extract content and optional id.
1128
+ * Trims string content automatically.
1129
+ */
1130
+ normalizeMessageInput(input) {
1131
+ if (typeof input === "string") return { content: input.trim() };
1132
+ return {
1133
+ content: input.content,
1134
+ id: input.id
1135
+ };
1136
+ }
1137
+ /**
1138
+ * Append a message and stream the response
1139
+ */
1140
+ async append(message) {
1141
+ this.mountDevtools();
1142
+ if (this.hasBlockingInterrupts()) throw new Error("ChatClient: cannot append normal input while pending interrupts exist. Use resumeInterrupts() instead.");
1143
+ const normalizedMessage = normalizeToUIMessage(message, generateMessageId);
1144
+ if (normalizedMessage.role === "system") return;
1145
+ const uiMessage = normalizedMessage;
1146
+ this.events.messageAppended(uiMessage);
1147
+ const messages = this.processor.getMessages();
1148
+ this.processor.setMessages([...messages, uiMessage]);
1149
+ this.devtoolsBridge.emitSnapshot();
1150
+ if (this.isLoading) {
1151
+ this.queuePostStreamAction(async () => {
1152
+ await this.streamResponse();
1153
+ });
1154
+ return;
1155
+ }
1156
+ await this.streamResponse();
1157
+ }
1158
+ /**
1159
+ * Stream a response from the LLM.
1160
+ * Returns true if the stream completed successfully, false on abort or error.
1161
+ */
1162
+ async streamResponse() {
1163
+ if (this.isLoading) return false;
1164
+ const generation = ++this.streamGeneration;
1165
+ const resumeThreadId = this.pendingResumeThreadId;
1166
+ const resumeParentRunId = this.pendingResumeParentRunId;
1167
+ const resumeItems = this.pendingResumeItems;
1168
+ this.pendingResumeThreadId = null;
1169
+ this.pendingResumeParentRunId = null;
1170
+ this.pendingResumeItems = null;
1171
+ const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1172
+ this.setCurrentRunId(runId);
1173
+ this.activeResumeThreadId = resumeThreadId ?? this.threadId;
1174
+ this.activeResumeRunId = runId;
1175
+ this.setIsLoading(true);
1176
+ this.deliverClaim = false;
1177
+ this.setStatus("submitted");
1178
+ this.setError(void 0);
1179
+ this.errorReportedGeneration = null;
1180
+ this.abortController = new AbortController();
1181
+ const signal = this.abortController.signal;
1182
+ this.pendingToolExecutions.clear();
1183
+ let streamCompletedSuccessfully = false;
1184
+ let activeDevtoolsRunId = null;
1185
+ let runTerminalEventEmitted = false;
1186
+ try {
1187
+ const messages = this.processor.getMessages();
1188
+ const clientTools = new Map(this.clientToolsRef.current);
1189
+ const runtimeContext = this.context;
1190
+ await this.callbacksRef.current.onResponse();
1191
+ if (signal.aborted) return false;
1192
+ const mergedBody = {
1193
+ ...this.bodyOption,
1194
+ ...this.forwardedPropsOption,
1195
+ ...this.pendingMessageBody
1196
+ };
1197
+ this.pendingMessageBody = void 0;
1198
+ this.currentStreamId = this.generateUniqueId("stream");
1199
+ this.devtoolsBridge.setCurrentStreamId(this.currentStreamId);
1200
+ this.currentMessageId = null;
1201
+ this.activeClientTools = clientTools;
1202
+ this.activeContext = runtimeContext;
1203
+ this.processor.prepareAssistantMessage();
1204
+ this.ensureSubscription();
1205
+ const processingComplete = this.waitForProcessing();
1206
+ const runContext = {
1207
+ threadId: resumeThreadId ?? this.threadId,
1208
+ runId,
1209
+ ...resumeParentRunId !== null ? { parentRunId: resumeParentRunId } : {},
1210
+ clientTools: Array.from(clientTools.values()).map((t) => ({
1211
+ name: t.name,
1212
+ description: t.description,
1213
+ parameters: t.inputSchema ? convertSchemaToJsonSchema(t.inputSchema) : { type: "object" }
1214
+ })),
1215
+ forwardedProps: { ...mergedBody },
1216
+ ...resumeItems ? { resume: resumeItems } : {}
1217
+ };
1218
+ this.devtoolsBridge.beginRun(runContext.runId, runContext.threadId);
1219
+ activeDevtoolsRunId = runContext.runId;
1220
+ this.devtoolsBridge.emitRunLifecycle("run:created", runContext.runId, "created");
1221
+ this.devtoolsBridge.emitRunLifecycle("run:started", runContext.runId, "started");
1222
+ this.devtoolsBridge.emitSnapshot();
1223
+ await this.connection.send(messages, mergedBody, signal, runContext);
1224
+ if (generation !== this.streamGeneration || signal.aborted) return false;
1225
+ await processingComplete;
1226
+ if (generation !== this.streamGeneration) return false;
1227
+ if (this.status === "error") {
1228
+ if (activeDevtoolsRunId) {
1229
+ this.devtoolsBridge.emitRunLifecycle("run:errored", activeDevtoolsRunId, "errored", this.error ? { error: this.error.message } : {});
1230
+ runTerminalEventEmitted = true;
1231
+ }
1232
+ return false;
1233
+ }
1234
+ if (this.pendingToolExecutions.size > 0) await Promise.all(this.pendingToolExecutions.values());
1235
+ this.processor.finalizeStream();
1236
+ streamCompletedSuccessfully = true;
1237
+ } catch (err) {
1238
+ if (err instanceof Error) {
1239
+ if (err.name === "AbortError") {
1240
+ if (activeDevtoolsRunId) {
1241
+ this.devtoolsBridge.emitRunLifecycle("run:cancelled", activeDevtoolsRunId, "cancelled");
1242
+ runTerminalEventEmitted = true;
1243
+ }
1244
+ return false;
1245
+ }
1246
+ if (generation === this.streamGeneration) {
1247
+ this.reportStreamError(err);
1248
+ if (activeDevtoolsRunId) {
1249
+ this.devtoolsBridge.emitRunLifecycle("run:errored", activeDevtoolsRunId, "errored", { error: err.message });
1250
+ runTerminalEventEmitted = true;
1251
+ }
1252
+ }
1253
+ }
1254
+ } finally {
1255
+ if (generation === this.streamGeneration) {
1256
+ this.currentStreamId = null;
1257
+ this.devtoolsBridge.setCurrentStreamId(null);
1258
+ this.currentMessageId = null;
1259
+ this.setCurrentRunId(null);
1260
+ this.activeClientTools = null;
1261
+ this.activeContext = void 0;
1262
+ this.abortController = null;
1263
+ this.setIsLoading(false);
1264
+ this.pendingMessageBody = void 0;
1265
+ if (activeDevtoolsRunId && !runTerminalEventEmitted) {
1266
+ if (streamCompletedSuccessfully) this.devtoolsBridge.emitRunLifecycle("run:completed", activeDevtoolsRunId, "completed");
1267
+ else if (signal.aborted) this.devtoolsBridge.emitRunLifecycle("run:cancelled", activeDevtoolsRunId, "cancelled");
1268
+ }
1269
+ await this.drainPostStreamActions();
1270
+ if (streamCompletedSuccessfully) {
1271
+ const lastPart = this.processor.getMessages().at(-1)?.parts.at(-1);
1272
+ const { finishReason } = this.processor.getState();
1273
+ if (lastPart?.type === "tool-result" && finishReason !== "stop" && this.shouldAutoSend()) try {
1274
+ await this.checkForContinuation();
1275
+ } catch (error) {
1276
+ console.error("Failed to continue flow after tool result:", error);
1277
+ await this.drainQueue();
1278
+ }
1279
+ else {
1280
+ if (this.status !== "ready") this.setStatus("ready");
1281
+ if (!this.messageQueueDraining) await this.drainQueue();
1282
+ }
1283
+ } else this.flushQueue();
1284
+ }
1285
+ }
1286
+ return streamCompletedSuccessfully;
1287
+ }
1288
+ /**
1289
+ * Start the client subscription loop.
1290
+ * This controls the connection lifecycle independently from request lifecycle.
1291
+ */
1292
+ subscribe(options) {
1293
+ const restart = options?.restart === true;
1294
+ if (this.isSubscribed && !restart) return;
1295
+ if (this.isSubscribed && restart) this.abortSubscriptionLoop();
1296
+ this.setIsSubscribed(true);
1297
+ this.setConnectionStatus("connecting");
1298
+ this.startSubscription();
1299
+ }
1300
+ /**
1301
+ * Unsubscribe and fully tear down live behavior.
1302
+ * This aborts an in-flight request and the subscription loop.
1303
+ */
1304
+ unsubscribe() {
1305
+ this.cancelInFlightStream({
1306
+ setReadyStatus: true,
1307
+ abortSubscription: true
1308
+ });
1309
+ this.discardPendingSends();
1310
+ this.resetSessionGenerating();
1311
+ this.setIsSubscribed(false);
1312
+ this.setConnectionStatus("disconnected");
1313
+ }
1314
+ /**
1315
+ * Reload the last assistant message
1316
+ */
1317
+ async reload() {
1318
+ const messages = this.processor.getMessages();
1319
+ if (messages.length === 0) return;
1320
+ const lastUserMessageIndex = messages.findLastIndex((m) => m.role === "user");
1321
+ if (lastUserMessageIndex === -1) return;
1322
+ if (this.isLoading) this.cancelInFlightStream();
1323
+ this.discardPendingSends();
1324
+ this.events.reloaded(lastUserMessageIndex);
1325
+ this.processor.removeMessagesAfter(lastUserMessageIndex);
1326
+ this.devtoolsBridge.emitSnapshot();
1327
+ await this.streamResponse();
1328
+ }
1329
+ /**
1330
+ * Stop the current stream
1331
+ */
1332
+ stop() {
1333
+ const hadLocalStream = this.abortController !== null;
1334
+ this.cancelInFlightStream({ setReadyStatus: true });
1335
+ this.discardPendingSends();
1336
+ if (hadLocalStream) this.resetSessionGenerating();
1337
+ this.events.stopped();
1338
+ }
1339
+ /**
1340
+ * Clear all messages
1341
+ */
1342
+ clear() {
1343
+ const hadLocalStream = this.abortController !== null;
1344
+ this.clearedStreamTracker.snapshotClear({
1345
+ messages: this.processor.getMessages(),
1346
+ activeRunIds: this.activeRunIds,
1347
+ currentRunId: this.currentRunId
1348
+ });
1349
+ if (this.isLoading || hadLocalStream) {
1350
+ this.cancelInFlightStream({ setReadyStatus: true });
1351
+ this.resetSessionGenerating({ preserveClearedStreamTracking: true });
1352
+ } else if (this.activeRunIds.size > 0) this.resetSessionGenerating({ preserveClearedStreamTracking: true });
1353
+ this.persistor?.beginClear();
1354
+ this.processor.clearMessages();
1355
+ this.discardPendingSends();
1356
+ this.persistor?.remove();
1357
+ this.lastResume = null;
1358
+ this.interruptManager.reset();
1359
+ this.pendingResumeThreadId = null;
1360
+ this.pendingResumeParentRunId = null;
1361
+ this.pendingResumeItems = null;
1362
+ this.setError(void 0);
1363
+ this.events.messagesCleared();
1364
+ }
1365
+ /**
1366
+ * Add the result of a client-side tool execution
1367
+ */
1368
+ async addToolResult(result) {
1369
+ const clientTool = this.clientToolsRef.current.get(result.tool);
1370
+ await this.addToolResultForClientTool(result, clientTool);
1371
+ }
1372
+ async addToolResultForClientTool(result, clientTool, context) {
1373
+ if (clientTool && result.state !== "output-error") try {
1374
+ result = {
1375
+ ...result,
1376
+ output: this.validateClientToolOutput(clientTool, result.output)
1377
+ };
1378
+ } catch (error) {
1379
+ result = {
1380
+ ...result,
1381
+ output: null,
1382
+ state: "output-error",
1383
+ errorText: error.message
1384
+ };
1385
+ }
1386
+ this.events.toolResultAdded(result.toolCallId, result.tool, result.output, result.state || "output-available", context);
1387
+ this.processor.addToolResult(result.toolCallId, result.output, result.state === "output-error" ? result.errorText || "Tool execution failed" : void 0);
1388
+ this.devtoolsBridge.emitSnapshot();
1389
+ if (this.interruptManager.resolveClientToolOutput(result.toolCallId, result.state === "output-error" ? { error: result.errorText || "Tool execution failed" } : result.output)) return;
1390
+ if (this.isLoading) {
1391
+ this.queuePostStreamAction(() => this.checkForContinuation());
1392
+ return;
1393
+ }
1394
+ await this.checkForContinuation();
1395
+ }
1396
+ validateClientToolOutput(clientTool, output) {
1397
+ if (clientTool.outputSchema && isStandardSchema(clientTool.outputSchema)) return parseWithStandardSchema(clientTool.outputSchema, output);
1398
+ return output;
1399
+ }
1400
+ /**
1401
+ * Respond to a tool approval request
1402
+ */
1403
+ async addToolApprovalResponse(response) {
1404
+ this.processor.addToolApprovalResponse(response.id, response.approved);
1405
+ this.devtoolsBridge.emitSnapshot();
1406
+ if (this.interruptManager.resolveToolApprovalDecision(response.id, response.approved)) return;
1407
+ const messages = this.processor.getMessages();
1408
+ let foundToolCallId;
1409
+ for (const msg of messages) {
1410
+ const toolCallPart = msg.parts.find((p) => p.type === "tool-call" && p.approval?.id === response.id);
1411
+ if (toolCallPart) {
1412
+ foundToolCallId = toolCallPart.id;
1413
+ break;
1414
+ }
1415
+ }
1416
+ if (foundToolCallId) this.events.toolApprovalResponded(response.id, foundToolCallId, response.approved);
1417
+ this.processor.addToolApprovalResponse(response.id, response.approved);
1418
+ this.devtoolsBridge.emitSnapshot();
1419
+ if (this.isLoading) {
1420
+ this.queuePostStreamAction(() => this.checkForContinuation());
1421
+ return;
1422
+ }
1423
+ await this.checkForContinuation();
1424
+ }
1425
+ /**
1426
+ * Queue an action to be executed after the current stream ends
1427
+ */
1428
+ queuePostStreamAction(action) {
1429
+ this.postStreamActions.push(action);
1430
+ }
1431
+ /**
1432
+ * Drain and execute all queued post-stream actions
1433
+ */
1434
+ async drainPostStreamActions() {
1435
+ if (this.draining) return;
1436
+ this.draining = true;
1437
+ try {
1438
+ let action;
1439
+ while ((action = this.postStreamActions.shift()) !== void 0) await action();
1440
+ } finally {
1441
+ this.draining = false;
1442
+ }
1443
+ }
1444
+ /**
1445
+ * Check if we should continue the flow and do so if needed
1446
+ */
1447
+ async checkForContinuation() {
1448
+ if (this.continuationPending || this.isLoading) {
1449
+ this.continuationSkipped = true;
1450
+ return;
1451
+ }
1452
+ if (this.shouldAutoSend()) {
1453
+ this.continuationPending = true;
1454
+ this.continuationSkipped = false;
1455
+ let succeeded = false;
1456
+ try {
1457
+ succeeded = await this.streamResponse();
1458
+ } finally {
1459
+ this.continuationPending = false;
1460
+ }
1461
+ if (this.continuationSkipped && succeeded) {
1462
+ this.continuationSkipped = false;
1463
+ await this.checkForContinuation();
1464
+ }
1465
+ }
1466
+ }
1467
+ /**
1468
+ * Check if all tool calls are complete and we should auto-send.
1469
+ * Requires that there is at least one tool call in the last assistant message;
1470
+ * a text-only response has nothing to auto-send.
1471
+ */
1472
+ shouldAutoSend() {
1473
+ const lastAssistant = this.processor.getMessages().findLast((m) => m.role === "assistant");
1474
+ if (!lastAssistant) return false;
1475
+ if (!lastAssistant.parts.some((p) => p.type === "tool-call")) return false;
1476
+ return this.processor.areAllToolsComplete();
1477
+ }
1478
+ /**
1479
+ * Get current messages
1480
+ */
1481
+ getMessages() {
1482
+ return this.processor.getMessages();
1483
+ }
1484
+ /**
1485
+ * True when an interrupt (or another direct send) claimed the client during
1486
+ * a drain. Read via a method so cross-await mutations are not constant-folded
1487
+ * by control-flow analysis.
1488
+ */
1489
+ shouldAbortMessageQueueDrain() {
1490
+ return this.isLoading || this.stopMessageQueueDrain;
1491
+ }
1492
+ /**
1493
+ * Deliver queued messages after a successful settle.
1494
+ * - `batch`: merge everything currently queued into one send, looping so
1495
+ * messages enqueued during that batch stream are not stranded.
1496
+ * - `fifo`: walk the queue in a loop, one stream at a time, until empty
1497
+ * (or until another send claims the client via interrupt).
1498
+ *
1499
+ * Uses `deliverMessage` directly so drains do not re-enter `sendMessage`'s
1500
+ * busy/queue policy (which would re-queue items and strand the rest).
1501
+ */
1502
+ async drainQueue() {
1503
+ if (this.messageQueueDraining || this.isLoading || this.messageQueue.length === 0) return;
1504
+ this.messageQueueDraining = true;
1505
+ this.stopMessageQueueDrain = false;
1506
+ try {
1507
+ if (this.queueConfig.drain === "batch") {
1508
+ while (this.messageQueue.length > 0) {
1509
+ if (this.shouldAbortMessageQueueDrain()) return;
1510
+ const items = this.messageQueue.splice(0);
1511
+ this.emitQueueChange();
1512
+ const merged = mergeQueuedMessages(items);
1513
+ if (!await this.deliverMessage(merged.content, merged.body) || this.shouldAbortMessageQueueDrain()) return;
1514
+ }
1515
+ return;
1516
+ }
1517
+ while (this.messageQueue.length > 0) {
1518
+ if (this.shouldAbortMessageQueueDrain()) return;
1519
+ const next = this.messageQueue.shift();
1520
+ if (next === void 0) return;
1521
+ this.emitQueueChange();
1522
+ if (!await this.deliverMessage(next.content, next.body) || this.shouldAbortMessageQueueDrain()) return;
1523
+ }
1524
+ } finally {
1525
+ this.messageQueueDraining = false;
1526
+ this.stopMessageQueueDrain = false;
1527
+ }
1528
+ }
1529
+ /**
1530
+ * Drop any in-flight send claim and discard pending queued messages
1531
+ * (stop / error / clear / unsubscribe / reload).
1532
+ */
1533
+ discardPendingSends() {
1534
+ this.sendInFlight = false;
1535
+ this.flushQueue();
1536
+ }
1537
+ /**
1538
+ * Get the current send queue (messages held while a stream was in flight).
1539
+ */
1540
+ getQueue() {
1541
+ return this.messageQueue.map(({ id, content, createdAt }) => ({
1542
+ id,
1543
+ content,
1544
+ createdAt
1545
+ }));
1546
+ }
1547
+ emitQueueChange() {
1548
+ this.callbacksRef.current.onQueueChange(this.getQueue());
1549
+ this.devtoolsBridge.emitSnapshot();
1550
+ }
1551
+ /**
1552
+ * Remove a queued message by id before it drains.
1553
+ */
1554
+ cancelQueued(id) {
1555
+ const index = this.messageQueue.findIndex((m) => m.id === id);
1556
+ if (index === -1) return;
1557
+ this.messageQueue.splice(index, 1);
1558
+ this.emitQueueChange();
1559
+ }
1560
+ /**
1561
+ * Discard all pending queued messages (stop / error / clear / unsubscribe /
1562
+ * reload). Does not send them. Emits `onQueueChange([])` when anything was
1563
+ * removed.
1564
+ */
1565
+ flushQueue() {
1566
+ if (this.messageQueue.length === 0) return;
1567
+ this.messageQueue = [];
1568
+ this.emitQueueChange();
1569
+ }
1570
+ /**
1571
+ * Get loading state
1572
+ */
1573
+ getIsLoading() {
1574
+ return this.isLoading;
1575
+ }
1576
+ /**
1577
+ * Get current status
1578
+ */
1579
+ getStatus() {
1580
+ return this.status;
1581
+ }
1582
+ /**
1583
+ * Get whether the subscription loop is active
1584
+ */
1585
+ getIsSubscribed() {
1586
+ return this.isSubscribed;
1587
+ }
1588
+ /**
1589
+ * Get current connection lifecycle status
1590
+ */
1591
+ getConnectionStatus() {
1592
+ return this.connectionStatus;
1593
+ }
1594
+ /**
1595
+ * Whether the shared session is actively generating.
1596
+ * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).
1597
+ * Unlike `isLoading` (request-local), this reflects shared generation
1598
+ * activity visible to all subscribers (e.g. across tabs/devices).
1599
+ */
1600
+ getSessionGenerating() {
1601
+ return this.sessionGenerating;
1602
+ }
1603
+ /**
1604
+ * Get current error
1605
+ */
1606
+ getError() {
1607
+ return this.error;
1608
+ }
1609
+ /**
1610
+ * Manually set messages
1611
+ */
1612
+ setMessagesManually(messages) {
1613
+ this.processor.setMessages(messages);
1614
+ this.devtoolsBridge.emitSnapshot();
1615
+ }
1616
+ updateOptions(options) {
1617
+ if (options.connection !== void 0 || options.fetcher !== void 0) {
1618
+ const wasSubscribed = this.isSubscribed;
1619
+ if (this.isLoading) this.cancelInFlightStream({
1620
+ setReadyStatus: true,
1621
+ abortSubscription: true
1622
+ });
1623
+ else if (wasSubscribed) this.abortSubscriptionLoop();
1624
+ this.resetSessionGenerating();
1625
+ this.setIsSubscribed(false);
1626
+ this.setConnectionStatus("disconnected");
1627
+ this.connection = normalizeConnectionAdapter(resolveTransport({
1628
+ connection: options.connection,
1629
+ fetcher: options.fetcher
1630
+ }));
1631
+ if (wasSubscribed) this.subscribe();
1632
+ }
1633
+ if (options.body !== void 0) this.bodyOption = options.body;
1634
+ if (options.forwardedProps !== void 0) this.forwardedPropsOption = options.forwardedProps;
1635
+ if ("context" in options) this.context = options.context;
1636
+ if (options.tools !== void 0) {
1637
+ this.interruptManager.updateTools(options.tools);
1638
+ this.clientToolsRef.current = /* @__PURE__ */ new Map();
1639
+ for (const tool of options.tools) this.clientToolsRef.current.set(tool.name, tool);
1640
+ this.devtoolsBridge.notifyToolsChanged();
1641
+ }
1642
+ if (options.queue !== void 0) this.queueConfig = normalizeQueueOption(options.queue);
1643
+ if (options.onResponse !== void 0) this.callbacksRef.current.onResponse = options.onResponse;
1644
+ if (options.onChunk !== void 0) this.callbacksRef.current.onChunk = options.onChunk;
1645
+ if (options.onFinish !== void 0) this.callbacksRef.current.onFinish = options.onFinish;
1646
+ if (options.onError !== void 0) this.callbacksRef.current.onError = options.onError;
1647
+ if (options.onSubscriptionChange !== void 0) this.callbacksRef.current.onSubscriptionChange = options.onSubscriptionChange;
1648
+ if (options.onConnectionStatusChange !== void 0) this.callbacksRef.current.onConnectionStatusChange = options.onConnectionStatusChange;
1649
+ if (options.onSessionGeneratingChange !== void 0) this.callbacksRef.current.onSessionGeneratingChange = options.onSessionGeneratingChange;
1650
+ if (options.onQueueChange !== void 0) this.callbacksRef.current.onQueueChange = options.onQueueChange;
1651
+ if (options.onResumeStateChange !== void 0) this.callbacksRef.current.onResumeStateChange = options.onResumeStateChange;
1652
+ if (options.onRunIdChange !== void 0) this.callbacksRef.current.onRunIdChange = options.onRunIdChange;
1653
+ if (options.onInterruptStateChange !== void 0) this.callbacksRef.current.onInterruptStateChange = options.onInterruptStateChange;
1654
+ if (options.onCustomEvent !== void 0) this.callbacksRef.current.onCustomEvent = options.onCustomEvent;
1655
+ }
1656
+ dispose() {
1657
+ this.disposed = true;
1658
+ this.unsubscribe();
1659
+ this.devtoolsBridge.dispose();
1660
+ this.devtoolsMounted = false;
1661
+ }
1395
1662
  };
1396
- //# sourceMappingURL=chat-client.js.map
1663
+ //#endregion
1664
+ export { ChatClient, normalizeQueueOption };
1665
+
1666
+ //# sourceMappingURL=chat-client.js.map