@assistant-ui/ai-sdk 0.0.2 → 0.0.4
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/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +23 -2
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/toCreateMessage.d.ts.map +1 -1
- package/dist/converters/toCreateMessage.js +6 -2
- package/dist/converters/toCreateMessage.js.map +1 -1
- package/dist/model-context/injectInteractableContext.d.ts +1 -1
- package/dist/model-context/injectInteractableContext.js +1 -1
- package/dist/model-context/injectInteractableContext.js.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +1 -1
- package/dist/model-context/injectQuoteContext.js +1 -1
- package/dist/model-context/injectQuoteContext.js.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKThreads.d.ts +10 -8
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +34 -27
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +18 -3
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +199 -73
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.js +1 -1
- package/dist/runtime/useChatThread.d.ts +14 -1
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +11 -4
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +52 -47
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.js +1 -1
- package/dist/usage.d.ts +1 -2
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +5 -7
- package/dist/usage.js.map +1 -1
- package/package.json +12 -12
- package/src/converters/convertMessage.test.ts +22 -0
- package/src/converters/convertMessage.ts +26 -2
- package/src/converters/toCreateMessage.ts +6 -5
- package/src/model-context/injectInteractableContext.ts +1 -1
- package/src/model-context/injectQuoteContext.ts +1 -1
- package/src/runtime/AISDKThreads.cloud.test.ts +11 -1
- package/src/runtime/AISDKThreads.test.ts +102 -0
- package/src/runtime/AISDKThreads.ts +16 -9
- package/src/runtime/useAISDKRuntime.denied-tool.test.tsx +122 -0
- package/src/runtime/useAISDKRuntime.test.ts +479 -10
- package/src/runtime/useAISDKRuntime.ts +199 -55
- package/src/runtime/useChatRuntime.integration.test.tsx +46 -0
- package/src/runtime/useChatRuntime.test.ts +16 -0
- package/src/runtime/useChatThread.ts +26 -0
- package/src/runtime/useExternalHistory.test.ts +143 -1
- package/src/runtime/useExternalHistory.ts +53 -56
- package/src/usage.test.ts +26 -8
- package/src/usage.ts +4 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/ai-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Vercel AI SDK adapter for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-sdk",
|
|
@@ -46,14 +46,14 @@
|
|
|
46
46
|
],
|
|
47
47
|
"sideEffects": false,
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@ai-sdk/mcp": "^2.0.
|
|
50
|
-
"@ai-sdk/react": "^4.0.
|
|
51
|
-
"@assistant-ui/core": "^0.3.
|
|
52
|
-
"@assistant-ui/store": "^0.3.
|
|
53
|
-
"@assistant-ui/tap": "^0.9.
|
|
54
|
-
"ai": "^7.0.
|
|
49
|
+
"@ai-sdk/mcp": "^2.0.41",
|
|
50
|
+
"@ai-sdk/react": "^4.0.88",
|
|
51
|
+
"@assistant-ui/core": "^0.3.17",
|
|
52
|
+
"@assistant-ui/store": "^0.3.12",
|
|
53
|
+
"@assistant-ui/tap": "^0.9.16",
|
|
54
|
+
"ai": "^7.0.85",
|
|
55
55
|
"assistant-cloud": "*",
|
|
56
|
-
"assistant-stream": "^0.3.
|
|
56
|
+
"assistant-stream": "^0.3.41"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"@types/react": "*",
|
|
@@ -68,14 +68,14 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@
|
|
71
|
+
"@assistant-ui/x-buildutils": "0.0.25",
|
|
72
|
+
"@testing-library/react": "^16.3.3",
|
|
72
73
|
"@types/json-schema": "^7.0.15",
|
|
73
74
|
"@types/react": "^19.2.18",
|
|
74
|
-
"@types/react-dom": "^19.2.
|
|
75
|
+
"@types/react-dom": "^19.2.5",
|
|
75
76
|
"jsdom": "^30.0.1",
|
|
76
77
|
"react": "^19.2.8",
|
|
77
|
-
"vitest": "^4.1.
|
|
78
|
-
"@assistant-ui/x-buildutils": "0.0.23"
|
|
78
|
+
"vitest": "^4.1.11"
|
|
79
79
|
},
|
|
80
80
|
"publishConfig": {
|
|
81
81
|
"access": "public",
|
|
@@ -26,6 +26,28 @@ describe("AISDKMessageConverter", () => {
|
|
|
26
26
|
expect(converted[1]?.metadata.isOptimistic).toBe(true);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
it("keeps metadata outside the thread shape reachable under custom", () => {
|
|
30
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
31
|
+
{
|
|
32
|
+
id: "a1",
|
|
33
|
+
role: "assistant",
|
|
34
|
+
parts: [{ type: "text", text: "yo" }],
|
|
35
|
+
metadata: {
|
|
36
|
+
usage: { inputTokens: 40, outputTokens: 2 },
|
|
37
|
+
modelId: "gpt-5.6-luna",
|
|
38
|
+
custom: { source: "route" },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
] as any);
|
|
42
|
+
|
|
43
|
+
expect(converted[0]?.metadata.custom).toEqual({
|
|
44
|
+
usage: { inputTokens: 40, outputTokens: 2 },
|
|
45
|
+
modelId: "gpt-5.6-luna",
|
|
46
|
+
source: "route",
|
|
47
|
+
});
|
|
48
|
+
expect(converted[0]?.metadata).not.toHaveProperty("usage");
|
|
49
|
+
});
|
|
50
|
+
|
|
29
51
|
it("does not flag messages when no optimistic id is provided", () => {
|
|
30
52
|
const converted = AISDKMessageConverter.toThreadMessages([
|
|
31
53
|
{ id: "a1", role: "assistant", parts: [{ type: "text", text: "yo" }] },
|
|
@@ -31,6 +31,30 @@ import {
|
|
|
31
31
|
import { unwrapModelContentEnvelope } from "./modelContentEnvelope";
|
|
32
32
|
|
|
33
33
|
type MessageMetadata = ThreadMessageLike["metadata"];
|
|
34
|
+
|
|
35
|
+
const THREAD_METADATA_KEYS = new Set([
|
|
36
|
+
"unstable_state",
|
|
37
|
+
"unstable_annotations",
|
|
38
|
+
"unstable_data",
|
|
39
|
+
"steps",
|
|
40
|
+
"timing",
|
|
41
|
+
"submittedFeedback",
|
|
42
|
+
"isOptimistic",
|
|
43
|
+
"custom",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const toThreadMetadata = (metadata: unknown): MessageMetadata => {
|
|
47
|
+
if (!metadata || typeof metadata !== "object") return undefined;
|
|
48
|
+
const result: Record<string, unknown> = {};
|
|
49
|
+
const extra: Record<string, unknown> = {};
|
|
50
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
51
|
+
(THREAD_METADATA_KEYS.has(key) ? result : extra)[key] = value;
|
|
52
|
+
}
|
|
53
|
+
if (Object.keys(extra).length > 0) {
|
|
54
|
+
result.custom = { ...extra, ...(result.custom as object | undefined) };
|
|
55
|
+
}
|
|
56
|
+
return result as MessageMetadata;
|
|
57
|
+
};
|
|
34
58
|
export type AISDKMessageConverterMetadata =
|
|
35
59
|
useExternalMessageConverter.Metadata & {
|
|
36
60
|
toolArgsKeyOrderCache?: Map<string, Map<string, string[]>>;
|
|
@@ -427,7 +451,7 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
|
|
|
427
451
|
status: { type: "complete" as const },
|
|
428
452
|
};
|
|
429
453
|
}),
|
|
430
|
-
metadata: message.metadata
|
|
454
|
+
metadata: toThreadMetadata(message.metadata),
|
|
431
455
|
};
|
|
432
456
|
|
|
433
457
|
case "system":
|
|
@@ -442,7 +466,7 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
|
|
|
442
466
|
createdAt,
|
|
443
467
|
content,
|
|
444
468
|
metadata: {
|
|
445
|
-
...(message.metadata
|
|
469
|
+
...toThreadMetadata(message.metadata),
|
|
446
470
|
...(timing && { timing }),
|
|
447
471
|
...(isOptimistic && { isOptimistic: true }),
|
|
448
472
|
},
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { AppendMessage } from "@assistant-ui/core";
|
|
2
2
|
import {
|
|
3
|
-
httpUrlPattern,
|
|
4
|
-
parseDataUrl,
|
|
5
3
|
resolveFileMediaType,
|
|
4
|
+
resolveFilePartSource,
|
|
6
5
|
resolveImageMediaType,
|
|
7
6
|
toMediaWireUrl,
|
|
8
7
|
} from "@assistant-ui/core/internal";
|
|
@@ -72,11 +71,13 @@ export const toCreateMessage = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
72
71
|
// envelope is rebuilt from the typed format rather than forwarded.
|
|
73
72
|
const mediaType = `audio/${part.audio.format}`;
|
|
74
73
|
const data = part.audio.data;
|
|
74
|
+
const source = resolveFilePartSource({ data, mimeType: mediaType });
|
|
75
75
|
return {
|
|
76
76
|
type: "file",
|
|
77
|
-
url:
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
url:
|
|
78
|
+
source.kind === "url"
|
|
79
|
+
? source.url
|
|
80
|
+
: `data:${mediaType};base64,${source.data}`,
|
|
80
81
|
mediaType,
|
|
81
82
|
...(part.filename && { filename: part.filename }),
|
|
82
83
|
};
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
* @example
|
|
24
24
|
* ```ts
|
|
25
25
|
* import { convertToModelMessages, streamText } from "ai";
|
|
26
|
-
* import { unstable_injectInteractableContext } from "@assistant-ui/
|
|
26
|
+
* import { unstable_injectInteractableContext } from "@assistant-ui/ai-sdk";
|
|
27
27
|
*
|
|
28
28
|
* export async function POST(req: Request) {
|
|
29
29
|
* const { messages } = await req.json();
|
|
@@ -22,7 +22,7 @@ const getQuoteText = (metadata: unknown): string | undefined => {
|
|
|
22
22
|
* @example
|
|
23
23
|
* ```ts
|
|
24
24
|
* import { convertToModelMessages, streamText } from "ai";
|
|
25
|
-
* import { injectQuoteContext } from "@assistant-ui/
|
|
25
|
+
* import { injectQuoteContext } from "@assistant-ui/ai-sdk";
|
|
26
26
|
*
|
|
27
27
|
* export async function POST(req: Request) {
|
|
28
28
|
* const { messages } = await req.json();
|
|
@@ -95,7 +95,7 @@ describe("AISDKThreads cloud", () => {
|
|
|
95
95
|
handle.destroy();
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
it("
|
|
98
|
+
it("keeps an in-flight cloud chat running across a switch and stops it on delete", async () => {
|
|
99
99
|
const chat = createCancellableTransport();
|
|
100
100
|
const handle = createAssistantClient(
|
|
101
101
|
AuiConfig({
|
|
@@ -126,6 +126,16 @@ describe("AISDKThreads cloud", () => {
|
|
|
126
126
|
await vi.waitFor(() => {
|
|
127
127
|
expect(handle.getClient().threads.getState().mainThreadId).toBe("t2");
|
|
128
128
|
});
|
|
129
|
+
expect(chat.getCancelCount()).toBe(0);
|
|
130
|
+
await vi.waitFor(() => {
|
|
131
|
+
expect(
|
|
132
|
+
handle.getClient().threads.item({ id: "t1" }).getState().isRunning,
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
flushTapSync(() =>
|
|
137
|
+
handle.getClient().threads.item({ id: "t1" }).delete(),
|
|
138
|
+
);
|
|
129
139
|
await vi.waitFor(() => {
|
|
130
140
|
expect(chat.getCancelCount()).toBe(1);
|
|
131
141
|
});
|
|
@@ -546,3 +546,105 @@ describe("AISDKThreads", () => {
|
|
|
546
546
|
}
|
|
547
547
|
});
|
|
548
548
|
});
|
|
549
|
+
|
|
550
|
+
describe("AISDKThreads branch isolation", () => {
|
|
551
|
+
const completeRound = async (
|
|
552
|
+
handle: ReturnType<typeof createAssistantClient>,
|
|
553
|
+
emit: (...chunks: never[]) => void,
|
|
554
|
+
close: () => void,
|
|
555
|
+
question: string,
|
|
556
|
+
answer: string,
|
|
557
|
+
) => {
|
|
558
|
+
const aui = handle.getClient();
|
|
559
|
+
flushTapSync(() => aui.composer.setText(question));
|
|
560
|
+
flushTapSync(() => aui.composer.send());
|
|
561
|
+
await vi.waitFor(() => {
|
|
562
|
+
expect(aui.thread.getState().messages.length).toBeGreaterThan(0);
|
|
563
|
+
});
|
|
564
|
+
emit(...(textReply(answer) as never[]));
|
|
565
|
+
close();
|
|
566
|
+
await vi.waitFor(() => {
|
|
567
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
it("keeps switching between populated threads free of cross-thread branches", async () => {
|
|
572
|
+
const { transport, emit, close } = createControlledTransport();
|
|
573
|
+
const handle = createAssistantClient(
|
|
574
|
+
AuiConfig({ threads: AISDKThreads({ transport }) }),
|
|
575
|
+
);
|
|
576
|
+
handle.subscribe(() => {});
|
|
577
|
+
const aui = handle.getClient();
|
|
578
|
+
|
|
579
|
+
await completeRound(
|
|
580
|
+
handle,
|
|
581
|
+
emit as never,
|
|
582
|
+
close,
|
|
583
|
+
"thread a question",
|
|
584
|
+
"thread a answer",
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
flushTapSync(() => aui.threads.switchToNewThread());
|
|
588
|
+
await completeRound(
|
|
589
|
+
handle,
|
|
590
|
+
emit as never,
|
|
591
|
+
close,
|
|
592
|
+
"thread b question",
|
|
593
|
+
"thread b answer",
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
flushTapSync(() => aui.threads.switchToThread("main"));
|
|
597
|
+
await vi.waitFor(() => {
|
|
598
|
+
expect(threadText(handle as never)).toEqual([
|
|
599
|
+
"thread a question",
|
|
600
|
+
"thread a answer",
|
|
601
|
+
]);
|
|
602
|
+
});
|
|
603
|
+
expect(aui.thread.message({ index: 0 }).getState().branchCount).toBe(1);
|
|
604
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(1);
|
|
605
|
+
|
|
606
|
+
handle.destroy();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("preserves intra-thread branches across a switch through an empty thread", async () => {
|
|
610
|
+
const { transport, emit, close } = createControlledTransport();
|
|
611
|
+
const handle = createAssistantClient(
|
|
612
|
+
AuiConfig({ threads: AISDKThreads({ transport }) }),
|
|
613
|
+
);
|
|
614
|
+
handle.subscribe(() => {});
|
|
615
|
+
const aui = handle.getClient();
|
|
616
|
+
|
|
617
|
+
await completeRound(
|
|
618
|
+
handle,
|
|
619
|
+
emit as never,
|
|
620
|
+
close,
|
|
621
|
+
"branchy question",
|
|
622
|
+
"first answer",
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
flushTapSync(() => aui.thread.message({ index: 1 }).reload());
|
|
626
|
+
await vi.waitFor(() => {
|
|
627
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(true);
|
|
628
|
+
});
|
|
629
|
+
emit(...(textReply("second answer") as never[]));
|
|
630
|
+
close();
|
|
631
|
+
await vi.waitFor(() => {
|
|
632
|
+
expect(handle.getClient().thread.getState().isRunning).toBe(false);
|
|
633
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
flushTapSync(() => aui.threads.switchToNewThread());
|
|
637
|
+
expect(handle.getClient().thread.getState().messages).toHaveLength(0);
|
|
638
|
+
|
|
639
|
+
flushTapSync(() => handle.getClient().threads.switchToThread("main"));
|
|
640
|
+
await vi.waitFor(() => {
|
|
641
|
+
expect(threadText(handle as never)).toEqual([
|
|
642
|
+
"branchy question",
|
|
643
|
+
"second answer",
|
|
644
|
+
]);
|
|
645
|
+
});
|
|
646
|
+
expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
|
|
647
|
+
|
|
648
|
+
handle.destroy();
|
|
649
|
+
});
|
|
650
|
+
});
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
useChatThread,
|
|
25
25
|
type ChatThreadOptions,
|
|
26
26
|
} from "./useChatThread";
|
|
27
|
+
import { MessageRepository } from "@assistant-ui/core/internal";
|
|
27
28
|
import { useResourceCleanup } from "./useResourceCleanup";
|
|
28
29
|
|
|
29
30
|
export type AISDKThreadsOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
@@ -41,9 +42,9 @@ export type AISDKThreadsOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
41
42
|
| undefined;
|
|
42
43
|
/**
|
|
43
44
|
* When set, the thread list is a `RemoteThreadList` backed by this
|
|
44
|
-
* assistant-cloud. Omit it to keep the in-memory list.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
45
|
+
* assistant-cloud. Omit it to keep the in-memory list. Every visited
|
|
46
|
+
* cloud thread stays mounted, so an in-flight run continues after a
|
|
47
|
+
* switch and stops on delete; per-thread history loads once per thread.
|
|
47
48
|
*/
|
|
48
49
|
cloud?: AssistantCloud | undefined;
|
|
49
50
|
/**
|
|
@@ -64,6 +65,7 @@ type AISDKThreadChatOptions<UI_MESSAGE extends UIMessage = UIMessage> = Omit<
|
|
|
64
65
|
type ChatEntry<UI_MESSAGE extends UIMessage> = {
|
|
65
66
|
chat: Chat<UI_MESSAGE>;
|
|
66
67
|
transport: ChatTransport<UI_MESSAGE>;
|
|
68
|
+
repository: MessageRepository;
|
|
67
69
|
};
|
|
68
70
|
|
|
69
71
|
const createChatEntry = <UI_MESSAGE extends UIMessage>(
|
|
@@ -84,6 +86,7 @@ const createChatEntry = <UI_MESSAGE extends UIMessage>(
|
|
|
84
86
|
return {
|
|
85
87
|
chat: new Chat<UI_MESSAGE>({ ...chatInit, id: threadId, transport }),
|
|
86
88
|
transport,
|
|
89
|
+
repository: new MessageRepository(),
|
|
87
90
|
};
|
|
88
91
|
};
|
|
89
92
|
|
|
@@ -113,7 +116,7 @@ const useAISDKChatThread = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
|
113
116
|
const [owned] = useState(() =>
|
|
114
117
|
cloud ? createChatEntry(threadId, options) : undefined,
|
|
115
118
|
);
|
|
116
|
-
const { chat, transport } =
|
|
119
|
+
const { chat, transport, repository } =
|
|
117
120
|
owned ?? getOrCreateChatEntry(threadId, options, chats);
|
|
118
121
|
|
|
119
122
|
useEffect(() => {
|
|
@@ -145,6 +148,7 @@ const useAISDKChatThread = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
|
145
148
|
: undefined
|
|
146
149
|
: fallbackItem,
|
|
147
150
|
chat,
|
|
151
|
+
messageRepositoryInstance: repository,
|
|
148
152
|
stopOnClientDestroy: cloud,
|
|
149
153
|
},
|
|
150
154
|
);
|
|
@@ -190,6 +194,7 @@ const useAISDKThreads = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
190
194
|
bindCloud
|
|
191
195
|
? RemoteThreadList({
|
|
192
196
|
adapter: cloudAdapter,
|
|
197
|
+
backgroundThreads: true,
|
|
193
198
|
thread,
|
|
194
199
|
threadId,
|
|
195
200
|
onThreadIdChange,
|
|
@@ -212,11 +217,13 @@ const useAISDKThreads = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
212
217
|
* per-thread orchestration as {@link AISDKChat} inside the client's own
|
|
213
218
|
* resource tree, so it works with any `AssistantClient` host, React or not.
|
|
214
219
|
* Without `cloud`, threads live in memory for the client's lifetime and keep
|
|
215
|
-
* their history across switches;
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
+
* their history across switches; only the visible thread is mounted, and a
|
|
221
|
+
* switched-away chat keeps streaming into its stored state until it settles
|
|
222
|
+
* or the thread is deleted. With `cloud`, the list is a
|
|
223
|
+
* `RemoteThreadList` with `backgroundThreads`: every visited thread stays
|
|
224
|
+
* mounted with its own history, a run continues after a switch and stops on
|
|
225
|
+
* delete, and a freshly created thread titles itself. Model context is
|
|
226
|
+
* registered on every mounted thread.
|
|
220
227
|
*/
|
|
221
228
|
export const AISDKThreads = resource(useAISDKThreads);
|
|
222
229
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import type { ExternalStoreAdapter } from "@assistant-ui/core";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({
|
|
8
|
+
adapter: undefined as ExternalStoreAdapter | undefined,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("@assistant-ui/core/react", async (importOriginal) => {
|
|
12
|
+
const original =
|
|
13
|
+
await importOriginal<typeof import("@assistant-ui/core/react")>();
|
|
14
|
+
return {
|
|
15
|
+
...original,
|
|
16
|
+
useExternalStoreRuntime: vi.fn((adapter: ExternalStoreAdapter) => {
|
|
17
|
+
mocks.adapter = adapter;
|
|
18
|
+
return {};
|
|
19
|
+
}),
|
|
20
|
+
useRuntimeAdapters: vi.fn(() => ({})),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock("./useExternalHistory", async (importOriginal) => {
|
|
25
|
+
const original =
|
|
26
|
+
await importOriginal<typeof import("./useExternalHistory")>();
|
|
27
|
+
return {
|
|
28
|
+
...original,
|
|
29
|
+
useExternalHistory: vi.fn(() => ({
|
|
30
|
+
isLoading: false,
|
|
31
|
+
deleteMessage: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
import { useAISDKRuntime } from "./useAISDKRuntime";
|
|
37
|
+
|
|
38
|
+
describe("useAISDKRuntime pending tool completion", () => {
|
|
39
|
+
const setup = (toolPart: Record<string, unknown>) => {
|
|
40
|
+
const messages = [
|
|
41
|
+
{
|
|
42
|
+
id: "m1",
|
|
43
|
+
role: "assistant",
|
|
44
|
+
parts: [toolPart],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
let updated: any[] | undefined;
|
|
48
|
+
const setMessages = vi.fn(
|
|
49
|
+
(updater: (m: typeof messages) => typeof messages) => {
|
|
50
|
+
updated = typeof updater === "function" ? updater(messages) : updater;
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
const chat = {
|
|
54
|
+
id: "chat-1",
|
|
55
|
+
status: "ready",
|
|
56
|
+
error: undefined,
|
|
57
|
+
messages,
|
|
58
|
+
setMessages,
|
|
59
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
60
|
+
regenerate: vi.fn(),
|
|
61
|
+
addToolOutput: vi.fn(),
|
|
62
|
+
addToolApprovalResponse: vi.fn(),
|
|
63
|
+
stop: vi.fn(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
renderHook(() => useAISDKRuntime(chat as never));
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
send: () =>
|
|
70
|
+
mocks.adapter!.onNew!({
|
|
71
|
+
role: "user",
|
|
72
|
+
parentId: "m1",
|
|
73
|
+
sourceId: null,
|
|
74
|
+
runConfig: {},
|
|
75
|
+
content: [{ type: "text", text: "next" }],
|
|
76
|
+
attachments: [],
|
|
77
|
+
createdAt: new Date(0),
|
|
78
|
+
metadata: { custom: {} },
|
|
79
|
+
} as never),
|
|
80
|
+
getUpdated: () => updated,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
it("preserves a denied tool call and its approval record on the next send", async () => {
|
|
85
|
+
const { send, getUpdated } = setup({
|
|
86
|
+
type: "tool-search",
|
|
87
|
+
toolCallId: "call_1",
|
|
88
|
+
state: "output-denied",
|
|
89
|
+
input: { query: "x" },
|
|
90
|
+
approval: { id: "appr_1", approved: false, reason: "too dangerous" },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await send();
|
|
94
|
+
|
|
95
|
+
const updated = getUpdated();
|
|
96
|
+
const part = updated?.at(-1)?.parts?.[0];
|
|
97
|
+
expect(part?.state).toBe("output-denied");
|
|
98
|
+
expect(part?.approval).toEqual({
|
|
99
|
+
id: "appr_1",
|
|
100
|
+
approved: false,
|
|
101
|
+
reason: "too dangerous",
|
|
102
|
+
});
|
|
103
|
+
expect(part?.errorText).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("still cancels a genuinely pending tool call on the next send", async () => {
|
|
107
|
+
const { send, getUpdated } = setup({
|
|
108
|
+
type: "tool-search",
|
|
109
|
+
toolCallId: "call_1",
|
|
110
|
+
state: "input-available",
|
|
111
|
+
input: { query: "x" },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await send();
|
|
115
|
+
|
|
116
|
+
const part = getUpdated()?.at(-1)?.parts?.[0];
|
|
117
|
+
expect(part?.state).toBe("output-error");
|
|
118
|
+
expect(part?.errorText).toBe(
|
|
119
|
+
"User cancelled tool call by sending a new message.",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|