@assistant-ui/ai-sdk 0.0.6 → 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/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 +39 -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 +4 -10
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/sdkIdentity.d.ts +1 -2
- package/dist/runtime/sdkIdentity.d.ts.map +1 -1
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +17 -5
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +95 -37
- 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/useChatThread.d.ts +19 -5
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +30 -9
- 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 +28 -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/converters/convertMessage.test.ts +138 -0
- package/src/converters/convertMessage.ts +94 -5
- package/src/runtime/AISDKChat.test.ts +4 -5
- package/src/runtime/AISDKThreads.test.ts +9 -5
- package/src/runtime/AISDKThreads.ts +3 -12
- 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 +7 -3
- package/src/runtime/useAISDKRuntime.ts +147 -23
- package/src/runtime/useAISDKRuntime.voice.test.tsx +270 -0
- package/src/runtime/useChatRuntime.test.ts +72 -4
- package/src/runtime/useChatThread.ts +44 -5
- package/src/runtime/useExternalHistory.test.ts +46 -0
- package/src/runtime/useExternalHistory.ts +24 -10
- package/src/tools/generativeTools.test.ts +7 -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,
|
|
@@ -52,7 +53,10 @@ import { sliceMessagesUntil } from "../utils/sliceMessagesUntil";
|
|
|
52
53
|
import { toCreateMessage } from "../converters/toCreateMessage";
|
|
53
54
|
import { vercelAttachmentAdapter } from "../adapters/vercelAttachmentAdapter";
|
|
54
55
|
import { getVercelAIMessages } from "../utils/getVercelAIMessages";
|
|
55
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
AISDKMessageConverter,
|
|
58
|
+
type AISDKMessageConverterMetadata,
|
|
59
|
+
} from "../converters/convertMessage";
|
|
56
60
|
import { wrapModelContentEnvelope } from "../converters/modelContentEnvelope";
|
|
57
61
|
import {
|
|
58
62
|
type AISDKStorageFormat,
|
|
@@ -81,6 +85,23 @@ const toUIMessage = <UI_MESSAGE extends UIMessage>(
|
|
|
81
85
|
role: createMessage.role ?? fallbackRole,
|
|
82
86
|
}) as UI_MESSAGE;
|
|
83
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
|
+
|
|
84
105
|
export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
85
106
|
ExternalStoreSharedOptions & {
|
|
86
107
|
adapters?:
|
|
@@ -115,6 +136,24 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
115
136
|
* Provide this to bridge resume-tool-call invocations into a custom handler.
|
|
116
137
|
*/
|
|
117
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;
|
|
118
157
|
/**
|
|
119
158
|
* How consecutive assistant messages are rendered.
|
|
120
159
|
*
|
|
@@ -220,6 +259,11 @@ const useGeneratedSuggestions = (
|
|
|
220
259
|
|
|
221
260
|
const NO_CANCELLED_MESSAGE_IDS: ReadonlySet<string> = new Set();
|
|
222
261
|
|
|
262
|
+
const NO_TOOL_APPROVAL_RESPONSES: ReadonlyMap<
|
|
263
|
+
string,
|
|
264
|
+
RespondToToolApprovalOptions
|
|
265
|
+
> = new Map();
|
|
266
|
+
|
|
223
267
|
const toChatError = (error: Error): AssistantError => {
|
|
224
268
|
const code = (error as { code?: unknown }).code;
|
|
225
269
|
return {
|
|
@@ -243,6 +287,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
243
287
|
cancelPendingToolCallsOnSend = true,
|
|
244
288
|
onResume,
|
|
245
289
|
onResumeToolCall,
|
|
290
|
+
onRespondToToolApproval: customOnRespondToToolApproval,
|
|
246
291
|
joinStrategy,
|
|
247
292
|
messageRepository,
|
|
248
293
|
unstable_onBranchChange,
|
|
@@ -256,6 +301,10 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
256
301
|
chatId: string;
|
|
257
302
|
ids: ReadonlySet<string>;
|
|
258
303
|
} | null>(null);
|
|
304
|
+
const [toolApprovalResponses, setToolApprovalResponses] = useState<
|
|
305
|
+
ReadonlyMap<string, RespondToToolApprovalOptions>
|
|
306
|
+
>(NO_TOOL_APPROVAL_RESPONSES);
|
|
307
|
+
const hostApprovalIdsRef = useRef(new Set<string>());
|
|
259
308
|
const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
|
|
260
309
|
new Map(),
|
|
261
310
|
);
|
|
@@ -285,6 +334,22 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
285
334
|
cancelledMessages?.chatId === chatHelpers.id
|
|
286
335
|
? cancelledMessages.ids
|
|
287
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
|
+
);
|
|
288
353
|
|
|
289
354
|
const retractCancellation = useCallback(
|
|
290
355
|
(chatId: string, messageId: string) => {
|
|
@@ -321,18 +386,20 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
321
386
|
isRunning,
|
|
322
387
|
messages: chatHelpers.messages,
|
|
323
388
|
joinStrategy,
|
|
324
|
-
metadata: useMemo(
|
|
389
|
+
metadata: useMemo<AISDKMessageConverterMetadata>(
|
|
325
390
|
() => ({
|
|
326
391
|
toolStatuses,
|
|
327
392
|
messageTiming,
|
|
328
393
|
toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
|
|
329
394
|
toolLastInputCache: toolLastInputCacheRef.current,
|
|
330
395
|
mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
|
|
396
|
+
supportsRichToolApprovalResponses,
|
|
331
397
|
...(optimisticMessageId && { optimisticMessageId }),
|
|
332
398
|
...(chatHelpers.error && {
|
|
333
399
|
error: toChatError(chatHelpers.error),
|
|
334
400
|
}),
|
|
335
401
|
...(cancelledMessageIds.size > 0 && { cancelledMessageIds }),
|
|
402
|
+
...(toolApprovalResponses.size > 0 && { toolApprovalResponses }),
|
|
336
403
|
}),
|
|
337
404
|
[
|
|
338
405
|
toolStatuses,
|
|
@@ -340,6 +407,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
340
407
|
optimisticMessageId,
|
|
341
408
|
chatHelpers.error,
|
|
342
409
|
cancelledMessageIds,
|
|
410
|
+
toolApprovalResponses,
|
|
411
|
+
supportsRichToolApprovalResponses,
|
|
343
412
|
],
|
|
344
413
|
),
|
|
345
414
|
});
|
|
@@ -347,13 +416,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
347
416
|
const exportedMessageRepository = useMemo(() => {
|
|
348
417
|
if (!messageRepository) return undefined;
|
|
349
418
|
const converted = toExportedMessageRepository(
|
|
350
|
-
|
|
351
|
-
messages: UI_MESSAGE[],
|
|
352
|
-
) => ThreadMessage[],
|
|
419
|
+
toThreadMessages,
|
|
353
420
|
messageRepository,
|
|
354
421
|
);
|
|
355
422
|
return converted.messages.length > 0 ? converted : undefined;
|
|
356
|
-
}, [messageRepository]);
|
|
423
|
+
}, [messageRepository, toThreadMessages]);
|
|
357
424
|
|
|
358
425
|
const generatedSuggestions = useGeneratedSuggestions(
|
|
359
426
|
suggestionAdapter,
|
|
@@ -370,9 +437,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
370
437
|
const { isLoading, deleteMessage: deleteHistoryMessage } = useExternalHistory(
|
|
371
438
|
runtimeRef,
|
|
372
439
|
adapters?.history ?? contextAdapters?.history,
|
|
373
|
-
|
|
374
|
-
messages: UI_MESSAGE[],
|
|
375
|
-
) => ThreadMessage[],
|
|
440
|
+
toThreadMessages,
|
|
376
441
|
aiSDKV6FormatAdapter as MessageFormatAdapter<
|
|
377
442
|
UI_MESSAGE,
|
|
378
443
|
AISDKStorageFormat
|
|
@@ -434,6 +499,69 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
434
499
|
});
|
|
435
500
|
};
|
|
436
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
|
+
|
|
437
565
|
const hasSeededRepositoryRef = useRef(false);
|
|
438
566
|
const shouldFeedRepository =
|
|
439
567
|
exportedMessageRepository != null &&
|
|
@@ -461,6 +589,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
461
589
|
.filter(Boolean)
|
|
462
590
|
.flat(),
|
|
463
591
|
),
|
|
592
|
+
onVoiceTranscript: (message: ThreadMessage) =>
|
|
593
|
+
chatHelpers.setMessages((current) => [
|
|
594
|
+
...current,
|
|
595
|
+
toVoiceTranscriptUIMessage<UI_MESSAGE>(message),
|
|
596
|
+
]),
|
|
464
597
|
onExportExternalState: (): MessageFormatRepository<UI_MESSAGE> => {
|
|
465
598
|
const exported = runtimeRef.current.thread.export();
|
|
466
599
|
|
|
@@ -501,10 +634,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
501
634
|
},
|
|
502
635
|
onLoadExternalState: (repo: MessageFormatRepository<UI_MESSAGE>) => {
|
|
503
636
|
// Convert MessageFormatRepository to ExportedMessageRepository
|
|
504
|
-
const exportedRepo = toExportedMessageRepository(
|
|
505
|
-
AISDKMessageConverter.toThreadMessages,
|
|
506
|
-
repo,
|
|
507
|
-
);
|
|
637
|
+
const exportedRepo = toExportedMessageRepository(toThreadMessages, repo);
|
|
508
638
|
|
|
509
639
|
// Import into the thread's MessageRepository
|
|
510
640
|
runtimeRef.current.thread.import(exportedRepo);
|
|
@@ -634,15 +764,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
634
764
|
);
|
|
635
765
|
}
|
|
636
766
|
},
|
|
637
|
-
onRespondToToolApproval:
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
id: approvalId,
|
|
641
|
-
approved,
|
|
642
|
-
...(reason != null && { reason }),
|
|
643
|
-
options: { metadata: lastRunConfigRef.current },
|
|
644
|
-
}),
|
|
645
|
-
),
|
|
767
|
+
onRespondToToolApproval: customOnRespondToToolApproval
|
|
768
|
+
? (response) => respondViaHost(customOnRespondToToolApproval, response)
|
|
769
|
+
: respondViaAISDK,
|
|
646
770
|
...pickExternalStoreSharedOptions(adapter),
|
|
647
771
|
...(adapter.unstable_messageRepositoryInstance && {
|
|
648
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) => ({
|
|
@@ -95,6 +100,51 @@ describe("useChatRuntime", () => {
|
|
|
95
100
|
window.sessionStorage.clear();
|
|
96
101
|
});
|
|
97
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
|
+
|
|
98
148
|
it("forwards a defined chat update throttle to useChat", () => {
|
|
99
149
|
mocks.useChat.mockReturnValue({
|
|
100
150
|
resumeStream: vi.fn(),
|
|
@@ -111,6 +161,24 @@ describe("useChatRuntime", () => {
|
|
|
111
161
|
expect(mocks.useChat.mock.calls[1]?.[0]).not.toHaveProperty("throttle");
|
|
112
162
|
});
|
|
113
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
|
+
|
|
114
182
|
it("waits for external history to load before resuming a stream", async () => {
|
|
115
183
|
mocks.state.isLoadingHistory = true;
|
|
116
184
|
const resumeStream = vi.fn().mockResolvedValue(undefined);
|
|
@@ -283,8 +351,8 @@ describe("useChatRuntime", () => {
|
|
|
283
351
|
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
284
352
|
status: "streaming",
|
|
285
353
|
};
|
|
286
|
-
mocks.useChat.mockImplementation(({
|
|
287
|
-
id === "thread-a" ? threadA : threadB,
|
|
354
|
+
mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
|
|
355
|
+
chat.id === "thread-a" ? threadA : threadB,
|
|
288
356
|
);
|
|
289
357
|
|
|
290
358
|
mocks.state.threadId = "thread-a";
|
|
@@ -321,8 +389,8 @@ describe("useChatRuntime", () => {
|
|
|
321
389
|
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
322
390
|
status: "ready",
|
|
323
391
|
};
|
|
324
|
-
mocks.useChat.mockImplementation(({
|
|
325
|
-
id === "__LOCALID_background" ? backgroundThread : mainThread,
|
|
392
|
+
mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
|
|
393
|
+
chat.id === "__LOCALID_background" ? backgroundThread : mainThread,
|
|
326
394
|
);
|
|
327
395
|
const transport = {
|
|
328
396
|
getResumableAdapter: () => ({
|