@assistant-ui/ai-sdk 0.0.5 → 0.0.7
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/dist/adapters/aiSDKFormatAdapter.d.ts +2 -8
- package/dist/adapters/aiSDKFormatAdapter.js +1 -25
- package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -2
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
- package/dist/aiSDKExtras.d.ts +2 -3
- package/dist/aiSDKExtras.d.ts.map +1 -1
- package/dist/converters/convertMessage.d.ts +5 -4
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +40 -3
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/modelContentEnvelope.d.ts +4 -5
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
- package/dist/converters/toCreateMessage.d.ts +1 -2
- package/dist/converters/toCreateMessage.d.ts.map +1 -1
- package/dist/converters/toolOutputConversion.d.ts +2 -3
- package/dist/converters/toolOutputConversion.d.ts.map +1 -1
- package/dist/hooks.d.ts +2 -3
- package/dist/hooks.d.ts.map +1 -1
- package/dist/model-context/injectInteractableContext.d.ts +1 -2
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +1 -2
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.d.ts +2 -3
- package/dist/runtime/AISDKChat.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.d.ts +2 -3
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +87 -68
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/sdkIdentity.d.ts +5 -0
- package/dist/runtime/sdkIdentity.d.ts.map +1 -0
- package/dist/runtime/sdkIdentity.js +9 -0
- package/dist/runtime/sdkIdentity.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +17 -5
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +103 -38
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts +2 -3
- package/dist/runtime/useChatRuntime.d.ts.map +1 -1
- package/dist/runtime/useChatRuntime.js +5 -1
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useChatThread.d.ts +19 -5
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +31 -10
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts +2 -3
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +31 -20
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.d.ts +1 -2
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
- package/dist/runtime/useStreamingTiming.d.ts +2 -3
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
- package/dist/tools/frontendTools.d.ts +3 -4
- package/dist/tools/frontendTools.d.ts.map +1 -1
- package/dist/tools/generativeTools.d.ts +5 -6
- package/dist/tools/generativeTools.d.ts.map +1 -1
- package/dist/tools/mcp-stdio.unsupported.d.ts +1 -2
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.d.ts +3 -4
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
- package/dist/transport/resumable.d.ts +4 -5
- package/dist/transport/resumable.d.ts.map +1 -1
- package/dist/usage.d.ts +4 -5
- package/dist/usage.d.ts.map +1 -1
- package/dist/utils/getVercelAIMessages.d.ts +1 -2
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
- package/dist/utils/sliceMessagesUntil.d.ts +1 -2
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/package.json +15 -14
- package/src/adapters/aiSDKFormatAdapter.ts +4 -41
- package/src/converters/convertMessage.test.ts +152 -0
- package/src/converters/convertMessage.ts +95 -5
- package/src/runtime/AISDKChat.test.ts +4 -5
- package/src/runtime/AISDKThreads.cloud.test.ts +12 -3
- package/src/runtime/AISDKThreads.test.ts +140 -13
- package/src/runtime/AISDKThreads.ts +23 -4
- package/src/runtime/sdkIdentity.ts +9 -0
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +287 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +257 -1
- package/src/runtime/useAISDKRuntime.test.ts +46 -4
- package/src/runtime/useAISDKRuntime.ts +164 -24
- package/src/runtime/useAISDKRuntime.voice.test.tsx +270 -0
- package/src/runtime/useChatRuntime.test.ts +72 -5
- package/src/runtime/useChatRuntime.ts +2 -1
- package/src/runtime/useChatThread.test.ts +74 -0
- package/src/runtime/useChatThread.ts +45 -6
- package/src/runtime/useExternalHistory.test.ts +75 -0
- package/src/runtime/useExternalHistory.ts +28 -11
- package/src/tools/generativeTools.test.ts +7 -1
- package/src/transport/AssistantChatTransport.test.ts +1 -9
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +0 -1
- package/dist/adapters/aiSDKFormatAdapter.js.map +0 -1
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
CreateUIMessage,
|
|
15
15
|
UseChatHelpers,
|
|
16
16
|
} from "@ai-sdk/react";
|
|
17
|
-
import { isToolUIPart, generateId } from "ai";
|
|
17
|
+
import { isToolUIPart, generateId, getToolName } from "ai";
|
|
18
18
|
import {
|
|
19
19
|
useExternalStoreRuntime,
|
|
20
20
|
useRuntimeAdapters,
|
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
AppendMessage,
|
|
38
38
|
RunConfig,
|
|
39
39
|
McpAppMetadata,
|
|
40
|
+
RespondToToolApprovalOptions,
|
|
40
41
|
} from "@assistant-ui/core";
|
|
41
42
|
import {
|
|
42
43
|
getExternalStoreMessages,
|
|
@@ -47,11 +48,15 @@ import {
|
|
|
47
48
|
MessageRepository,
|
|
48
49
|
} from "@assistant-ui/core/internal";
|
|
49
50
|
import type { ReadonlyJSONObject } from "assistant-stream/utils";
|
|
51
|
+
import type { AssistantError } from "@assistant-ui/core";
|
|
50
52
|
import { sliceMessagesUntil } from "../utils/sliceMessagesUntil";
|
|
51
53
|
import { toCreateMessage } from "../converters/toCreateMessage";
|
|
52
54
|
import { vercelAttachmentAdapter } from "../adapters/vercelAttachmentAdapter";
|
|
53
55
|
import { getVercelAIMessages } from "../utils/getVercelAIMessages";
|
|
54
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
AISDKMessageConverter,
|
|
58
|
+
type AISDKMessageConverterMetadata,
|
|
59
|
+
} from "../converters/convertMessage";
|
|
55
60
|
import { wrapModelContentEnvelope } from "../converters/modelContentEnvelope";
|
|
56
61
|
import {
|
|
57
62
|
type AISDKStorageFormat,
|
|
@@ -80,6 +85,23 @@ const toUIMessage = <UI_MESSAGE extends UIMessage>(
|
|
|
80
85
|
role: createMessage.role ?? fallbackRole,
|
|
81
86
|
}) as UI_MESSAGE;
|
|
82
87
|
|
|
88
|
+
const toVoiceTranscriptUIMessage = <UI_MESSAGE extends UIMessage>(
|
|
89
|
+
message: ThreadMessage,
|
|
90
|
+
): UI_MESSAGE =>
|
|
91
|
+
({
|
|
92
|
+
id: message.id,
|
|
93
|
+
role: message.role,
|
|
94
|
+
parts: message.content
|
|
95
|
+
.filter((part) => part.type === "text")
|
|
96
|
+
.map((part) => ({ type: "text", text: part.text })),
|
|
97
|
+
metadata: {
|
|
98
|
+
modality: "voice",
|
|
99
|
+
...(Object.keys(message.metadata.custom).length > 0 && {
|
|
100
|
+
custom: message.metadata.custom,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
}) as UI_MESSAGE;
|
|
104
|
+
|
|
83
105
|
export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
84
106
|
ExternalStoreSharedOptions & {
|
|
85
107
|
adapters?:
|
|
@@ -114,6 +136,24 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
114
136
|
* Provide this to bridge resume-tool-call invocations into a custom handler.
|
|
115
137
|
*/
|
|
116
138
|
onResumeToolCall?: ExternalStoreAdapter["onResumeToolCall"];
|
|
139
|
+
/**
|
|
140
|
+
* Answers tool approval requests through a host-owned channel instead of the AI SDK's `addToolApprovalResponse`.
|
|
141
|
+
*
|
|
142
|
+
* Called for every approval request in the thread with the complete response, including option and free-form answers. Hand requests the host does not own to `respondViaAISDK`, which is what runs when this option is omitted. The answer applies to the approval when the handler starts and is removed if it throws. It is never written into the `useChat` messages, so `sendAutomaticallyWhen` cannot forward it, and it lasts as long as this runtime: until then a second response to the same request rejects, and a runtime mounted again over the same chat shows the request open until the resumed run records its resolution in the chat.
|
|
143
|
+
*
|
|
144
|
+
* While a handler is set, an approval's `display`, `allowFreeform` and `options` reach the renderer, because the handler can receive answers the AI SDK cannot carry.
|
|
145
|
+
*/
|
|
146
|
+
onRespondToToolApproval?:
|
|
147
|
+
| ((
|
|
148
|
+
response: RespondToToolApprovalOptions,
|
|
149
|
+
context: {
|
|
150
|
+
toolCallId: string;
|
|
151
|
+
toolName: string;
|
|
152
|
+
/** Sends this response through the AI SDK's `addToolApprovalResponse`, which carries only `approved` and `reason`. */
|
|
153
|
+
respondViaAISDK: () => Promise<void>;
|
|
154
|
+
},
|
|
155
|
+
) => Promise<void> | void)
|
|
156
|
+
| undefined;
|
|
117
157
|
/**
|
|
118
158
|
* How consecutive assistant messages are rendered.
|
|
119
159
|
*
|
|
@@ -219,6 +259,24 @@ const useGeneratedSuggestions = (
|
|
|
219
259
|
|
|
220
260
|
const NO_CANCELLED_MESSAGE_IDS: ReadonlySet<string> = new Set();
|
|
221
261
|
|
|
262
|
+
const NO_TOOL_APPROVAL_RESPONSES: ReadonlyMap<
|
|
263
|
+
string,
|
|
264
|
+
RespondToToolApprovalOptions
|
|
265
|
+
> = new Map();
|
|
266
|
+
|
|
267
|
+
const toChatError = (error: Error): AssistantError => {
|
|
268
|
+
const code = (error as { code?: unknown }).code;
|
|
269
|
+
return {
|
|
270
|
+
code:
|
|
271
|
+
typeof code === "string"
|
|
272
|
+
? code
|
|
273
|
+
: error.name !== "Error"
|
|
274
|
+
? error.name
|
|
275
|
+
: "unknown",
|
|
276
|
+
message: error.message,
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
222
280
|
export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
223
281
|
chatHelpers: ReturnType<typeof useChat<UI_MESSAGE>>,
|
|
224
282
|
adapter: AISDKRuntimeAdapter<UI_MESSAGE> = {},
|
|
@@ -229,6 +287,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
229
287
|
cancelPendingToolCallsOnSend = true,
|
|
230
288
|
onResume,
|
|
231
289
|
onResumeToolCall,
|
|
290
|
+
onRespondToToolApproval: customOnRespondToToolApproval,
|
|
232
291
|
joinStrategy,
|
|
233
292
|
messageRepository,
|
|
234
293
|
unstable_onBranchChange,
|
|
@@ -242,6 +301,10 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
242
301
|
chatId: string;
|
|
243
302
|
ids: ReadonlySet<string>;
|
|
244
303
|
} | null>(null);
|
|
304
|
+
const [toolApprovalResponses, setToolApprovalResponses] = useState<
|
|
305
|
+
ReadonlyMap<string, RespondToToolApprovalOptions>
|
|
306
|
+
>(NO_TOOL_APPROVAL_RESPONSES);
|
|
307
|
+
const hostApprovalIdsRef = useRef(new Set<string>());
|
|
245
308
|
const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
|
|
246
309
|
new Map(),
|
|
247
310
|
);
|
|
@@ -271,6 +334,22 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
271
334
|
cancelledMessages?.chatId === chatHelpers.id
|
|
272
335
|
? cancelledMessages.ids
|
|
273
336
|
: NO_CANCELLED_MESSAGE_IDS;
|
|
337
|
+
const supportsRichToolApprovalResponses =
|
|
338
|
+
customOnRespondToToolApproval != null;
|
|
339
|
+
|
|
340
|
+
const toThreadMessages = useCallback(
|
|
341
|
+
(sourceMessages: UI_MESSAGE[]) => {
|
|
342
|
+
const metadata: AISDKMessageConverterMetadata = {
|
|
343
|
+
supportsRichToolApprovalResponses,
|
|
344
|
+
};
|
|
345
|
+
return AISDKMessageConverter.toThreadMessages(
|
|
346
|
+
sourceMessages,
|
|
347
|
+
false,
|
|
348
|
+
metadata,
|
|
349
|
+
);
|
|
350
|
+
},
|
|
351
|
+
[supportsRichToolApprovalResponses],
|
|
352
|
+
);
|
|
274
353
|
|
|
275
354
|
const retractCancellation = useCallback(
|
|
276
355
|
(chatId: string, messageId: string) => {
|
|
@@ -307,16 +386,20 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
307
386
|
isRunning,
|
|
308
387
|
messages: chatHelpers.messages,
|
|
309
388
|
joinStrategy,
|
|
310
|
-
metadata: useMemo(
|
|
389
|
+
metadata: useMemo<AISDKMessageConverterMetadata>(
|
|
311
390
|
() => ({
|
|
312
391
|
toolStatuses,
|
|
313
392
|
messageTiming,
|
|
314
393
|
toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
|
|
315
394
|
toolLastInputCache: toolLastInputCacheRef.current,
|
|
316
395
|
mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
|
|
396
|
+
supportsRichToolApprovalResponses,
|
|
317
397
|
...(optimisticMessageId && { optimisticMessageId }),
|
|
318
|
-
...(chatHelpers.error && {
|
|
398
|
+
...(chatHelpers.error && {
|
|
399
|
+
error: toChatError(chatHelpers.error),
|
|
400
|
+
}),
|
|
319
401
|
...(cancelledMessageIds.size > 0 && { cancelledMessageIds }),
|
|
402
|
+
...(toolApprovalResponses.size > 0 && { toolApprovalResponses }),
|
|
320
403
|
}),
|
|
321
404
|
[
|
|
322
405
|
toolStatuses,
|
|
@@ -324,6 +407,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
324
407
|
optimisticMessageId,
|
|
325
408
|
chatHelpers.error,
|
|
326
409
|
cancelledMessageIds,
|
|
410
|
+
toolApprovalResponses,
|
|
411
|
+
supportsRichToolApprovalResponses,
|
|
327
412
|
],
|
|
328
413
|
),
|
|
329
414
|
});
|
|
@@ -331,13 +416,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
331
416
|
const exportedMessageRepository = useMemo(() => {
|
|
332
417
|
if (!messageRepository) return undefined;
|
|
333
418
|
const converted = toExportedMessageRepository(
|
|
334
|
-
|
|
335
|
-
messages: UI_MESSAGE[],
|
|
336
|
-
) => ThreadMessage[],
|
|
419
|
+
toThreadMessages,
|
|
337
420
|
messageRepository,
|
|
338
421
|
);
|
|
339
422
|
return converted.messages.length > 0 ? converted : undefined;
|
|
340
|
-
}, [messageRepository]);
|
|
423
|
+
}, [messageRepository, toThreadMessages]);
|
|
341
424
|
|
|
342
425
|
const generatedSuggestions = useGeneratedSuggestions(
|
|
343
426
|
suggestionAdapter,
|
|
@@ -354,9 +437,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
354
437
|
const { isLoading, deleteMessage: deleteHistoryMessage } = useExternalHistory(
|
|
355
438
|
runtimeRef,
|
|
356
439
|
adapters?.history ?? contextAdapters?.history,
|
|
357
|
-
|
|
358
|
-
messages: UI_MESSAGE[],
|
|
359
|
-
) => ThreadMessage[],
|
|
440
|
+
toThreadMessages,
|
|
360
441
|
aiSDKV6FormatAdapter as MessageFormatAdapter<
|
|
361
442
|
UI_MESSAGE,
|
|
362
443
|
AISDKStorageFormat
|
|
@@ -418,6 +499,69 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
418
499
|
});
|
|
419
500
|
};
|
|
420
501
|
|
|
502
|
+
const respondViaAISDK = ({
|
|
503
|
+
approvalId,
|
|
504
|
+
approved,
|
|
505
|
+
reason,
|
|
506
|
+
}: RespondToToolApprovalOptions) =>
|
|
507
|
+
Promise.resolve(
|
|
508
|
+
chatHelpers.addToolApprovalResponse({
|
|
509
|
+
id: approvalId,
|
|
510
|
+
approved,
|
|
511
|
+
...(reason != null && { reason }),
|
|
512
|
+
options: { metadata: lastRunConfigRef.current },
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
const respondViaHost = async (
|
|
517
|
+
onRespond: NonNullable<AISDKRuntimeAdapter["onRespondToToolApproval"]>,
|
|
518
|
+
response: RespondToToolApprovalOptions,
|
|
519
|
+
) => {
|
|
520
|
+
const { approvalId } = response;
|
|
521
|
+
const requested = chatHelpers.messages
|
|
522
|
+
.flatMap((message) => message.parts)
|
|
523
|
+
.filter(isToolUIPart)
|
|
524
|
+
.find(
|
|
525
|
+
(part) =>
|
|
526
|
+
part.state === "approval-requested" &&
|
|
527
|
+
part.approval.id === approvalId,
|
|
528
|
+
);
|
|
529
|
+
if (!requested || hostApprovalIdsRef.current.has(approvalId))
|
|
530
|
+
throw new Error(
|
|
531
|
+
`Tool approval ${approvalId} is not waiting for a response.`,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// A host answer stays out of the useChat messages, where sendAutomaticallyWhen would forward it to the chat route.
|
|
535
|
+
const applyResponse = (applied: boolean) => {
|
|
536
|
+
if (applied) hostApprovalIdsRef.current.add(approvalId);
|
|
537
|
+
else hostApprovalIdsRef.current.delete(approvalId);
|
|
538
|
+
setToolApprovalResponses((prev) => {
|
|
539
|
+
const responses = new Map(prev);
|
|
540
|
+
if (applied) responses.set(approvalId, response);
|
|
541
|
+
else responses.delete(approvalId);
|
|
542
|
+
return responses;
|
|
543
|
+
});
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
applyResponse(true);
|
|
547
|
+
try {
|
|
548
|
+
await onRespond(response, {
|
|
549
|
+
toolCallId: requested.toolCallId,
|
|
550
|
+
toolName: getToolName(requested),
|
|
551
|
+
respondViaAISDK: async () => {
|
|
552
|
+
try {
|
|
553
|
+
await respondViaAISDK(response);
|
|
554
|
+
} finally {
|
|
555
|
+
applyResponse(false);
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (hostApprovalIdsRef.current.has(approvalId)) applyResponse(false);
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
421
565
|
const hasSeededRepositoryRef = useRef(false);
|
|
422
566
|
const shouldFeedRepository =
|
|
423
567
|
exportedMessageRepository != null &&
|
|
@@ -445,6 +589,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
445
589
|
.filter(Boolean)
|
|
446
590
|
.flat(),
|
|
447
591
|
),
|
|
592
|
+
onVoiceTranscript: (message: ThreadMessage) =>
|
|
593
|
+
chatHelpers.setMessages((current) => [
|
|
594
|
+
...current,
|
|
595
|
+
toVoiceTranscriptUIMessage<UI_MESSAGE>(message),
|
|
596
|
+
]),
|
|
448
597
|
onExportExternalState: (): MessageFormatRepository<UI_MESSAGE> => {
|
|
449
598
|
const exported = runtimeRef.current.thread.export();
|
|
450
599
|
|
|
@@ -485,10 +634,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
485
634
|
},
|
|
486
635
|
onLoadExternalState: (repo: MessageFormatRepository<UI_MESSAGE>) => {
|
|
487
636
|
// Convert MessageFormatRepository to ExportedMessageRepository
|
|
488
|
-
const exportedRepo = toExportedMessageRepository(
|
|
489
|
-
AISDKMessageConverter.toThreadMessages,
|
|
490
|
-
repo,
|
|
491
|
-
);
|
|
637
|
+
const exportedRepo = toExportedMessageRepository(toThreadMessages, repo);
|
|
492
638
|
|
|
493
639
|
// Import into the thread's MessageRepository
|
|
494
640
|
runtimeRef.current.thread.import(exportedRepo);
|
|
@@ -618,15 +764,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
618
764
|
);
|
|
619
765
|
}
|
|
620
766
|
},
|
|
621
|
-
onRespondToToolApproval:
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
id: approvalId,
|
|
625
|
-
approved,
|
|
626
|
-
...(reason != null && { reason }),
|
|
627
|
-
options: { metadata: lastRunConfigRef.current },
|
|
628
|
-
}),
|
|
629
|
-
),
|
|
767
|
+
onRespondToToolApproval: customOnRespondToToolApproval
|
|
768
|
+
? (response) => respondViaHost(customOnRespondToToolApproval, response)
|
|
769
|
+
: respondViaAISDK,
|
|
630
770
|
...pickExternalStoreSharedOptions(adapter),
|
|
631
771
|
...(adapter.unstable_messageRepositoryInstance && {
|
|
632
772
|
unstable_messageRepositoryInstance:
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import {
|
|
5
|
+
getExternalStoreMessages,
|
|
6
|
+
type RealtimeVoiceAdapter,
|
|
7
|
+
type ThreadHistoryAdapter,
|
|
8
|
+
type ThreadMessage,
|
|
9
|
+
} from "@assistant-ui/core";
|
|
10
|
+
import type { UIMessage } from "ai";
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { useAISDKRuntime } from "./useAISDKRuntime";
|
|
14
|
+
|
|
15
|
+
const createChatHelpers = (initialMessages: UIMessage[] = []) => {
|
|
16
|
+
let currentMessages: UIMessage[] = initialMessages;
|
|
17
|
+
const chatHelpers: any = {
|
|
18
|
+
id: "chat-1",
|
|
19
|
+
status: "ready",
|
|
20
|
+
error: null,
|
|
21
|
+
messages: currentMessages,
|
|
22
|
+
setMessages: vi.fn(
|
|
23
|
+
(next: UIMessage[] | ((current: UIMessage[]) => UIMessage[])) => {
|
|
24
|
+
currentMessages =
|
|
25
|
+
typeof next === "function" ? next(currentMessages) : [...next];
|
|
26
|
+
chatHelpers.messages = currentMessages;
|
|
27
|
+
return currentMessages;
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
regenerate: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
addToolResult: vi.fn(),
|
|
33
|
+
addToolOutput: vi.fn(),
|
|
34
|
+
stop: vi.fn(),
|
|
35
|
+
};
|
|
36
|
+
return chatHelpers;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const createVoiceAdapter = () => {
|
|
40
|
+
let transcriptCallback:
|
|
41
|
+
| ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
|
|
42
|
+
| undefined;
|
|
43
|
+
const session: RealtimeVoiceAdapter.Session = {
|
|
44
|
+
status: { type: "running" },
|
|
45
|
+
isMuted: false,
|
|
46
|
+
disconnect: vi.fn(),
|
|
47
|
+
mute: vi.fn(),
|
|
48
|
+
unmute: vi.fn(),
|
|
49
|
+
onStatusChange: () => () => {},
|
|
50
|
+
onTranscript: (callback) => {
|
|
51
|
+
transcriptCallback = callback;
|
|
52
|
+
return () => {
|
|
53
|
+
transcriptCallback = undefined;
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
onModeChange: () => () => {},
|
|
57
|
+
onVolumeChange: () => () => {},
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
adapter: { connect: () => session } satisfies RealtimeVoiceAdapter,
|
|
61
|
+
emitTranscript: (transcript: RealtimeVoiceAdapter.TranscriptItem) =>
|
|
62
|
+
transcriptCallback?.(transcript),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const createHistoryAdapter = () => {
|
|
67
|
+
const append = vi.fn(async () => {});
|
|
68
|
+
const adapter: ThreadHistoryAdapter = {
|
|
69
|
+
load: vi.fn(),
|
|
70
|
+
append: vi.fn(),
|
|
71
|
+
withFormat: vi.fn().mockReturnValue({
|
|
72
|
+
load: vi.fn().mockResolvedValue({ headId: null, messages: [] }),
|
|
73
|
+
append,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
return { adapter, append };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const textOf = (message: ThreadMessage) =>
|
|
80
|
+
message.content
|
|
81
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
82
|
+
.join("");
|
|
83
|
+
|
|
84
|
+
const renderVoiceRuntime = async (
|
|
85
|
+
chat: ReturnType<typeof createChatHelpers>,
|
|
86
|
+
adapters: { voice: RealtimeVoiceAdapter; history?: ThreadHistoryAdapter },
|
|
87
|
+
) => {
|
|
88
|
+
const rendered = renderHook(() => useAISDKRuntime(chat, { adapters }));
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(rendered.result.current.thread.getState().capabilities.voice).toBe(
|
|
91
|
+
true,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
return rendered;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
describe("useAISDKRuntime voice transcripts", () => {
|
|
98
|
+
it("persists finalized transcripts through the useChat messages", async () => {
|
|
99
|
+
const chat = createChatHelpers();
|
|
100
|
+
const voice = createVoiceAdapter();
|
|
101
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
102
|
+
voice: voice.adapter,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
act(() => {
|
|
106
|
+
result.current.thread.connectVoice();
|
|
107
|
+
voice.emitTranscript({
|
|
108
|
+
role: "assistant",
|
|
109
|
+
text: "Spoken reply",
|
|
110
|
+
isFinal: true,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const transcript = chat.messages[0];
|
|
115
|
+
expect(transcript).toEqual({
|
|
116
|
+
id: expect.any(String),
|
|
117
|
+
role: "assistant",
|
|
118
|
+
parts: [{ type: "text", text: "Spoken reply" }],
|
|
119
|
+
metadata: { modality: "voice" },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
rerender();
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
const messages = result.current.thread
|
|
126
|
+
.getState()
|
|
127
|
+
.messages.filter((message) => message.id === transcript.id);
|
|
128
|
+
expect(messages).toHaveLength(1);
|
|
129
|
+
expect(messages[0]?.metadata.modality).toBe("voice");
|
|
130
|
+
expect(getExternalStoreMessages(messages[0]!)).toEqual([transcript]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keeps a transcript spoken after a typed reply as its own message", async () => {
|
|
135
|
+
const chat = createChatHelpers([
|
|
136
|
+
{
|
|
137
|
+
id: "typed-user",
|
|
138
|
+
role: "user",
|
|
139
|
+
parts: [{ type: "text", text: "Hi" }],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "typed-assistant",
|
|
143
|
+
role: "assistant",
|
|
144
|
+
parts: [{ type: "text", text: "Typed reply" }],
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
const voice = createVoiceAdapter();
|
|
148
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
149
|
+
voice: voice.adapter,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
act(() => {
|
|
153
|
+
result.current.thread.connectVoice();
|
|
154
|
+
voice.emitTranscript({
|
|
155
|
+
role: "assistant",
|
|
156
|
+
text: "Spoken reply",
|
|
157
|
+
isFinal: true,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
const transcript = chat.messages[2];
|
|
161
|
+
rerender();
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
const spoken = result.current.thread.getState().messages.at(-1)!;
|
|
165
|
+
expect(getExternalStoreMessages(spoken)).toEqual([transcript]);
|
|
166
|
+
});
|
|
167
|
+
const messages = result.current.thread.getState().messages;
|
|
168
|
+
expect(messages.map((message) => message.id)).toEqual([
|
|
169
|
+
"typed-user",
|
|
170
|
+
"typed-assistant",
|
|
171
|
+
transcript.id,
|
|
172
|
+
]);
|
|
173
|
+
expect(textOf(messages[1]!)).toBe("Typed reply");
|
|
174
|
+
expect(messages[1]?.metadata.modality).toBeUndefined();
|
|
175
|
+
expect(textOf(messages[2]!)).toBe("Spoken reply");
|
|
176
|
+
expect(messages[2]?.metadata.modality).toBe("voice");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("keeps consecutive assistant transcripts as separate messages", async () => {
|
|
180
|
+
const chat = createChatHelpers();
|
|
181
|
+
const voice = createVoiceAdapter();
|
|
182
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
183
|
+
voice: voice.adapter,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
act(() => {
|
|
187
|
+
result.current.thread.connectVoice();
|
|
188
|
+
voice.emitTranscript({ role: "assistant", text: "First", isFinal: true });
|
|
189
|
+
voice.emitTranscript({
|
|
190
|
+
role: "assistant",
|
|
191
|
+
text: "Second",
|
|
192
|
+
isFinal: true,
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
const [first, second] = chat.messages;
|
|
196
|
+
rerender();
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
const messages = result.current.thread.getState().messages;
|
|
200
|
+
expect(
|
|
201
|
+
messages.map((message) => getExternalStoreMessages(message)),
|
|
202
|
+
).toEqual([[first], [second]]);
|
|
203
|
+
});
|
|
204
|
+
expect(result.current.thread.getState().messages.map(textOf)).toEqual([
|
|
205
|
+
"First",
|
|
206
|
+
"Second",
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps the transcript in the thread after the session disconnects", async () => {
|
|
211
|
+
const chat = createChatHelpers();
|
|
212
|
+
const voice = createVoiceAdapter();
|
|
213
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
214
|
+
voice: voice.adapter,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
act(() => {
|
|
218
|
+
result.current.thread.connectVoice();
|
|
219
|
+
voice.emitTranscript({
|
|
220
|
+
role: "assistant",
|
|
221
|
+
text: "Spoken reply",
|
|
222
|
+
isFinal: true,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
const transcript = chat.messages[0];
|
|
226
|
+
rerender();
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
const messages = result.current.thread.getState().messages;
|
|
229
|
+
expect(getExternalStoreMessages(messages[0]!)).toEqual([transcript]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
act(() => {
|
|
233
|
+
result.current.thread.disconnectVoice();
|
|
234
|
+
});
|
|
235
|
+
rerender();
|
|
236
|
+
|
|
237
|
+
const messages = result.current.thread.getState().messages;
|
|
238
|
+
expect(messages).toHaveLength(1);
|
|
239
|
+
expect(messages[0]?.id).toBe(transcript.id);
|
|
240
|
+
expect(messages[0]?.metadata.modality).toBe("voice");
|
|
241
|
+
expect(result.current.thread.getState().voice).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("appends a finalized transcript to the history adapter without a text run", async () => {
|
|
245
|
+
const chat = createChatHelpers();
|
|
246
|
+
const voice = createVoiceAdapter();
|
|
247
|
+
const history = createHistoryAdapter();
|
|
248
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
249
|
+
voice: voice.adapter,
|
|
250
|
+
history: history.adapter,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
act(() => {
|
|
254
|
+
result.current.thread.connectVoice();
|
|
255
|
+
voice.emitTranscript({
|
|
256
|
+
role: "assistant",
|
|
257
|
+
text: "Spoken reply",
|
|
258
|
+
isFinal: true,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
const transcript = chat.messages[0];
|
|
262
|
+
rerender();
|
|
263
|
+
|
|
264
|
+
await waitFor(() => expect(history.append).toHaveBeenCalledTimes(1));
|
|
265
|
+
expect(history.append).toHaveBeenCalledWith({
|
|
266
|
+
parentId: null,
|
|
267
|
+
message: transcript,
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -53,6 +53,11 @@ const mocks = vi.hoisted(() => {
|
|
|
53
53
|
|
|
54
54
|
vi.mock("@ai-sdk/react", () => ({
|
|
55
55
|
useChat: mocks.useChat,
|
|
56
|
+
Chat: class MockChat {
|
|
57
|
+
constructor(config: unknown) {
|
|
58
|
+
Object.assign(this, config);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
56
61
|
}));
|
|
57
62
|
|
|
58
63
|
vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
|
|
@@ -88,7 +93,6 @@ const sendMessagesOptions = {
|
|
|
88
93
|
|
|
89
94
|
describe("useChatRuntime", () => {
|
|
90
95
|
beforeEach(() => {
|
|
91
|
-
vi.clearAllMocks();
|
|
92
96
|
mocks.state.isLoadingHistory = false;
|
|
93
97
|
mocks.state.threadId = "thread-id";
|
|
94
98
|
mocks.state.mainThreadId = "thread-id";
|
|
@@ -96,6 +100,51 @@ describe("useChatRuntime", () => {
|
|
|
96
100
|
window.sessionStorage.clear();
|
|
97
101
|
});
|
|
98
102
|
|
|
103
|
+
it("forwards a callback through a ref, so a later render's callback fires instead of the mounted one", () => {
|
|
104
|
+
mocks.useChat.mockReturnValue({
|
|
105
|
+
resumeStream: vi.fn(),
|
|
106
|
+
status: "ready",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const onToolCallA = vi.fn();
|
|
110
|
+
const onToolCallB = vi.fn();
|
|
111
|
+
|
|
112
|
+
const { rerender } = renderHook(
|
|
113
|
+
({ onToolCall }: { onToolCall: typeof onToolCallA }) =>
|
|
114
|
+
useChatRuntime({ onToolCall }),
|
|
115
|
+
{ initialProps: { onToolCall: onToolCallA } },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const chat = mocks.useChat.mock.calls[0]?.[0]?.chat as {
|
|
119
|
+
onToolCall?: (arg: unknown) => void;
|
|
120
|
+
sendAutomaticallyWhen?: (arg: unknown) => boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
chat.onToolCall?.("first");
|
|
124
|
+
expect(onToolCallA).toHaveBeenCalledExactlyOnceWith("first");
|
|
125
|
+
|
|
126
|
+
rerender({ onToolCall: onToolCallB });
|
|
127
|
+
chat.onToolCall?.("second");
|
|
128
|
+
|
|
129
|
+
expect(onToolCallB).toHaveBeenCalledExactlyOnceWith("second");
|
|
130
|
+
expect(onToolCallA).toHaveBeenCalledOnce();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("coerces an unset sendAutomaticallyWhen to false, matching useChat's own default", () => {
|
|
134
|
+
mocks.useChat.mockReturnValue({
|
|
135
|
+
resumeStream: vi.fn(),
|
|
136
|
+
status: "ready",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
renderHook(() => useChatRuntime());
|
|
140
|
+
|
|
141
|
+
const chat = mocks.useChat.mock.calls[0]?.[0]?.chat as {
|
|
142
|
+
sendAutomaticallyWhen?: (arg: unknown) => boolean;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
expect(chat.sendAutomaticallyWhen?.({})).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
99
148
|
it("forwards a defined chat update throttle to useChat", () => {
|
|
100
149
|
mocks.useChat.mockReturnValue({
|
|
101
150
|
resumeStream: vi.fn(),
|
|
@@ -112,6 +161,24 @@ describe("useChatRuntime", () => {
|
|
|
112
161
|
expect(mocks.useChat.mock.calls[1]?.[0]).not.toHaveProperty("throttle");
|
|
113
162
|
});
|
|
114
163
|
|
|
164
|
+
it("forwards a custom approval handler to the runtime only", () => {
|
|
165
|
+
const onRespondToToolApproval = vi.fn();
|
|
166
|
+
mocks.useChat.mockReturnValue({
|
|
167
|
+
resumeStream: vi.fn(),
|
|
168
|
+
status: "ready",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
renderHook(() => useChatRuntime({ onRespondToToolApproval }));
|
|
172
|
+
|
|
173
|
+
expect(mocks.useAISDKRuntime).toHaveBeenCalledWith(
|
|
174
|
+
expect.anything(),
|
|
175
|
+
expect.objectContaining({ onRespondToToolApproval }),
|
|
176
|
+
);
|
|
177
|
+
expect(mocks.useChat.mock.calls[0]?.[0]).not.toHaveProperty(
|
|
178
|
+
"onRespondToToolApproval",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
115
182
|
it("waits for external history to load before resuming a stream", async () => {
|
|
116
183
|
mocks.state.isLoadingHistory = true;
|
|
117
184
|
const resumeStream = vi.fn().mockResolvedValue(undefined);
|
|
@@ -284,8 +351,8 @@ describe("useChatRuntime", () => {
|
|
|
284
351
|
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
285
352
|
status: "streaming",
|
|
286
353
|
};
|
|
287
|
-
mocks.useChat.mockImplementation(({
|
|
288
|
-
id === "thread-a" ? threadA : threadB,
|
|
354
|
+
mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
|
|
355
|
+
chat.id === "thread-a" ? threadA : threadB,
|
|
289
356
|
);
|
|
290
357
|
|
|
291
358
|
mocks.state.threadId = "thread-a";
|
|
@@ -322,8 +389,8 @@ describe("useChatRuntime", () => {
|
|
|
322
389
|
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
323
390
|
status: "ready",
|
|
324
391
|
};
|
|
325
|
-
mocks.useChat.mockImplementation(({
|
|
326
|
-
id === "__LOCALID_background" ? backgroundThread : mainThread,
|
|
392
|
+
mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
|
|
393
|
+
chat.id === "__LOCALID_background" ? backgroundThread : mainThread,
|
|
327
394
|
);
|
|
328
395
|
const transport = {
|
|
329
396
|
getResumableAdapter: () => ({
|