@assistant-ui/ai-sdk 0.0.7 → 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 +1 -3
- 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 +4 -6
- package/dist/aiSDKExtras.d.ts.map +1 -1
- package/dist/converters/convertMessage.d.ts +32 -24
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +43 -9
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/modelContentEnvelope.d.ts +6 -7
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
- package/dist/converters/toCreateMessage.d.ts +2 -4
- package/dist/converters/toCreateMessage.d.ts.map +1 -1
- package/dist/converters/toolOutputConversion.d.ts +20 -22
- package/dist/converters/toolOutputConversion.d.ts.map +1 -1
- package/dist/hooks.d.ts +2 -4
- 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 +2 -4
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +1 -3
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.d.ts +2 -4
- package/dist/runtime/AISDKChat.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKThreads.d.ts +29 -31
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +22 -23
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/sdkIdentity.d.ts +1 -3
- package/dist/runtime/sdkIdentity.d.ts.map +1 -1
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +69 -70
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +112 -56
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts +6 -8
- 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 +94 -89
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +4 -5
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts +7 -7
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +229 -59
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.d.ts +1 -3
- 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 +3 -5
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
- package/dist/runtime/useStreamingTiming.js +1 -1
- package/dist/tools/frontendTools.d.ts +21 -23
- package/dist/tools/frontendTools.d.ts.map +1 -1
- package/dist/tools/generativeTools.d.ts +24 -26
- 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 -4
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.d.ts +14 -16
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
- package/dist/transport/resumable.d.ts +25 -27
- package/dist/transport/resumable.d.ts.map +1 -1
- package/dist/usage.d.ts +8 -10
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js.map +1 -1
- package/dist/utils/getVercelAIMessages.d.ts +2 -4
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
- package/dist/utils/sliceMessagesUntil.d.ts +1 -3
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/package.json +9 -8
- package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
- package/src/adapters/vercelAttachmentAdapter.ts +2 -2
- package/src/converters/convertMessage.test.ts +478 -3
- package/src/converters/convertMessage.ts +100 -14
- package/src/index.native.ts +1 -1
- package/src/index.ts +1 -1
- package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
- package/src/runtime/AISDKThreads.test.ts +79 -46
- package/src/runtime/AISDKThreads.ts +1 -1
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +51 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +225 -9
- package/src/runtime/useAISDKRuntime.test.ts +275 -5
- package/src/runtime/useAISDKRuntime.ts +141 -37
- package/src/runtime/useAISDKRuntime.voice.test.tsx +50 -1
- package/src/runtime/useChatRuntime.integration.test.tsx +56 -0
- package/src/runtime/useChatRuntime.test.ts +5 -1
- package/src/runtime/useChatRuntime.ts +6 -2
- package/src/runtime/useChatThread.transport.test.tsx +149 -0
- package/src/runtime/useChatThread.ts +33 -5
- package/src/runtime/useExternalHistory.test.ts +943 -3
- package/src/runtime/useExternalHistory.ts +366 -14
- package/src/runtime/useResourceCleanup.ts +41 -15
- package/src/usage.ts +1 -1
|
@@ -38,12 +38,14 @@ import type {
|
|
|
38
38
|
RunConfig,
|
|
39
39
|
McpAppMetadata,
|
|
40
40
|
RespondToToolApprovalOptions,
|
|
41
|
+
Unstable_ToolInteractionLog,
|
|
41
42
|
} from "@assistant-ui/core";
|
|
42
43
|
import {
|
|
43
44
|
getExternalStoreMessages,
|
|
44
45
|
pickExternalStoreSharedOptions,
|
|
45
46
|
} from "@assistant-ui/core";
|
|
46
47
|
import {
|
|
48
|
+
appendToolInteraction,
|
|
47
49
|
consumeSuggestionResult,
|
|
48
50
|
MessageRepository,
|
|
49
51
|
} from "@assistant-ui/core/internal";
|
|
@@ -95,7 +97,7 @@ const toVoiceTranscriptUIMessage = <UI_MESSAGE extends UIMessage>(
|
|
|
95
97
|
.filter((part) => part.type === "text")
|
|
96
98
|
.map((part) => ({ type: "text", text: part.text })),
|
|
97
99
|
metadata: {
|
|
98
|
-
modality:
|
|
100
|
+
...(message.metadata.modality && { modality: message.metadata.modality }),
|
|
99
101
|
...(Object.keys(message.metadata.custom).length > 0 && {
|
|
100
102
|
custom: message.metadata.custom,
|
|
101
103
|
}),
|
|
@@ -139,9 +141,9 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
139
141
|
/**
|
|
140
142
|
* Answers tool approval requests through a host-owned channel instead of the AI SDK's `addToolApprovalResponse`.
|
|
141
143
|
*
|
|
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,
|
|
144
|
+
* 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. With a history adapter, the answer is stored with its message once the handler resolves and returns on reload, and a second response to the same request rejects; without one it lasts as long as this runtime, and a runtime mounted again over the same chat shows the request open until the resumed run records its resolution in the chat.
|
|
143
145
|
*
|
|
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.
|
|
146
|
+
* While a handler is set, an approval's `display`, `allowFreeform`, `dismissible` and `options` reach the renderer, because the handler can receive answers the AI SDK cannot carry. A stream declares them through the `approvalDescriptor` of its `tool-approval-request` chunk, the one approval field the AI SDK keeps opaque; the converter reads the request and answer fields from that descriptor when the approval itself lacks them.
|
|
145
147
|
*/
|
|
146
148
|
onRespondToToolApproval?:
|
|
147
149
|
| ((
|
|
@@ -304,15 +306,33 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
304
306
|
const [toolApprovalResponses, setToolApprovalResponses] = useState<
|
|
305
307
|
ReadonlyMap<string, RespondToToolApprovalOptions>
|
|
306
308
|
>(NO_TOOL_APPROVAL_RESPONSES);
|
|
309
|
+
const [toolArtifactEpoch, setToolArtifactEpoch] = useState(0);
|
|
310
|
+
const [toolInteractionEpoch, setToolInteractionEpoch] = useState(0);
|
|
307
311
|
const hostApprovalIdsRef = useRef(new Set<string>());
|
|
312
|
+
const toolApprovalResponsesRef = useRef<
|
|
313
|
+
Map<string, RespondToToolApprovalOptions>
|
|
314
|
+
>(new Map());
|
|
308
315
|
const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
|
|
309
316
|
new Map(),
|
|
310
317
|
);
|
|
311
318
|
const toolLastInputCacheRef = useRef<Map<string, ReadonlyJSONObject>>(
|
|
312
319
|
new Map(),
|
|
313
320
|
);
|
|
321
|
+
const toolArgsTextCacheRef = useRef<
|
|
322
|
+
WeakMap<ReadonlyJSONObject, Map<string, string>>
|
|
323
|
+
>(new WeakMap());
|
|
314
324
|
const mcpAppMetadataCacheRef = useRef<Map<string, McpAppMetadata>>(new Map());
|
|
325
|
+
const toolArtifactsRef = useRef<Map<string, unknown>>(new Map());
|
|
326
|
+
const toolInteractionsRef = useRef<Map<string, Unstable_ToolInteractionLog>>(
|
|
327
|
+
new Map(),
|
|
328
|
+
);
|
|
315
329
|
const lastRunConfigRef = useRef<RunConfig | undefined>(undefined);
|
|
330
|
+
const markToolArtifactsChanged = useCallback(() => {
|
|
331
|
+
setToolArtifactEpoch((epoch) => epoch + 1);
|
|
332
|
+
}, []);
|
|
333
|
+
const markToolInteractionsChanged = useCallback(() => {
|
|
334
|
+
setToolInteractionEpoch((epoch) => epoch + 1);
|
|
335
|
+
}, []);
|
|
316
336
|
|
|
317
337
|
const hasExecutingTools = Object.values(toolStatuses).some(
|
|
318
338
|
(s) => s?.type === "executing",
|
|
@@ -341,6 +361,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
341
361
|
(sourceMessages: UI_MESSAGE[]) => {
|
|
342
362
|
const metadata: AISDKMessageConverterMetadata = {
|
|
343
363
|
supportsRichToolApprovalResponses,
|
|
364
|
+
toolArtifacts: toolArtifactsRef.current,
|
|
365
|
+
toolInteractions: toolInteractionsRef.current,
|
|
366
|
+
toolApprovalResponses: toolApprovalResponsesRef.current,
|
|
344
367
|
};
|
|
345
368
|
return AISDKMessageConverter.toThreadMessages(
|
|
346
369
|
sourceMessages,
|
|
@@ -391,8 +414,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
391
414
|
toolStatuses,
|
|
392
415
|
messageTiming,
|
|
393
416
|
toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
|
|
417
|
+
toolArgsTextCache: toolArgsTextCacheRef.current,
|
|
394
418
|
toolLastInputCache: toolLastInputCacheRef.current,
|
|
395
419
|
mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
|
|
420
|
+
toolArtifacts: toolArtifactsRef.current,
|
|
421
|
+
toolInteractions: toolInteractionsRef.current,
|
|
396
422
|
supportsRichToolApprovalResponses,
|
|
397
423
|
...(optimisticMessageId && { optimisticMessageId }),
|
|
398
424
|
...(chatHelpers.error && {
|
|
@@ -409,6 +435,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
409
435
|
cancelledMessageIds,
|
|
410
436
|
toolApprovalResponses,
|
|
411
437
|
supportsRichToolApprovalResponses,
|
|
438
|
+
toolArtifactEpoch,
|
|
439
|
+
toolInteractionEpoch,
|
|
412
440
|
],
|
|
413
441
|
),
|
|
414
442
|
});
|
|
@@ -434,7 +462,12 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
434
462
|
},
|
|
435
463
|
}));
|
|
436
464
|
|
|
437
|
-
const {
|
|
465
|
+
const {
|
|
466
|
+
isLoading,
|
|
467
|
+
deleteMessage: deleteHistoryMessage,
|
|
468
|
+
persistToolInteractions,
|
|
469
|
+
persistToolApprovalResponses,
|
|
470
|
+
} = useExternalHistory(
|
|
438
471
|
runtimeRef,
|
|
439
472
|
adapters?.history ?? contextAdapters?.history,
|
|
440
473
|
toThreadMessages,
|
|
@@ -445,6 +478,17 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
445
478
|
(messages) => {
|
|
446
479
|
chatHelpers.setMessages(messages);
|
|
447
480
|
},
|
|
481
|
+
toolArtifactsRef.current,
|
|
482
|
+
markToolArtifactsChanged,
|
|
483
|
+
toolInteractionsRef.current,
|
|
484
|
+
markToolInteractionsChanged,
|
|
485
|
+
toolApprovalResponsesRef.current,
|
|
486
|
+
() => {
|
|
487
|
+
hostApprovalIdsRef.current = new Set(
|
|
488
|
+
toolApprovalResponsesRef.current.keys(),
|
|
489
|
+
);
|
|
490
|
+
setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
|
|
491
|
+
},
|
|
448
492
|
);
|
|
449
493
|
|
|
450
494
|
const {
|
|
@@ -467,35 +511,43 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
467
511
|
if (!cancelPendingToolCallsOnSend) return;
|
|
468
512
|
|
|
469
513
|
// The runtime auto-aborts in-flight tool invocations when a new run
|
|
470
|
-
// is dispatched (append() / startRun())
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
//
|
|
514
|
+
// is dispatched (append() / startRun()), so this only has to mark the
|
|
515
|
+
// abandoned tools cancelled in the UI message list. Every non-terminal
|
|
516
|
+
// tool call qualifies wherever it sits: the run that produced it is over,
|
|
517
|
+
// and a staged `startRun: false` message can sit between it and the tail.
|
|
518
|
+
// Uses setMessages to avoid triggering sendAutomaticallyWhen.
|
|
474
519
|
chatHelpers.setMessages((messages) => {
|
|
475
|
-
const lastMessage = messages.at(-1);
|
|
476
|
-
if (lastMessage?.role !== "assistant") return messages;
|
|
477
|
-
|
|
478
520
|
let hasChanges = false;
|
|
479
|
-
const parts = lastMessage.parts?.map((part) => {
|
|
480
|
-
if (!isToolUIPart(part)) return part;
|
|
481
|
-
if (
|
|
482
|
-
part.state === "output-available" ||
|
|
483
|
-
part.state === "output-error" ||
|
|
484
|
-
part.state === "output-denied"
|
|
485
|
-
)
|
|
486
|
-
return part;
|
|
487
521
|
|
|
522
|
+
const next = messages.map((message) => {
|
|
523
|
+
if (message.role !== "assistant") return message;
|
|
524
|
+
|
|
525
|
+
let messageChanged = false;
|
|
526
|
+
const parts = message.parts?.map((part) => {
|
|
527
|
+
if (!isToolUIPart(part)) return part;
|
|
528
|
+
if (
|
|
529
|
+
part.state === "output-available" ||
|
|
530
|
+
part.state === "output-error" ||
|
|
531
|
+
part.state === "output-denied"
|
|
532
|
+
)
|
|
533
|
+
return part;
|
|
534
|
+
|
|
535
|
+
messageChanged = true;
|
|
536
|
+
const { approval: _approval, ...rest } = part;
|
|
537
|
+
return {
|
|
538
|
+
...rest,
|
|
539
|
+
state: "output-error" as const,
|
|
540
|
+
errorText: "User cancelled tool call by sending a new message.",
|
|
541
|
+
};
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (!messageChanged) return message;
|
|
488
545
|
hasChanges = true;
|
|
489
|
-
|
|
490
|
-
return {
|
|
491
|
-
...rest,
|
|
492
|
-
state: "output-error" as const,
|
|
493
|
-
errorText: "User cancelled tool call by sending a new message.",
|
|
494
|
-
};
|
|
546
|
+
return { ...message, parts };
|
|
495
547
|
});
|
|
496
548
|
|
|
497
549
|
if (!hasChanges) return messages;
|
|
498
|
-
return
|
|
550
|
+
return next;
|
|
499
551
|
});
|
|
500
552
|
};
|
|
501
553
|
|
|
@@ -519,10 +571,13 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
519
571
|
) => {
|
|
520
572
|
const { approvalId } = response;
|
|
521
573
|
const requested = chatHelpers.messages
|
|
522
|
-
.flatMap((message) =>
|
|
523
|
-
|
|
574
|
+
.flatMap((message) =>
|
|
575
|
+
message.parts.flatMap((part) =>
|
|
576
|
+
isToolUIPart(part) ? [{ messageId: message.id, part }] : [],
|
|
577
|
+
),
|
|
578
|
+
)
|
|
524
579
|
.find(
|
|
525
|
-
(part) =>
|
|
580
|
+
({ part }) =>
|
|
526
581
|
part.state === "approval-requested" &&
|
|
527
582
|
part.approval.id === approvalId,
|
|
528
583
|
);
|
|
@@ -535,19 +590,16 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
535
590
|
const applyResponse = (applied: boolean) => {
|
|
536
591
|
if (applied) hostApprovalIdsRef.current.add(approvalId);
|
|
537
592
|
else hostApprovalIdsRef.current.delete(approvalId);
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
else responses.delete(approvalId);
|
|
542
|
-
return responses;
|
|
543
|
-
});
|
|
593
|
+
if (applied) toolApprovalResponsesRef.current.set(approvalId, response);
|
|
594
|
+
else toolApprovalResponsesRef.current.delete(approvalId);
|
|
595
|
+
setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
|
|
544
596
|
};
|
|
545
597
|
|
|
546
598
|
applyResponse(true);
|
|
547
599
|
try {
|
|
548
600
|
await onRespond(response, {
|
|
549
|
-
toolCallId: requested.toolCallId,
|
|
550
|
-
toolName: getToolName(requested),
|
|
601
|
+
toolCallId: requested.part.toolCallId,
|
|
602
|
+
toolName: getToolName(requested.part),
|
|
551
603
|
respondViaAISDK: async () => {
|
|
552
604
|
try {
|
|
553
605
|
await respondViaAISDK(response);
|
|
@@ -560,6 +612,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
560
612
|
if (hostApprovalIdsRef.current.has(approvalId)) applyResponse(false);
|
|
561
613
|
throw error;
|
|
562
614
|
}
|
|
615
|
+
if (hostApprovalIdsRef.current.has(approvalId)) {
|
|
616
|
+
await persistToolApprovalResponses(requested.messageId);
|
|
617
|
+
}
|
|
563
618
|
};
|
|
564
619
|
|
|
565
620
|
const hasSeededRepositoryRef = useRef(false);
|
|
@@ -569,6 +624,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
569
624
|
messages.length === 0;
|
|
570
625
|
|
|
571
626
|
const runtime = useExternalStoreRuntime({
|
|
627
|
+
unstable_persistsHistory: true,
|
|
572
628
|
isRunning: providerIsRunning,
|
|
573
629
|
...(shouldFeedRepository
|
|
574
630
|
? { messageRepository: exportedMessageRepository }
|
|
@@ -714,6 +770,34 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
714
770
|
|
|
715
771
|
await deleteHistoryMessage(messageId);
|
|
716
772
|
|
|
773
|
+
let removedToolArtifact = false;
|
|
774
|
+
let removedToolInteractions = false;
|
|
775
|
+
let removedToolApprovalResponse = false;
|
|
776
|
+
let removedHostApprovalId = false;
|
|
777
|
+
for (const part of threadMessages[messageIndex]!.content) {
|
|
778
|
+
if (part.type === "tool-call") {
|
|
779
|
+
removedToolArtifact =
|
|
780
|
+
toolArtifactsRef.current.delete(part.toolCallId) ||
|
|
781
|
+
removedToolArtifact;
|
|
782
|
+
removedToolInteractions =
|
|
783
|
+
toolInteractionsRef.current.delete(part.toolCallId) ||
|
|
784
|
+
removedToolInteractions;
|
|
785
|
+
if (part.approval) {
|
|
786
|
+
removedToolApprovalResponse =
|
|
787
|
+
toolApprovalResponsesRef.current.delete(part.approval.id) ||
|
|
788
|
+
removedToolApprovalResponse;
|
|
789
|
+
removedHostApprovalId =
|
|
790
|
+
hostApprovalIdsRef.current.delete(part.approval.id) ||
|
|
791
|
+
removedHostApprovalId;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (removedToolArtifact) markToolArtifactsChanged();
|
|
796
|
+
if (removedToolInteractions) markToolInteractionsChanged();
|
|
797
|
+
if (removedToolApprovalResponse || removedHostApprovalId) {
|
|
798
|
+
setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
|
|
799
|
+
}
|
|
800
|
+
|
|
717
801
|
const deleteIds = new Set(
|
|
718
802
|
getExternalStoreMessages<UI_MESSAGE>(threadMessages[messageIndex]!).map(
|
|
719
803
|
(message) => message.id,
|
|
@@ -735,8 +819,13 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
735
819
|
toolName,
|
|
736
820
|
result,
|
|
737
821
|
isError,
|
|
822
|
+
artifact,
|
|
738
823
|
modelContent,
|
|
739
824
|
}) => {
|
|
825
|
+
if (artifact !== undefined) {
|
|
826
|
+
toolArtifactsRef.current.set(toolCallId, artifact);
|
|
827
|
+
markToolArtifactsChanged();
|
|
828
|
+
}
|
|
740
829
|
const options = { metadata: lastRunConfigRef.current };
|
|
741
830
|
if (isError) {
|
|
742
831
|
return Promise.resolve(
|
|
@@ -767,6 +856,21 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
767
856
|
onRespondToToolApproval: customOnRespondToToolApproval
|
|
768
857
|
? (response) => respondViaHost(customOnRespondToToolApproval, response)
|
|
769
858
|
: respondViaAISDK,
|
|
859
|
+
unstable_onRecordToolInteraction: ({
|
|
860
|
+
messageId,
|
|
861
|
+
toolCallId,
|
|
862
|
+
interaction,
|
|
863
|
+
}) => {
|
|
864
|
+
toolInteractionsRef.current.set(
|
|
865
|
+
toolCallId,
|
|
866
|
+
appendToolInteraction(
|
|
867
|
+
toolInteractionsRef.current.get(toolCallId),
|
|
868
|
+
interaction,
|
|
869
|
+
),
|
|
870
|
+
);
|
|
871
|
+
markToolInteractionsChanged();
|
|
872
|
+
return persistToolInteractions(messageId);
|
|
873
|
+
},
|
|
770
874
|
...pickExternalStoreSharedOptions(adapter),
|
|
771
875
|
...(adapter.unstable_messageRepositoryInstance && {
|
|
772
876
|
unstable_messageRepositoryInstance:
|
|
@@ -36,7 +36,9 @@ const createChatHelpers = (initialMessages: UIMessage[] = []) => {
|
|
|
36
36
|
return chatHelpers;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
const createVoiceAdapter = (
|
|
39
|
+
const createVoiceAdapter = ({
|
|
40
|
+
sendText,
|
|
41
|
+
}: { sendText?: RealtimeVoiceAdapter.Session["sendText"] } = {}) => {
|
|
40
42
|
let transcriptCallback:
|
|
41
43
|
| ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
|
|
42
44
|
| undefined;
|
|
@@ -46,6 +48,7 @@ const createVoiceAdapter = () => {
|
|
|
46
48
|
disconnect: vi.fn(),
|
|
47
49
|
mute: vi.fn(),
|
|
48
50
|
unmute: vi.fn(),
|
|
51
|
+
...(sendText && { sendText }),
|
|
49
52
|
onStatusChange: () => () => {},
|
|
50
53
|
onTranscript: (callback) => {
|
|
51
54
|
transcriptCallback = callback;
|
|
@@ -267,4 +270,50 @@ describe("useAISDKRuntime voice transcripts", () => {
|
|
|
267
270
|
message: transcript,
|
|
268
271
|
});
|
|
269
272
|
});
|
|
273
|
+
|
|
274
|
+
it("persists a message typed into the session as a typed turn", async () => {
|
|
275
|
+
const chat = createChatHelpers();
|
|
276
|
+
const sendText = vi.fn(async (_text: string) => {});
|
|
277
|
+
const voice = createVoiceAdapter({ sendText });
|
|
278
|
+
const history = createHistoryAdapter();
|
|
279
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
280
|
+
voice: voice.adapter,
|
|
281
|
+
history: history.adapter,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
act(() => {
|
|
285
|
+
result.current.thread.connectVoice();
|
|
286
|
+
});
|
|
287
|
+
await act(async () => {
|
|
288
|
+
await result.current.thread.append({
|
|
289
|
+
role: "user",
|
|
290
|
+
content: [{ type: "text", text: "Typed" }],
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(sendText).toHaveBeenCalledExactlyOnceWith("Typed");
|
|
295
|
+
expect(chat.sendMessage).not.toHaveBeenCalled();
|
|
296
|
+
const typed = chat.messages[0];
|
|
297
|
+
expect(typed).toEqual({
|
|
298
|
+
id: expect.any(String),
|
|
299
|
+
role: "user",
|
|
300
|
+
parts: [{ type: "text", text: "Typed" }],
|
|
301
|
+
metadata: {},
|
|
302
|
+
});
|
|
303
|
+
rerender();
|
|
304
|
+
|
|
305
|
+
await waitFor(() => {
|
|
306
|
+
const messages = result.current.thread
|
|
307
|
+
.getState()
|
|
308
|
+
.messages.filter((message) => message.id === typed.id);
|
|
309
|
+
expect(messages).toHaveLength(1);
|
|
310
|
+
expect(messages[0]?.metadata.modality).toBeUndefined();
|
|
311
|
+
expect(textOf(messages[0]!)).toBe("Typed");
|
|
312
|
+
});
|
|
313
|
+
await waitFor(() => expect(history.append).toHaveBeenCalledTimes(1));
|
|
314
|
+
expect(history.append).toHaveBeenCalledWith({
|
|
315
|
+
parentId: null,
|
|
316
|
+
message: typed,
|
|
317
|
+
});
|
|
318
|
+
});
|
|
270
319
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
+
import { getEventListeners } from "node:events";
|
|
3
4
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
4
5
|
import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
|
|
5
6
|
import { AuiConfig, AuiProvider, useAuiState } from "@assistant-ui/store";
|
|
7
|
+
import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
|
|
6
8
|
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
7
9
|
import { AISDKChat } from "./AISDKChat";
|
|
8
10
|
import type { ChatTransport, UIMessage } from "ai";
|
|
@@ -183,6 +185,60 @@ describe("useChatRuntime integration", () => {
|
|
|
183
185
|
view.unmount();
|
|
184
186
|
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
185
187
|
});
|
|
188
|
+
|
|
189
|
+
it("aborts a nested runtime when only its own component unmounts", async () => {
|
|
190
|
+
const outer = createCancellableTransport();
|
|
191
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
192
|
+
let nested: AssistantRuntime | undefined;
|
|
193
|
+
let providerSignal: AbortSignal | undefined;
|
|
194
|
+
let setVisible: ((visible: boolean) => void) | undefined;
|
|
195
|
+
|
|
196
|
+
const NestedChat = () => {
|
|
197
|
+
providerSignal = useAssistantClientDestroySignal();
|
|
198
|
+
nested = useChatRuntime({ transport });
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
const Shell = () => {
|
|
202
|
+
const [visible, set] = useState(true);
|
|
203
|
+
setVisible = set;
|
|
204
|
+
return (
|
|
205
|
+
<AuiProvider
|
|
206
|
+
config={AuiConfig({
|
|
207
|
+
threads: AISDKChat({ transport: outer.transport }),
|
|
208
|
+
})}
|
|
209
|
+
>
|
|
210
|
+
{visible && <NestedChat />}
|
|
211
|
+
</AuiProvider>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
const listeners = () => getEventListeners(providerSignal!, "abort").length;
|
|
215
|
+
|
|
216
|
+
const view = render(<Shell />);
|
|
217
|
+
await waitFor(() => expect(nested).toBeDefined());
|
|
218
|
+
const mounted = listeners();
|
|
219
|
+
|
|
220
|
+
for (const cycle of [1, 2]) {
|
|
221
|
+
await act(async () => {
|
|
222
|
+
await nested!.thread.append(`stream ${cycle}`);
|
|
223
|
+
});
|
|
224
|
+
await waitFor(() =>
|
|
225
|
+
expect(nested!.thread.getState().isRunning).toBe(true),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await act(async () => setVisible?.(false));
|
|
229
|
+
await waitFor(() => expect(getCancelCount()).toBe(cycle));
|
|
230
|
+
expect(listeners()).toBe(mounted - 1);
|
|
231
|
+
|
|
232
|
+
nested = undefined;
|
|
233
|
+
await act(async () => setVisible?.(true));
|
|
234
|
+
await waitFor(() => expect(nested).toBeDefined());
|
|
235
|
+
expect(listeners()).toBe(mounted);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
view.unmount();
|
|
239
|
+
await act(nextTask);
|
|
240
|
+
expect(getCancelCount()).toBe(2);
|
|
241
|
+
});
|
|
186
242
|
});
|
|
187
243
|
|
|
188
244
|
const StreamingApp = ({
|
|
@@ -52,7 +52,11 @@ const mocks = vi.hoisted(() => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
vi.mock("@ai-sdk/react", () => ({
|
|
55
|
-
useChat:
|
|
55
|
+
useChat: (...args: unknown[]) => {
|
|
56
|
+
const chat = mocks.useChat(...args);
|
|
57
|
+
if (chat) chat.stop ??= vi.fn(async () => {});
|
|
58
|
+
return chat;
|
|
59
|
+
},
|
|
56
60
|
Chat: class MockChat {
|
|
57
61
|
constructor(config: unknown) {
|
|
58
62
|
Object.assign(this, config);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useRemoteThreadListRuntime,
|
|
9
9
|
} from "@assistant-ui/core/react";
|
|
10
10
|
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
11
|
+
import { useHostDestroySignal } from "@assistant-ui/store/internal";
|
|
11
12
|
import { useChatThread, type ChatThreadOptions } from "./useChatThread";
|
|
12
13
|
import { AI_SDK_SDK } from "./sdkIdentity";
|
|
13
14
|
|
|
@@ -18,7 +19,8 @@ export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
21
|
-
options
|
|
22
|
+
options: ChatThreadOptions<UI_MESSAGE> | undefined,
|
|
23
|
+
hostDestroySignal: AbortSignal,
|
|
22
24
|
): AssistantRuntime => {
|
|
23
25
|
const id = useAuiState((s) => s.threadListItem.id);
|
|
24
26
|
const isMainThread = useAuiState(
|
|
@@ -31,6 +33,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
31
33
|
getThreadListItem: () =>
|
|
32
34
|
aui.threadListItem.source ? aui.threadListItem : undefined,
|
|
33
35
|
stopOnClientDestroy: true,
|
|
36
|
+
hostDestroySignal,
|
|
34
37
|
});
|
|
35
38
|
};
|
|
36
39
|
|
|
@@ -39,10 +42,11 @@ export const useChatRuntime = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
|
39
42
|
onThreadIdChange,
|
|
40
43
|
...options
|
|
41
44
|
}: UseChatRuntimeOptions<UI_MESSAGE> = {}): AssistantRuntime => {
|
|
45
|
+
const hostDestroySignal = useHostDestroySignal();
|
|
42
46
|
const cloudAdapter = useCloudThreadListAdapter({ cloud, sdk: AI_SDK_SDK });
|
|
43
47
|
return useRemoteThreadListRuntime({
|
|
44
48
|
runtimeHook: function RuntimeHook() {
|
|
45
|
-
return useChatThreadRuntime(options);
|
|
49
|
+
return useChatThreadRuntime(options, hostDestroySignal);
|
|
46
50
|
},
|
|
47
51
|
adapter: cloudAdapter,
|
|
48
52
|
allowNesting: true,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Chat } from "@ai-sdk/react";
|
|
7
|
+
import {
|
|
8
|
+
AssistantChatTransport,
|
|
9
|
+
type InitializableThreadListItem,
|
|
10
|
+
} from "../transport/AssistantChatTransport";
|
|
11
|
+
import { useChatThread } from "./useChatThread";
|
|
12
|
+
|
|
13
|
+
const itemFor = (remoteId: string) => ({
|
|
14
|
+
initialize: async () => ({ remoteId, externalId: undefined }),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const finishedStream = () =>
|
|
18
|
+
new Response(
|
|
19
|
+
new ReadableStream<Uint8Array>({
|
|
20
|
+
start(controller) {
|
|
21
|
+
controller.enqueue(
|
|
22
|
+
new TextEncoder().encode(
|
|
23
|
+
'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
controller.close();
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
describe("useChatThread shared transport isolation", () => {
|
|
33
|
+
it("gives each thread its own clone wired to its own thread-list item", async () => {
|
|
34
|
+
const transport = new AssistantChatTransport({ api: "/api/chat" });
|
|
35
|
+
const setRuntime = vi.spyOn(transport, "setRuntime");
|
|
36
|
+
const setGetItem = vi.spyOn(transport, "__internal_setGetThreadListItem");
|
|
37
|
+
const clones: AssistantChatTransport<never>[] = [];
|
|
38
|
+
const getters: (() => InitializableThreadListItem | undefined)[] = [];
|
|
39
|
+
const realClone = transport.__internal_clone.bind(transport);
|
|
40
|
+
vi.spyOn(transport, "__internal_clone").mockImplementation(() => {
|
|
41
|
+
const clone = realClone();
|
|
42
|
+
const index = clones.push(clone as AssistantChatTransport<never>) - 1;
|
|
43
|
+
vi.spyOn(clone, "__internal_setGetThreadListItem").mockImplementation(
|
|
44
|
+
(getter) => {
|
|
45
|
+
getters[index] = getter;
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
return clone;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
renderHook(() =>
|
|
52
|
+
useChatThread(
|
|
53
|
+
{ transport },
|
|
54
|
+
{
|
|
55
|
+
id: "thread-a",
|
|
56
|
+
isMainThread: true,
|
|
57
|
+
getThreadListItem: () => itemFor("remote-a"),
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
renderHook(() =>
|
|
62
|
+
useChatThread(
|
|
63
|
+
{ transport },
|
|
64
|
+
{
|
|
65
|
+
id: "thread-b",
|
|
66
|
+
isMainThread: false,
|
|
67
|
+
getThreadListItem: () => itemFor("remote-b"),
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(setRuntime).not.toHaveBeenCalled();
|
|
73
|
+
expect(setGetItem).not.toHaveBeenCalled();
|
|
74
|
+
expect(clones).toHaveLength(2);
|
|
75
|
+
expect(clones[0]).not.toBe(clones[1]);
|
|
76
|
+
|
|
77
|
+
const remoteIdOf = async (
|
|
78
|
+
getter?: () => InitializableThreadListItem | undefined,
|
|
79
|
+
) => (await getter?.()?.initialize())?.remoteId;
|
|
80
|
+
expect(await remoteIdOf(getters[0])).toBe("remote-a");
|
|
81
|
+
expect(await remoteIdOf(getters[1])).toBe("remote-b");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sends each thread's request with that thread's remoteId and model context", async () => {
|
|
85
|
+
const sent: { id: unknown; system: unknown }[] = [];
|
|
86
|
+
const transport = new AssistantChatTransport({
|
|
87
|
+
api: "/api/chat",
|
|
88
|
+
fetch: async (_input, init) => {
|
|
89
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
90
|
+
sent.push({ id: body["id"], system: body["system"] });
|
|
91
|
+
return finishedStream();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const mount = (id: string, remoteId: string, system: string) =>
|
|
96
|
+
renderHook(() => {
|
|
97
|
+
const runtime = useChatThread(
|
|
98
|
+
{ transport },
|
|
99
|
+
{
|
|
100
|
+
id,
|
|
101
|
+
isMainThread: id === "thread-a",
|
|
102
|
+
getThreadListItem: () => itemFor(remoteId),
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
useEffect(
|
|
106
|
+
() =>
|
|
107
|
+
runtime.registerModelContextProvider({
|
|
108
|
+
getModelContext: () => ({ system }),
|
|
109
|
+
}),
|
|
110
|
+
[runtime],
|
|
111
|
+
);
|
|
112
|
+
return runtime;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const threadA = mount("thread-a", "remote-a", "system-a");
|
|
116
|
+
const threadB = mount("thread-b", "remote-b", "system-b");
|
|
117
|
+
|
|
118
|
+
threadA.rerender();
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
threadB.result.current.thread.append("hello");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await waitFor(() => expect(sent).toHaveLength(1));
|
|
125
|
+
expect(sent[0]).toEqual({ id: "remote-b", system: "system-b" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("uses the supplied instance when the caller owns the chat", () => {
|
|
129
|
+
const transport = new AssistantChatTransport({ api: "/api/chat" });
|
|
130
|
+
const clone = vi.spyOn(transport, "__internal_clone");
|
|
131
|
+
const chat = new Chat({ id: "thread-a", transport });
|
|
132
|
+
|
|
133
|
+
renderHook(() =>
|
|
134
|
+
useChatThread(
|
|
135
|
+
{ transport },
|
|
136
|
+
{
|
|
137
|
+
id: "thread-a",
|
|
138
|
+
isMainThread: true,
|
|
139
|
+
getThreadListItem: () => itemFor("remote-a"),
|
|
140
|
+
chat,
|
|
141
|
+
},
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// A caller-owned chat is already bound to its transport, so cloning again
|
|
146
|
+
// would wire an instance the chat never sends through.
|
|
147
|
+
expect(clone).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|