@tanstack/ai-client 0.22.1 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -1
- package/dist/esm/audio-recorder.js +190 -213
- package/dist/esm/audio-recorder.js.map +1 -1
- package/dist/esm/chat-client.d.ts +172 -3
- package/dist/esm/chat-client.js +1656 -1386
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/cleared-stream-tracker.d.ts +23 -0
- package/dist/esm/cleared-stream-tracker.js +97 -0
- package/dist/esm/cleared-stream-tracker.js.map +1 -0
- package/dist/esm/client-persistor.d.ts +25 -12
- package/dist/esm/client-persistor.js +260 -235
- package/dist/esm/client-persistor.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +231 -10
- package/dist/esm/connection-adapters.js +989 -574
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools-noop.d.ts +1 -0
- package/dist/esm/devtools-noop.js +79 -139
- package/dist/esm/devtools-noop.js.map +1 -1
- package/dist/esm/devtools.d.ts +31 -1
- package/dist/esm/devtools.js +977 -1127
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/events.js +224 -226
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/generation-client.d.ts +145 -2
- package/dist/esm/generation-client.js +659 -321
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-reconstruct.d.ts +21 -0
- package/dist/esm/generation-reconstruct.js +85 -0
- package/dist/esm/generation-reconstruct.js.map +1 -0
- package/dist/esm/generation-types.d.ts +289 -3
- package/dist/esm/generation-types.js +356 -13
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +9 -4
- package/dist/esm/index.js +7 -39
- package/dist/esm/interrupt-manager.d.ts +77 -0
- package/dist/esm/interrupt-manager.js +787 -0
- package/dist/esm/interrupt-manager.js.map +1 -0
- package/dist/esm/mcp-app-bridge.js +56 -64
- package/dist/esm/mcp-app-bridge.js.map +1 -1
- package/dist/esm/realtime-client.js +366 -440
- package/dist/esm/realtime-client.js.map +1 -1
- package/dist/esm/response-stream.js +19 -26
- package/dist/esm/response-stream.js.map +1 -1
- package/dist/esm/sse-parser.js +44 -47
- package/dist/esm/sse-parser.js.map +1 -1
- package/dist/esm/sse-utils.js +8 -9
- package/dist/esm/sse-utils.js.map +1 -1
- package/dist/esm/storage-adapters.d.ts +62 -0
- package/dist/esm/storage-adapters.js +174 -0
- package/dist/esm/storage-adapters.js.map +1 -0
- package/dist/esm/types.d.ts +212 -10
- package/dist/esm/types.js +38 -7
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +113 -2
- package/dist/esm/video-generation-client.js +665 -379
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +7 -7
- package/src/chat-client.ts +1079 -61
- package/src/cleared-stream-tracker.ts +151 -0
- package/src/client-persistor.ts +102 -33
- package/src/connection-adapters.ts +1185 -142
- package/src/devtools-noop.ts +4 -3
- package/src/devtools.ts +121 -3
- package/src/generation-client.ts +563 -13
- package/src/generation-reconstruct.ts +121 -0
- package/src/generation-types.ts +727 -3
- package/src/index.ts +56 -1
- package/src/interrupt-manager.ts +1440 -0
- package/src/storage-adapters.ts +242 -0
- package/src/types.ts +301 -9
- package/src/video-generation-client.ts +479 -13
- package/dist/esm/index.js.map +0 -1
package/dist/esm/chat-client.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
//#
|
|
1663
|
+
//#endregion
|
|
1664
|
+
export { ChatClient, normalizeQueueOption };
|
|
1665
|
+
|
|
1666
|
+
//# sourceMappingURL=chat-client.js.map
|