@assistant-ui/ai-sdk 0.0.6 → 0.0.8
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 +1 -1
- package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -2
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +2 -5
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
- package/dist/adapters/vercelAttachmentAdapter.js +3 -3
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -1
- package/dist/aiSDKExtras.d.ts +6 -9
- package/dist/aiSDKExtras.d.ts.map +1 -1
- package/dist/converters/convertMessage.d.ts +34 -25
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +78 -8
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/modelContentEnvelope.d.ts +10 -12
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
- package/dist/converters/toCreateMessage.d.ts +3 -6
- package/dist/converters/toCreateMessage.d.ts.map +1 -1
- package/dist/converters/toolOutputConversion.d.ts +22 -25
- package/dist/converters/toolOutputConversion.d.ts.map +1 -1
- package/dist/hooks.d.ts +4 -7
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +17 -14
- package/dist/index.d.ts.map +1 -0
- package/dist/index.native.d.ts +15 -12
- package/dist/index.native.d.ts.map +1 -0
- package/dist/model-context/injectInteractableContext.d.ts +3 -6
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +2 -5
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.d.ts +4 -7
- package/dist/runtime/AISDKChat.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKThreads.d.ts +29 -32
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +26 -33
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/sdkIdentity.d.ts +2 -5
- package/dist/runtime/sdkIdentity.d.ts.map +1 -1
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +72 -61
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +170 -56
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts +8 -11
- package/dist/runtime/useChatRuntime.d.ts.map +1 -1
- package/dist/runtime/useChatRuntime.js +14 -10
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useChatThread.d.ts +100 -81
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +33 -13
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts +8 -9
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +236 -58
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.d.ts +1 -4
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
- package/dist/runtime/useResourceCleanup.js +38 -14
- package/dist/runtime/useResourceCleanup.js.map +1 -1
- package/dist/runtime/useStreamingTiming.d.ts +5 -8
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
- package/dist/runtime/useStreamingTiming.js +1 -1
- package/dist/tools/frontendTools.d.ts +24 -27
- package/dist/tools/frontendTools.d.ts.map +1 -1
- package/dist/tools/generativeTools.d.ts +29 -32
- package/dist/tools/generativeTools.d.ts.map +1 -1
- package/dist/tools/mcp-stdio.node.d.ts +2 -2
- package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +2 -5
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.d.ts +17 -20
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
- package/dist/transport/resumable.d.ts +29 -32
- package/dist/transport/resumable.d.ts.map +1 -1
- package/dist/usage.d.ts +12 -15
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js.map +1 -1
- package/dist/utils/getVercelAIMessages.d.ts +3 -6
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
- package/dist/utils/sliceMessagesUntil.d.ts +2 -5
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/package.json +16 -14
- package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
- package/src/adapters/vercelAttachmentAdapter.ts +2 -2
- package/src/converters/convertMessage.test.ts +616 -3
- package/src/converters/convertMessage.ts +187 -12
- package/src/index.native.ts +1 -1
- package/src/index.ts +1 -1
- package/src/runtime/AISDKChat.test.ts +4 -5
- package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
- package/src/runtime/AISDKThreads.test.ts +88 -51
- package/src/runtime/AISDKThreads.ts +4 -13
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +338 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +476 -4
- package/src/runtime/useAISDKRuntime.test.ts +282 -8
- package/src/runtime/useAISDKRuntime.ts +274 -46
- package/src/runtime/useAISDKRuntime.voice.test.tsx +319 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +56 -0
- package/src/runtime/useChatRuntime.test.ts +77 -5
- package/src/runtime/useChatRuntime.ts +6 -2
- package/src/runtime/useChatThread.transport.test.tsx +149 -0
- package/src/runtime/useChatThread.ts +77 -10
- package/src/runtime/useExternalHistory.test.ts +989 -3
- package/src/runtime/useExternalHistory.ts +390 -24
- package/src/runtime/useResourceCleanup.ts +41 -15
- package/src/tools/generativeTools.test.ts +7 -1
- package/src/usage.ts +1 -1
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
isMcpAppUri,
|
|
14
14
|
type ReasoningMessagePart,
|
|
15
|
+
type ToolApprovalOption,
|
|
15
16
|
type ToolCallMessagePart,
|
|
16
17
|
type TextMessagePart,
|
|
17
18
|
type DataMessagePart,
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
type ThreadMessageLike,
|
|
23
24
|
type McpAppMetadata,
|
|
24
25
|
type MessagePartStreamStatus,
|
|
26
|
+
type RespondToToolApprovalOptions,
|
|
27
|
+
type Unstable_ToolInteractionLog,
|
|
25
28
|
} from "@assistant-ui/core";
|
|
26
29
|
import { stableStringifyToolArgs } from "@assistant-ui/core/internal";
|
|
27
30
|
import {
|
|
@@ -47,7 +50,7 @@ const THREAD_METADATA_KEYS = new Set([
|
|
|
47
50
|
const toThreadMetadata = (metadata: unknown): MessageMetadata => {
|
|
48
51
|
if (!metadata || typeof metadata !== "object") return undefined;
|
|
49
52
|
const result: Record<string, unknown> = {};
|
|
50
|
-
const extra
|
|
53
|
+
const extra = Object.create(null) as Record<string, unknown>;
|
|
51
54
|
for (const [key, value] of Object.entries(metadata)) {
|
|
52
55
|
(THREAD_METADATA_KEYS.has(key) ? result : extra)[key] = value;
|
|
53
56
|
}
|
|
@@ -59,8 +62,20 @@ const toThreadMetadata = (metadata: unknown): MessageMetadata => {
|
|
|
59
62
|
export type AISDKMessageConverterMetadata =
|
|
60
63
|
useExternalMessageConverter.Metadata & {
|
|
61
64
|
toolArgsKeyOrderCache?: Map<string, Map<string, string[]>>;
|
|
65
|
+
/**
|
|
66
|
+
* Frozen `argsText` keyed weakly by a settled tool call's input object, then
|
|
67
|
+
* by call, since the text carries the call's streamed key order. A known
|
|
68
|
+
* call/input pair skips serialization; the entries become collectible once
|
|
69
|
+
* the input is unreachable. A fresh input object re-serializes in its own
|
|
70
|
+
* deterministic key order.
|
|
71
|
+
*/
|
|
72
|
+
toolArgsTextCache?: WeakMap<ReadonlyJSONObject, Map<string, string>>;
|
|
62
73
|
toolLastInputCache?: Map<string, ReadonlyJSONObject>;
|
|
63
74
|
mcpAppMetadataCache?: Map<string, McpAppMetadata>;
|
|
75
|
+
toolArtifacts?: ReadonlyMap<string, unknown>;
|
|
76
|
+
toolInteractions?: ReadonlyMap<string, Unstable_ToolInteractionLog>;
|
|
77
|
+
supportsRichToolApprovalResponses?: boolean;
|
|
78
|
+
toolApprovalResponses?: ReadonlyMap<string, RespondToToolApprovalOptions>;
|
|
64
79
|
/** Id of the currently-streaming message, flagged optimistic (#4037). */
|
|
65
80
|
optimisticMessageId?: string | undefined;
|
|
66
81
|
};
|
|
@@ -153,19 +168,115 @@ function extractMcpAppMetadata(
|
|
|
153
168
|
return out;
|
|
154
169
|
}
|
|
155
170
|
|
|
171
|
+
const normalizeToolApprovalOptions = (
|
|
172
|
+
options: unknown,
|
|
173
|
+
): readonly ToolApprovalOption[] | undefined => {
|
|
174
|
+
if (!Array.isArray(options)) return undefined;
|
|
175
|
+
|
|
176
|
+
return options.flatMap<ToolApprovalOption>((value) => {
|
|
177
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
178
|
+
const option = value as Record<string, unknown>;
|
|
179
|
+
if (typeof option.id !== "string" || typeof option.kind !== "string")
|
|
180
|
+
return [];
|
|
181
|
+
|
|
182
|
+
const confirm = option.confirm;
|
|
183
|
+
const confirmDetails =
|
|
184
|
+
confirm && typeof confirm === "object" && !Array.isArray(confirm)
|
|
185
|
+
? (confirm as Record<string, unknown>)
|
|
186
|
+
: undefined;
|
|
187
|
+
|
|
188
|
+
return [
|
|
189
|
+
{
|
|
190
|
+
id: option.id,
|
|
191
|
+
kind: option.kind,
|
|
192
|
+
...(typeof option.label === "string" && { label: option.label }),
|
|
193
|
+
...(typeof option.description === "string" && {
|
|
194
|
+
description: option.description,
|
|
195
|
+
}),
|
|
196
|
+
...(Array.isArray(option.grants) && {
|
|
197
|
+
grants: option.grants.filter(
|
|
198
|
+
(grant): grant is string => typeof grant === "string",
|
|
199
|
+
),
|
|
200
|
+
}),
|
|
201
|
+
...(typeof confirm === "boolean"
|
|
202
|
+
? { confirm }
|
|
203
|
+
: confirmDetails
|
|
204
|
+
? {
|
|
205
|
+
confirm: {
|
|
206
|
+
...(typeof confirmDetails.title === "string" && {
|
|
207
|
+
title: confirmDetails.title,
|
|
208
|
+
}),
|
|
209
|
+
...(typeof confirmDetails.description === "string" && {
|
|
210
|
+
description: confirmDetails.description,
|
|
211
|
+
}),
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
: {}),
|
|
215
|
+
},
|
|
216
|
+
];
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const APPROVAL_DESCRIPTOR_FIELDS = [
|
|
221
|
+
"prompt",
|
|
222
|
+
"display",
|
|
223
|
+
"allowFreeform",
|
|
224
|
+
"dismissible",
|
|
225
|
+
"options",
|
|
226
|
+
"optionId",
|
|
227
|
+
"text",
|
|
228
|
+
"resolution",
|
|
229
|
+
] as const;
|
|
230
|
+
|
|
231
|
+
// The AI SDK's approval object declares none of the core request and answer
|
|
232
|
+
// fields and `validateUIMessages` strips unknown ones, so a host streams or
|
|
233
|
+
// persists them inside the opaque `approvalDescriptor`. Only those fields are
|
|
234
|
+
// read from it: a descriptor cannot approve its own request.
|
|
235
|
+
const readApprovalDescriptor = (
|
|
236
|
+
descriptor: unknown,
|
|
237
|
+
): Record<string, unknown> => {
|
|
238
|
+
if (
|
|
239
|
+
!descriptor ||
|
|
240
|
+
typeof descriptor !== "object" ||
|
|
241
|
+
Array.isArray(descriptor)
|
|
242
|
+
)
|
|
243
|
+
return {};
|
|
244
|
+
const fields: Record<string, unknown> = {};
|
|
245
|
+
for (const key of APPROVAL_DESCRIPTOR_FIELDS) {
|
|
246
|
+
if (Object.hasOwn(descriptor, key))
|
|
247
|
+
fields[key] = (descriptor as Record<string, unknown>)[key];
|
|
248
|
+
}
|
|
249
|
+
return fields;
|
|
250
|
+
};
|
|
251
|
+
|
|
156
252
|
function getToolApprovalAndInterrupt(
|
|
157
253
|
part: {
|
|
158
254
|
approval?: Record<string, unknown> | undefined;
|
|
159
255
|
},
|
|
160
256
|
toolStatus: { type: string; payload?: unknown } | undefined,
|
|
257
|
+
supportsRichToolApprovalResponses: boolean,
|
|
258
|
+
toolApprovalResponses:
|
|
259
|
+
| ReadonlyMap<string, RespondToToolApprovalOptions>
|
|
260
|
+
| undefined,
|
|
161
261
|
): {
|
|
162
262
|
approval?: NonNullable<ToolCallMessagePart["approval"]>;
|
|
163
263
|
interrupt?: NonNullable<ToolCallMessagePart["interrupt"]>;
|
|
164
264
|
} {
|
|
165
265
|
if (part.approval) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
266
|
+
const approval = {
|
|
267
|
+
...readApprovalDescriptor(part.approval.descriptor),
|
|
268
|
+
...part.approval,
|
|
269
|
+
};
|
|
270
|
+
const response =
|
|
271
|
+
typeof approval.id === "string" &&
|
|
272
|
+
approval.approved === undefined &&
|
|
273
|
+
approval.resolution !== "cancelled" &&
|
|
274
|
+
approval.resolution !== "expired"
|
|
275
|
+
? toolApprovalResponses?.get(approval.id)
|
|
276
|
+
: undefined;
|
|
277
|
+
// The built-in AI SDK channel sends only id, approved and reason back to
|
|
278
|
+
// the server, so a request shape promising any other answer would render
|
|
279
|
+
// controls whose response cannot travel.
|
|
169
280
|
const {
|
|
170
281
|
id,
|
|
171
282
|
prompt,
|
|
@@ -175,11 +286,23 @@ function getToolApprovalAndInterrupt(
|
|
|
175
286
|
resolution,
|
|
176
287
|
display,
|
|
177
288
|
allowFreeform,
|
|
289
|
+
dismissible,
|
|
178
290
|
options,
|
|
179
291
|
optionId,
|
|
180
292
|
text,
|
|
181
293
|
...additionalApprovalFields
|
|
182
|
-
} =
|
|
294
|
+
} = response
|
|
295
|
+
? {
|
|
296
|
+
...approval,
|
|
297
|
+
approved: response.approved,
|
|
298
|
+
...(response.reason != null && { reason: response.reason }),
|
|
299
|
+
...(response.optionId != null && { optionId: response.optionId }),
|
|
300
|
+
...(response.text != null && { text: response.text }),
|
|
301
|
+
}
|
|
302
|
+
: approval;
|
|
303
|
+
const normalizedOptions = supportsRichToolApprovalResponses
|
|
304
|
+
? normalizeToolApprovalOptions(options)
|
|
305
|
+
: undefined;
|
|
183
306
|
const requestReason = additionalApprovalFields.requestReason;
|
|
184
307
|
if (typeof id === "string")
|
|
185
308
|
return {
|
|
@@ -194,6 +317,16 @@ function getToolApprovalAndInterrupt(
|
|
|
194
317
|
...(typeof approved === "boolean" && { approved }),
|
|
195
318
|
...(typeof reason === "string" && { reason }),
|
|
196
319
|
...(isAutomatic === true && { isAutomatic: true }),
|
|
320
|
+
...(supportsRichToolApprovalResponses && {
|
|
321
|
+
...((display === "decision" ||
|
|
322
|
+
display === "select" ||
|
|
323
|
+
display === "text") && { display }),
|
|
324
|
+
...(typeof allowFreeform === "boolean" && { allowFreeform }),
|
|
325
|
+
...(typeof dismissible === "boolean" && { dismissible }),
|
|
326
|
+
...(normalizedOptions && { options: normalizedOptions }),
|
|
327
|
+
...(typeof optionId === "string" && { optionId }),
|
|
328
|
+
...(typeof text === "string" && { text }),
|
|
329
|
+
}),
|
|
197
330
|
...((resolution === "cancelled" || resolution === "expired") && {
|
|
198
331
|
resolution,
|
|
199
332
|
}),
|
|
@@ -270,7 +403,14 @@ function convertParts(
|
|
|
270
403
|
const toolCallId = part.toolCallId;
|
|
271
404
|
const argsKeyOrderCacheKey = `${message.id}:${toolCallId}`;
|
|
272
405
|
|
|
273
|
-
|
|
406
|
+
// A tool call that streamed complete arguments then failed schema
|
|
407
|
+
// validation keeps them in `rawInput`, not `input`; reading `input`
|
|
408
|
+
// alone would convert the error snapshot to `{}` and hide the input.
|
|
409
|
+
const rawInput = (part.input ??
|
|
410
|
+
("rawInput" in part ? part.rawInput : undefined)) as
|
|
411
|
+
| ReadonlyJSONObject
|
|
412
|
+
| null
|
|
413
|
+
| undefined;
|
|
274
414
|
let args: ReadonlyJSONObject;
|
|
275
415
|
if (
|
|
276
416
|
rawInput != null &&
|
|
@@ -303,12 +443,13 @@ function convertParts(
|
|
|
303
443
|
};
|
|
304
444
|
}
|
|
305
445
|
|
|
306
|
-
let argsText
|
|
307
|
-
metadata.toolArgsKeyOrderCache,
|
|
308
|
-
argsKeyOrderCacheKey,
|
|
309
|
-
args,
|
|
310
|
-
);
|
|
446
|
+
let argsText: string;
|
|
311
447
|
if (part.state === "input-streaming") {
|
|
448
|
+
argsText = stableStringifyToolArgs(
|
|
449
|
+
metadata.toolArgsKeyOrderCache,
|
|
450
|
+
argsKeyOrderCacheKey,
|
|
451
|
+
args,
|
|
452
|
+
);
|
|
312
453
|
// strip closing delimiters added by the AI SDK's fix-json
|
|
313
454
|
argsText = stripClosingDelimiters(argsText);
|
|
314
455
|
// Re-parse so args carries the partial-JSON meta that marks which
|
|
@@ -317,6 +458,27 @@ function convertParts(
|
|
|
317
458
|
// of the stripped text is the streaming frontier.
|
|
318
459
|
args = parsePartialJsonObject(argsText) ?? args;
|
|
319
460
|
} else {
|
|
461
|
+
// A settled part is re-converted whenever its message or the converter
|
|
462
|
+
// metadata changes; the text frozen on its input object skips
|
|
463
|
+
// re-serializing large args while the call keeps that input. Arrival
|
|
464
|
+
// order only matters while args stream, so the key-order entry is
|
|
465
|
+
// released.
|
|
466
|
+
const frozen =
|
|
467
|
+
metadata.toolArgsTextCache?.get(args) ?? new Map<string, string>();
|
|
468
|
+
const frozenText = frozen.get(argsKeyOrderCacheKey);
|
|
469
|
+
if (frozenText !== undefined) {
|
|
470
|
+
argsText = frozenText;
|
|
471
|
+
} else {
|
|
472
|
+
argsText = stableStringifyToolArgs(
|
|
473
|
+
metadata.toolArgsKeyOrderCache,
|
|
474
|
+
argsKeyOrderCacheKey,
|
|
475
|
+
args,
|
|
476
|
+
);
|
|
477
|
+
metadata.toolArgsTextCache?.set(
|
|
478
|
+
args,
|
|
479
|
+
frozen.set(argsKeyOrderCacheKey, argsText),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
320
482
|
metadata.toolArgsKeyOrderCache?.delete(argsKeyOrderCacheKey);
|
|
321
483
|
if (
|
|
322
484
|
part.state === "output-available" ||
|
|
@@ -332,6 +494,8 @@ function convertParts(
|
|
|
332
494
|
part,
|
|
333
495
|
metadata.mcpAppMetadataCache,
|
|
334
496
|
);
|
|
497
|
+
const artifact = metadata.toolArtifacts?.get(toolCallId);
|
|
498
|
+
const interactions = metadata.toolInteractions?.get(toolCallId);
|
|
335
499
|
return {
|
|
336
500
|
type: "tool-call",
|
|
337
501
|
toolName,
|
|
@@ -340,6 +504,12 @@ function convertParts(
|
|
|
340
504
|
args,
|
|
341
505
|
result,
|
|
342
506
|
isError,
|
|
507
|
+
...(artifact !== undefined && { artifact }),
|
|
508
|
+
...(interactions !== undefined && {
|
|
509
|
+
unstable_interactions: interactions,
|
|
510
|
+
}),
|
|
511
|
+
...(part.state === "output-available" &&
|
|
512
|
+
part.preliminary === true && { isPreliminary: true }),
|
|
343
513
|
...(modelContent !== undefined && { modelContent }),
|
|
344
514
|
...(mcpApp && { mcp: { app: mcpApp } }),
|
|
345
515
|
...(part.callProviderMetadata != null
|
|
@@ -348,7 +518,12 @@ function convertParts(
|
|
|
348
518
|
part.callProviderMetadata as PartProviderMetadata,
|
|
349
519
|
}
|
|
350
520
|
: undefined),
|
|
351
|
-
...getToolApprovalAndInterrupt(
|
|
521
|
+
...getToolApprovalAndInterrupt(
|
|
522
|
+
part,
|
|
523
|
+
toolStatus,
|
|
524
|
+
metadata.supportsRichToolApprovalResponses === true,
|
|
525
|
+
metadata.toolApprovalResponses,
|
|
526
|
+
),
|
|
352
527
|
} satisfies ToolCallMessagePart;
|
|
353
528
|
}
|
|
354
529
|
|
package/src/index.native.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -153,7 +153,9 @@ describe("AISDKChat as a standalone client config entry", () => {
|
|
|
153
153
|
]
|
|
154
154
|
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
|
|
155
155
|
.join("");
|
|
156
|
-
const fetchMock = vi.fn
|
|
156
|
+
const fetchMock = vi.fn<
|
|
157
|
+
(input: RequestInfo | URL, init: RequestInit) => Promise<Response>
|
|
158
|
+
>(
|
|
157
159
|
async () =>
|
|
158
160
|
new Response(sse, {
|
|
159
161
|
headers: { "content-type": "text/event-stream" },
|
|
@@ -178,10 +180,7 @@ describe("AISDKChat as a standalone client config entry", () => {
|
|
|
178
180
|
expect(state.messages).toHaveLength(2);
|
|
179
181
|
});
|
|
180
182
|
|
|
181
|
-
const [url, init] = fetchMock.mock.calls[0]
|
|
182
|
-
RequestInfo,
|
|
183
|
-
RequestInit,
|
|
184
|
-
];
|
|
183
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
185
184
|
expect(String(url)).toContain("/api/chat");
|
|
186
185
|
const body = JSON.parse(init.body as string);
|
|
187
186
|
expect(body.id).toBe("test-thread-1");
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
+
import { getEventListeners } from "node:events";
|
|
3
4
|
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { flushTapSync } from "@assistant-ui/tap";
|
|
5
|
-
import {
|
|
5
|
+
import { flushTapSync, resource, useResource } from "@assistant-ui/tap";
|
|
6
|
+
import {
|
|
7
|
+
attachTransformScopes,
|
|
8
|
+
AuiConfig,
|
|
9
|
+
createAssistantClient,
|
|
10
|
+
} from "@assistant-ui/store/client";
|
|
11
|
+
import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
|
|
12
|
+
import { inMemoryThreadListTransformScopes } from "@assistant-ui/core/store";
|
|
6
13
|
import type { AssistantCloud } from "assistant-cloud";
|
|
7
14
|
import type { RemoteThreadListAdapter } from "@assistant-ui/core";
|
|
8
15
|
import type { ThreadHistoryAdapter } from "@assistant-ui/core";
|
|
@@ -152,4 +159,55 @@ describe("AISDKThreads cloud", () => {
|
|
|
152
159
|
handle.destroy();
|
|
153
160
|
}
|
|
154
161
|
});
|
|
162
|
+
|
|
163
|
+
it("keeps cloud threads off the client destroy signal and stops them on destroy", async () => {
|
|
164
|
+
const chat = createCancellableTransport();
|
|
165
|
+
let destroySignal: AbortSignal | undefined;
|
|
166
|
+
function useThreads() {
|
|
167
|
+
destroySignal = useAssistantClientDestroySignal();
|
|
168
|
+
return useResource(
|
|
169
|
+
AISDKThreads({
|
|
170
|
+
cloud: {} as AssistantCloud,
|
|
171
|
+
threadId: "t1",
|
|
172
|
+
transport: () => chat.transport,
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
attachTransformScopes(useThreads, inMemoryThreadListTransformScopes);
|
|
177
|
+
const handle = createAssistantClient(
|
|
178
|
+
AuiConfig({ threads: resource(useThreads)() }),
|
|
179
|
+
);
|
|
180
|
+
handle.subscribe(() => {});
|
|
181
|
+
const listeners = () => getEventListeners(destroySignal!, "abort").length;
|
|
182
|
+
try {
|
|
183
|
+
await handle.getClient().threads.getLoadThreadsPromise();
|
|
184
|
+
await vi.waitFor(() => {
|
|
185
|
+
expect(handle.getClient().threads.getState().mainThreadId).toBe("t1");
|
|
186
|
+
});
|
|
187
|
+
await vi.waitFor(() => {
|
|
188
|
+
expect(handle.getClient().thread.getState().isLoading).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
const withFirstThread = listeners();
|
|
191
|
+
|
|
192
|
+
flushTapSync(() => handle.getClient().threads.switchToThread("t2"));
|
|
193
|
+
await vi.waitFor(() => {
|
|
194
|
+
expect(handle.getClient().threads.getState().mainThreadId).toBe("t2");
|
|
195
|
+
});
|
|
196
|
+
await vi.waitFor(() => {
|
|
197
|
+
expect(handle.getClient().thread.getState().isLoading).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
expect(listeners()).toBe(withFirstThread);
|
|
200
|
+
|
|
201
|
+
flushTapSync(() => handle.getClient().composer.setText("stream me"));
|
|
202
|
+
flushTapSync(() => handle.getClient().composer.send());
|
|
203
|
+
await vi.waitFor(() => {
|
|
204
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
} finally {
|
|
207
|
+
handle.destroy();
|
|
208
|
+
}
|
|
209
|
+
await vi.waitFor(() => {
|
|
210
|
+
expect(chat.getCancelCount()).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
155
213
|
});
|
|
@@ -28,6 +28,33 @@ const threadText = (aui: ReturnType<typeof createAssistantClient>) =>
|
|
|
28
28
|
m.content.map((part) => (part.type === "text" ? part.text : "")).join(""),
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
+
const createLiveHandle = (
|
|
32
|
+
getOptions: () => Parameters<typeof AISDKThreads>[0],
|
|
33
|
+
) => {
|
|
34
|
+
const listeners = new Set<() => void>();
|
|
35
|
+
const handle = createAssistantClient({
|
|
36
|
+
getConfig: () => AuiConfig({ threads: AISDKThreads(getOptions()) }),
|
|
37
|
+
subscribe: (listener) => {
|
|
38
|
+
listeners.add(listener);
|
|
39
|
+
return () => listeners.delete(listener);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
handle.subscribe(() => {});
|
|
43
|
+
return {
|
|
44
|
+
handle,
|
|
45
|
+
rerender: () =>
|
|
46
|
+
flushTapSync(() => listeners.forEach((listener) => listener())),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const chatCallbacks = () => ({
|
|
51
|
+
onToolCall: vi.fn(),
|
|
52
|
+
onData: vi.fn(),
|
|
53
|
+
onFinish: vi.fn(),
|
|
54
|
+
onError: vi.fn(),
|
|
55
|
+
sendAutomaticallyWhen: vi.fn(() => false),
|
|
56
|
+
});
|
|
57
|
+
|
|
31
58
|
describe("AISDKThreads", () => {
|
|
32
59
|
it("runs one chat per thread and keeps histories isolated across switches", async () => {
|
|
33
60
|
const { transport, emit, close } = createControlledTransport();
|
|
@@ -253,25 +280,19 @@ describe("AISDKThreads", () => {
|
|
|
253
280
|
|
|
254
281
|
it("forwards ChatInit callbacks to each thread's chat from the latest render", async () => {
|
|
255
282
|
const { transport, emit, close } = createControlledTransport();
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
subscribe: (listener) => {
|
|
266
|
-
listeners.add(listener);
|
|
267
|
-
return () => listeners.delete(listener);
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
|
-
handle.subscribe(() => {});
|
|
283
|
+
const sendMessages = vi.spyOn(transport, "sendMessages");
|
|
284
|
+
const mounted = chatCallbacks();
|
|
285
|
+
const latest = chatCallbacks();
|
|
286
|
+
latest.sendAutomaticallyWhen.mockReturnValueOnce(true);
|
|
287
|
+
let callbacks = mounted;
|
|
288
|
+
const { handle, rerender } = createLiveHandle(() => ({
|
|
289
|
+
transport: () => transport,
|
|
290
|
+
...callbacks,
|
|
291
|
+
}));
|
|
271
292
|
const aui = handle.getClient();
|
|
272
293
|
|
|
273
|
-
|
|
274
|
-
|
|
294
|
+
callbacks = latest;
|
|
295
|
+
rerender();
|
|
275
296
|
|
|
276
297
|
flushTapSync(() => aui.composer.setText("hi"));
|
|
277
298
|
flushTapSync(() => aui.composer.send());
|
|
@@ -280,10 +301,36 @@ describe("AISDKThreads", () => {
|
|
|
280
301
|
handle.getClient().thread.getState().messages.length,
|
|
281
302
|
).toBeGreaterThan(0);
|
|
282
303
|
});
|
|
283
|
-
emit(
|
|
304
|
+
emit(
|
|
305
|
+
{ type: "start" },
|
|
306
|
+
{
|
|
307
|
+
type: "tool-input-available",
|
|
308
|
+
toolCallId: "call-1",
|
|
309
|
+
toolName: "lookup",
|
|
310
|
+
input: {},
|
|
311
|
+
},
|
|
312
|
+
{ type: "data-note", data: "note" },
|
|
313
|
+
{ type: "finish" },
|
|
314
|
+
);
|
|
284
315
|
close();
|
|
285
|
-
await vi.waitFor(() => expect(
|
|
286
|
-
|
|
316
|
+
await vi.waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
|
|
317
|
+
emit({ type: "start" }, { type: "error", errorText: "boom" });
|
|
318
|
+
close();
|
|
319
|
+
await vi.waitFor(() => expect(latest.onFinish).toHaveBeenCalledTimes(2));
|
|
320
|
+
|
|
321
|
+
expect(latest.onToolCall).toHaveBeenCalledExactlyOnceWith({
|
|
322
|
+
toolCall: expect.objectContaining({ toolCallId: "call-1" }),
|
|
323
|
+
});
|
|
324
|
+
expect(latest.onData).toHaveBeenCalledExactlyOnceWith(
|
|
325
|
+
expect.objectContaining({ type: "data-note", data: "note" }),
|
|
326
|
+
);
|
|
327
|
+
expect(latest.onError).toHaveBeenCalledExactlyOnceWith(
|
|
328
|
+
expect.objectContaining({ message: "boom" }),
|
|
329
|
+
);
|
|
330
|
+
expect(latest.sendAutomaticallyWhen).toHaveBeenCalledOnce();
|
|
331
|
+
for (const callback of Object.values(mounted)) {
|
|
332
|
+
expect(callback).not.toHaveBeenCalled();
|
|
333
|
+
}
|
|
287
334
|
|
|
288
335
|
handle.destroy();
|
|
289
336
|
});
|
|
@@ -293,18 +340,10 @@ describe("AISDKThreads", () => {
|
|
|
293
340
|
const onFinishA = vi.fn();
|
|
294
341
|
const onFinishB = vi.fn();
|
|
295
342
|
let onFinish = onFinishA;
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
threads: AISDKThreads({ transport: () => transport, onFinish }),
|
|
301
|
-
}),
|
|
302
|
-
subscribe: (listener) => {
|
|
303
|
-
listeners.add(listener);
|
|
304
|
-
return () => listeners.delete(listener);
|
|
305
|
-
},
|
|
306
|
-
});
|
|
307
|
-
handle.subscribe(() => {});
|
|
343
|
+
const { handle, rerender } = createLiveHandle(() => ({
|
|
344
|
+
transport: () => transport,
|
|
345
|
+
onFinish,
|
|
346
|
+
}));
|
|
308
347
|
const aui = handle.getClient();
|
|
309
348
|
|
|
310
349
|
flushTapSync(() => aui.composer.setText("stream me"));
|
|
@@ -322,7 +361,7 @@ describe("AISDKThreads", () => {
|
|
|
322
361
|
|
|
323
362
|
flushTapSync(() => aui.threads.switchToNewThread());
|
|
324
363
|
onFinish = onFinishB;
|
|
325
|
-
|
|
364
|
+
rerender();
|
|
326
365
|
|
|
327
366
|
emit({ type: "text-end", id: "t1" }, { type: "finish" });
|
|
328
367
|
close();
|
|
@@ -361,18 +400,12 @@ describe("AISDKThreads", () => {
|
|
|
361
400
|
const onFinishA = vi.fn();
|
|
362
401
|
const onFinishB = vi.fn();
|
|
363
402
|
let onFinish = onFinishA;
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
subscribe: (listener) => {
|
|
371
|
-
listeners.add(listener);
|
|
372
|
-
return () => listeners.delete(listener);
|
|
373
|
-
},
|
|
374
|
-
});
|
|
375
|
-
handle.subscribe(() => {});
|
|
403
|
+
const { handle, rerender } = createLiveHandle(() => ({
|
|
404
|
+
cloud,
|
|
405
|
+
threadId: "t1",
|
|
406
|
+
transport,
|
|
407
|
+
onFinish,
|
|
408
|
+
}));
|
|
376
409
|
try {
|
|
377
410
|
await handle.getClient().threads.getLoadThreadsPromise();
|
|
378
411
|
await vi.waitFor(() => {
|
|
@@ -383,7 +416,7 @@ describe("AISDKThreads", () => {
|
|
|
383
416
|
});
|
|
384
417
|
|
|
385
418
|
onFinish = onFinishB;
|
|
386
|
-
|
|
419
|
+
rerender();
|
|
387
420
|
|
|
388
421
|
flushTapSync(() => handle.getClient().composer.setText("hi"));
|
|
389
422
|
flushTapSync(() => handle.getClient().composer.send());
|
|
@@ -455,7 +488,9 @@ describe("AISDKThreads", () => {
|
|
|
455
488
|
const list = vi.fn(async () => ({
|
|
456
489
|
threads: [cloudThread("cloud-1"), cloudThread("cloud-2")],
|
|
457
490
|
}));
|
|
458
|
-
const create = vi.fn(async () => ({
|
|
491
|
+
const create = vi.fn<AssistantCloud["threads"]["create"]>(async () => ({
|
|
492
|
+
thread_id: "cloud-created",
|
|
493
|
+
}));
|
|
459
494
|
const deleteThread = vi.fn(async () => {});
|
|
460
495
|
const cloud = {
|
|
461
496
|
threads: {
|
|
@@ -521,7 +556,9 @@ describe("AISDKThreads", () => {
|
|
|
521
556
|
external_id: null,
|
|
522
557
|
metadata: null,
|
|
523
558
|
});
|
|
524
|
-
const create = vi.fn
|
|
559
|
+
const create = vi.fn<AssistantCloud["threads"]["messages"]["create"]>(
|
|
560
|
+
async () => ({ message_id: "remote-message-1" }),
|
|
561
|
+
);
|
|
525
562
|
const cloud = {
|
|
526
563
|
threads: {
|
|
527
564
|
list: vi.fn(async () => ({
|
|
@@ -602,9 +639,9 @@ describe("AISDKThreads", () => {
|
|
|
602
639
|
external_id: null,
|
|
603
640
|
metadata: null,
|
|
604
641
|
});
|
|
605
|
-
const createMessage = vi.fn
|
|
606
|
-
|
|
607
|
-
}));
|
|
642
|
+
const createMessage = vi.fn<
|
|
643
|
+
AssistantCloud["threads"]["messages"]["create"]
|
|
644
|
+
>(async () => ({ message_id: "remote-message-1" }));
|
|
608
645
|
const cloud = {
|
|
609
646
|
threads: {
|
|
610
647
|
list: vi.fn(async () => ({ threads: [cloudThread("t1")] })),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { resource, useResource, withKey } from "@assistant-ui/tap";
|
|
4
4
|
import { useEffect, useMemo, useState } from "react";
|
|
5
|
-
import { Chat,
|
|
5
|
+
import type { Chat, UIMessage } from "@ai-sdk/react";
|
|
6
6
|
import type { ChatTransport } from "ai";
|
|
7
7
|
import type { AssistantCloud } from "assistant-cloud";
|
|
8
8
|
import {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { useAui } from "@assistant-ui/store";
|
|
21
21
|
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
22
22
|
import {
|
|
23
|
+
createChat,
|
|
23
24
|
splitChatThreadOptions,
|
|
24
25
|
useChatThread,
|
|
25
26
|
type ChatThreadOptions,
|
|
@@ -91,17 +92,7 @@ const createChatEntry = <UI_MESSAGE extends UIMessage>(
|
|
|
91
92
|
? options.transport.__internal_clone()
|
|
92
93
|
: options.transport;
|
|
93
94
|
return {
|
|
94
|
-
chat:
|
|
95
|
-
...chatInit,
|
|
96
|
-
id: threadId,
|
|
97
|
-
transport,
|
|
98
|
-
onToolCall: (arg) => optionsRef.current?.onToolCall?.(arg),
|
|
99
|
-
onData: (arg) => optionsRef.current?.onData?.(arg),
|
|
100
|
-
onFinish: (arg) => optionsRef.current?.onFinish?.(arg),
|
|
101
|
-
onError: (arg) => optionsRef.current?.onError?.(arg),
|
|
102
|
-
sendAutomaticallyWhen: (arg) =>
|
|
103
|
-
optionsRef.current?.sendAutomaticallyWhen?.(arg) ?? false,
|
|
104
|
-
}),
|
|
95
|
+
chat: createChat({ ...chatInit, id: threadId, transport }, optionsRef),
|
|
105
96
|
transport,
|
|
106
97
|
repository: new MessageRepository(),
|
|
107
98
|
optionsRef,
|
|
@@ -171,7 +162,7 @@ const useAISDKChatThread = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
|
171
162
|
: fallbackItem,
|
|
172
163
|
chat,
|
|
173
164
|
messageRepositoryInstance: repository,
|
|
174
|
-
stopOnClientDestroy:
|
|
165
|
+
stopOnClientDestroy: false,
|
|
175
166
|
},
|
|
176
167
|
);
|
|
177
168
|
|