@assistant-ui/ai-sdk 0.0.4 → 0.0.5
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/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +8 -4
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts.map +1 -1
- package/dist/runtime/useChatRuntime.js +2 -1
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/tools/generativeTools.js +7 -4
- package/dist/tools/generativeTools.js.map +1 -1
- package/dist/usage.d.ts +8 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +8 -0
- package/dist/usage.js.map +1 -1
- package/package.json +10 -10
- package/src/converters/convertMessage.test.ts +102 -0
- package/src/converters/convertMessage.ts +38 -18
- package/src/runtime/AISDKChat.integration.test.tsx +48 -27
- package/src/runtime/__tests__/controlled-transport.ts +21 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +137 -4
- package/src/runtime/useChatRuntime.ts +1 -0
- package/src/runtime/useExternalHistory.ts +3 -0
- package/src/tools/generativeTools.test.ts +79 -0
- package/src/tools/generativeTools.ts +7 -8
- package/src/usage.ts +8 -0
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.5",
|
|
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.45",
|
|
50
|
+
"@ai-sdk/react": "^4.0.96",
|
|
51
|
+
"@assistant-ui/core": "^0.3.18",
|
|
52
|
+
"@assistant-ui/store": "^0.3.13",
|
|
53
|
+
"@assistant-ui/tap": "^0.9.17",
|
|
54
|
+
"ai": "^7.0.93",
|
|
55
55
|
"assistant-cloud": "*",
|
|
56
|
-
"assistant-stream": "^0.3.
|
|
56
|
+
"assistant-stream": "^0.3.42"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"@types/react": "*",
|
|
@@ -68,11 +68,11 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
71
|
+
"@assistant-ui/x-buildutils": "0.0.26",
|
|
72
72
|
"@testing-library/react": "^16.3.3",
|
|
73
73
|
"@types/json-schema": "^7.0.15",
|
|
74
74
|
"@types/react": "^19.2.18",
|
|
75
|
-
"@types/react-dom": "^19.2.
|
|
75
|
+
"@types/react-dom": "^19.2.7",
|
|
76
76
|
"jsdom": "^30.0.1",
|
|
77
77
|
"react": "^19.2.8",
|
|
78
78
|
"vitest": "^4.1.11"
|
|
@@ -394,6 +394,108 @@ describe("AISDKMessageConverter", () => {
|
|
|
394
394
|
});
|
|
395
395
|
});
|
|
396
396
|
|
|
397
|
+
it("preserves producer-defined approval fields and gives prompt precedence", () => {
|
|
398
|
+
const descriptor = { scope: "account:deploy" };
|
|
399
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
400
|
+
{
|
|
401
|
+
id: "a1",
|
|
402
|
+
role: "assistant",
|
|
403
|
+
parts: [
|
|
404
|
+
{
|
|
405
|
+
type: "tool-deploy",
|
|
406
|
+
toolCallId: "tc-1",
|
|
407
|
+
state: "approval-responded",
|
|
408
|
+
input: { environment: "production" },
|
|
409
|
+
approval: {
|
|
410
|
+
id: "approval-1",
|
|
411
|
+
approved: true,
|
|
412
|
+
reason: "approved by operator",
|
|
413
|
+
prompt: "Deploy to production?",
|
|
414
|
+
descriptor,
|
|
415
|
+
requestReason: "Production access requires approval",
|
|
416
|
+
signature: "signed-approval",
|
|
417
|
+
futureField: "preserved",
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
} as any,
|
|
422
|
+
]);
|
|
423
|
+
|
|
424
|
+
const toolCall = converted[0]?.content.find(
|
|
425
|
+
(part): part is any => part.type === "tool-call",
|
|
426
|
+
);
|
|
427
|
+
expect(toolCall?.approval).toEqual({
|
|
428
|
+
id: "approval-1",
|
|
429
|
+
approved: true,
|
|
430
|
+
reason: "approved by operator",
|
|
431
|
+
prompt: "Deploy to production?",
|
|
432
|
+
descriptor,
|
|
433
|
+
requestReason: "Production access requires approval",
|
|
434
|
+
signature: "signed-approval",
|
|
435
|
+
futureField: "preserved",
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("drops fields the AI SDK cannot answer and uses requestReason as the prompt", () => {
|
|
440
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
441
|
+
{
|
|
442
|
+
id: "a1",
|
|
443
|
+
role: "assistant",
|
|
444
|
+
parts: [
|
|
445
|
+
{
|
|
446
|
+
type: "tool-deploy",
|
|
447
|
+
toolCallId: "tc-1",
|
|
448
|
+
state: "approval-requested",
|
|
449
|
+
input: {},
|
|
450
|
+
approval: {
|
|
451
|
+
id: "approval-1",
|
|
452
|
+
display: "select",
|
|
453
|
+
allowFreeform: true,
|
|
454
|
+
options: [{ id: "once", kind: "allow-once" }],
|
|
455
|
+
optionId: "once",
|
|
456
|
+
text: "an answer",
|
|
457
|
+
resolution: "cancelled",
|
|
458
|
+
requestReason: "kept",
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
} as any,
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
const toolCall = converted[0]?.content.find(
|
|
466
|
+
(part): part is any => part.type === "tool-call",
|
|
467
|
+
);
|
|
468
|
+
expect(toolCall?.approval).toEqual({
|
|
469
|
+
id: "approval-1",
|
|
470
|
+
prompt: "kept",
|
|
471
|
+
resolution: "cancelled",
|
|
472
|
+
requestReason: "kept",
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("drops a resolution the core contract does not declare", () => {
|
|
477
|
+
const converted = AISDKMessageConverter.toThreadMessages([
|
|
478
|
+
{
|
|
479
|
+
id: "a1",
|
|
480
|
+
role: "assistant",
|
|
481
|
+
parts: [
|
|
482
|
+
{
|
|
483
|
+
type: "tool-deploy",
|
|
484
|
+
toolCallId: "tc-1",
|
|
485
|
+
state: "approval-requested",
|
|
486
|
+
input: {},
|
|
487
|
+
approval: { id: "approval-1", resolution: "whatever" },
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
} as any,
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
const toolCall = converted[0]?.content.find(
|
|
494
|
+
(part): part is any => part.type === "tool-call",
|
|
495
|
+
);
|
|
496
|
+
expect(toolCall?.approval).toEqual({ id: "approval-1" });
|
|
497
|
+
});
|
|
498
|
+
|
|
397
499
|
it("strips closing delimiters from streaming tool argsText", () => {
|
|
398
500
|
const converted = AISDKMessageConverter.toThreadMessages([
|
|
399
501
|
{
|
|
@@ -154,30 +154,50 @@ function extractMcpAppMetadata(
|
|
|
154
154
|
|
|
155
155
|
function getToolApprovalAndInterrupt(
|
|
156
156
|
part: {
|
|
157
|
-
approval?:
|
|
158
|
-
| {
|
|
159
|
-
id: string;
|
|
160
|
-
approved?: boolean;
|
|
161
|
-
reason?: string;
|
|
162
|
-
isAutomatic?: boolean;
|
|
163
|
-
}
|
|
164
|
-
| undefined;
|
|
157
|
+
approval?: Record<string, unknown> | undefined;
|
|
165
158
|
},
|
|
166
159
|
toolStatus: { type: string; payload?: unknown } | undefined,
|
|
167
160
|
): {
|
|
168
161
|
approval?: NonNullable<ToolCallMessagePart["approval"]>;
|
|
169
162
|
interrupt?: NonNullable<ToolCallMessagePart["interrupt"]>;
|
|
170
163
|
} {
|
|
171
|
-
if (part.approval
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
164
|
+
if (part.approval) {
|
|
165
|
+
// The AI SDK sends only id, approved and reason back to the server, so a
|
|
166
|
+
// request shape promising any other answer would render controls whose
|
|
167
|
+
// response cannot travel.
|
|
168
|
+
const {
|
|
169
|
+
id,
|
|
170
|
+
prompt,
|
|
171
|
+
approved,
|
|
172
|
+
reason,
|
|
173
|
+
isAutomatic,
|
|
174
|
+
resolution,
|
|
175
|
+
display,
|
|
176
|
+
allowFreeform,
|
|
177
|
+
options,
|
|
178
|
+
optionId,
|
|
179
|
+
text,
|
|
180
|
+
...additionalApprovalFields
|
|
181
|
+
} = part.approval;
|
|
182
|
+
const requestReason = additionalApprovalFields.requestReason;
|
|
183
|
+
if (typeof id === "string")
|
|
184
|
+
return {
|
|
185
|
+
approval: {
|
|
186
|
+
...additionalApprovalFields,
|
|
187
|
+
id,
|
|
188
|
+
...(typeof prompt === "string"
|
|
189
|
+
? { prompt }
|
|
190
|
+
: typeof requestReason === "string"
|
|
191
|
+
? { prompt: requestReason }
|
|
192
|
+
: {}),
|
|
193
|
+
...(typeof approved === "boolean" && { approved }),
|
|
194
|
+
...(typeof reason === "string" && { reason }),
|
|
195
|
+
...(isAutomatic === true && { isAutomatic: true }),
|
|
196
|
+
...((resolution === "cancelled" || resolution === "expired") && {
|
|
197
|
+
resolution,
|
|
198
|
+
}),
|
|
199
|
+
} as NonNullable<ToolCallMessagePart["approval"]>,
|
|
200
|
+
};
|
|
181
201
|
}
|
|
182
202
|
|
|
183
203
|
if (toolStatus?.type === "interrupt") {
|
|
@@ -1,47 +1,68 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { render, waitFor } from "@testing-library/react";
|
|
5
|
-
import { StrictMode } from "react";
|
|
3
|
+
import { StrictMode, type ReactNode } from "react";
|
|
4
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
6
5
|
import { AuiConfig, AuiProvider, useAui } from "@assistant-ui/store";
|
|
7
|
-
import {
|
|
6
|
+
import type { ChatTransport, UIMessage } from "ai";
|
|
8
7
|
import { describe, expect, it } from "vitest";
|
|
9
8
|
import { AISDKChat } from "./AISDKChat";
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createCancellableTransport,
|
|
11
|
+
createStreamHarness,
|
|
12
|
+
} from "./__tests__/controlled-transport";
|
|
11
13
|
|
|
12
14
|
describe("AISDKChat React integration", () => {
|
|
13
|
-
it("
|
|
14
|
-
const { transport, getCancelCount
|
|
15
|
-
|
|
16
|
-
let isRunning = () => false;
|
|
17
|
-
|
|
18
|
-
const SendOnMount = () => {
|
|
19
|
-
const aui = useAui();
|
|
20
|
-
isRunning = () => aui.thread.getState().isRunning;
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
if (started) return;
|
|
23
|
-
started = true;
|
|
24
|
-
flushTapSync(() => aui.composer.setText("keep streaming"));
|
|
25
|
-
flushTapSync(() => aui.composer.send());
|
|
26
|
-
}, [aui]);
|
|
27
|
-
return null;
|
|
28
|
-
};
|
|
15
|
+
it("aborts the in-flight transport after a real unmount", async () => {
|
|
16
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
17
|
+
const { Probe, send, isRunning } = createStreamHarness();
|
|
29
18
|
|
|
30
19
|
const view = render(
|
|
31
20
|
<StrictMode>
|
|
32
21
|
<AuiProvider config={AuiConfig({ threads: AISDKChat({ transport }) })}>
|
|
33
|
-
<
|
|
22
|
+
<Probe />
|
|
34
23
|
</AuiProvider>
|
|
35
24
|
</StrictMode>,
|
|
36
25
|
);
|
|
37
26
|
|
|
38
|
-
await
|
|
39
|
-
|
|
40
|
-
|
|
27
|
+
await act(async () => send());
|
|
28
|
+
await waitFor(() => expect(isRunning()).toBe(true));
|
|
29
|
+
// the Strict Mode double mount already ran a host cleanup by now
|
|
30
|
+
expect(getCancelCount()).toBe(0);
|
|
41
31
|
|
|
42
32
|
view.unmount();
|
|
43
|
-
await
|
|
33
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("AISDKChat legacy useAui host integration", () => {
|
|
38
|
+
const LegacyProvider = ({
|
|
39
|
+
transport,
|
|
40
|
+
children,
|
|
41
|
+
}: {
|
|
42
|
+
transport: ChatTransport<UIMessage>;
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
}) => {
|
|
45
|
+
const aui = useAui(AuiConfig({ threads: AISDKChat({ transport }) }));
|
|
46
|
+
return <AuiProvider value={aui}>{children}</AuiProvider>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
it("aborts the in-flight transport after a real unmount", async () => {
|
|
50
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
51
|
+
const { Probe, send, isRunning } = createStreamHarness();
|
|
52
|
+
|
|
53
|
+
const view = render(
|
|
54
|
+
<StrictMode>
|
|
55
|
+
<LegacyProvider transport={transport}>
|
|
56
|
+
<Probe />
|
|
57
|
+
</LegacyProvider>
|
|
58
|
+
</StrictMode>,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
await act(async () => send());
|
|
62
|
+
await waitFor(() => expect(isRunning()).toBe(true));
|
|
44
63
|
expect(getCancelCount()).toBe(0);
|
|
45
|
-
|
|
64
|
+
|
|
65
|
+
view.unmount();
|
|
66
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
46
67
|
});
|
|
47
68
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ChatTransport, UIMessage, UIMessageChunk } from "ai";
|
|
2
|
+
import { useAui, type AssistantClient } from "@assistant-ui/store";
|
|
3
|
+
import { flushTapSync } from "@assistant-ui/tap";
|
|
2
4
|
|
|
3
5
|
export const createControlledTransport = () => {
|
|
4
6
|
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
|
|
@@ -41,3 +43,22 @@ export const createCancellableTransport = () => {
|
|
|
41
43
|
close: () => controller.close(),
|
|
42
44
|
};
|
|
43
45
|
};
|
|
46
|
+
|
|
47
|
+
export const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
48
|
+
|
|
49
|
+
export const createStreamHarness = () => {
|
|
50
|
+
let aui: AssistantClient | undefined;
|
|
51
|
+
const Probe = () => {
|
|
52
|
+
aui = useAui();
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
Probe,
|
|
57
|
+
send: () => {
|
|
58
|
+
flushTapSync(() => aui!.composer.setText("keep streaming"));
|
|
59
|
+
flushTapSync(() => aui!.composer.send());
|
|
60
|
+
},
|
|
61
|
+
isRunning: () => aui?.thread.getState().isRunning === true,
|
|
62
|
+
client: () => aui!,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
4
4
|
import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
|
|
5
|
-
import { useAuiState } from "@assistant-ui/store";
|
|
6
|
-
import type {
|
|
7
|
-
import {
|
|
5
|
+
import { AuiConfig, AuiProvider, useAuiState } from "@assistant-ui/store";
|
|
6
|
+
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
7
|
+
import { AISDKChat } from "./AISDKChat";
|
|
8
|
+
import type { ChatTransport, UIMessage } from "ai";
|
|
9
|
+
import { Activity, StrictMode, useState, type ReactNode } from "react";
|
|
8
10
|
import { describe, expect, it } from "vitest";
|
|
9
11
|
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
12
|
+
import {
|
|
13
|
+
createCancellableTransport,
|
|
14
|
+
createStreamHarness,
|
|
15
|
+
nextTask,
|
|
16
|
+
} from "./__tests__/controlled-transport";
|
|
10
17
|
import { useChatRuntime } from "./useChatRuntime";
|
|
11
18
|
import { useThreadTokenUsage } from "../usage";
|
|
12
19
|
|
|
@@ -65,8 +72,134 @@ describe("useChatRuntime integration", () => {
|
|
|
65
72
|
);
|
|
66
73
|
});
|
|
67
74
|
});
|
|
75
|
+
|
|
76
|
+
it("aborts a deleted thread's stream while the host stays mounted", async () => {
|
|
77
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
78
|
+
const { Probe, send, isRunning, client } = createStreamHarness();
|
|
79
|
+
|
|
80
|
+
const view = render(
|
|
81
|
+
<StrictMode>
|
|
82
|
+
<StreamingApp transport={transport} probe={<Probe />} />
|
|
83
|
+
</StrictMode>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
await act(async () => send());
|
|
87
|
+
await waitFor(() => expect(isRunning()).toBe(true));
|
|
88
|
+
|
|
89
|
+
await act(async () => client().threadListItem.delete());
|
|
90
|
+
|
|
91
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
92
|
+
view.unmount();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("aborts the in-flight transport after a real unmount", async () => {
|
|
96
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
97
|
+
const { Probe, send, isRunning } = createStreamHarness();
|
|
98
|
+
|
|
99
|
+
const view = render(
|
|
100
|
+
<StrictMode>
|
|
101
|
+
<StreamingApp transport={transport} probe={<Probe />} />
|
|
102
|
+
</StrictMode>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await act(async () => send());
|
|
106
|
+
await waitFor(() => expect(isRunning()).toBe(true));
|
|
107
|
+
// the Strict Mode double mount already ran a host cleanup by now
|
|
108
|
+
expect(getCancelCount()).toBe(0);
|
|
109
|
+
|
|
110
|
+
view.unmount();
|
|
111
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("keeps streaming while hidden and aborts when the hidden host unmounts", async () => {
|
|
115
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
116
|
+
const { Probe, send, isRunning } = createStreamHarness();
|
|
117
|
+
|
|
118
|
+
let setMode: ((mode: "visible" | "hidden") => void) | undefined;
|
|
119
|
+
const Shell = () => {
|
|
120
|
+
const [mode, set] = useState<"visible" | "hidden">("visible");
|
|
121
|
+
setMode = set;
|
|
122
|
+
return (
|
|
123
|
+
<Activity mode={mode}>
|
|
124
|
+
<StreamingApp transport={transport} probe={<Probe />} />
|
|
125
|
+
</Activity>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const view = render(
|
|
130
|
+
<StrictMode>
|
|
131
|
+
<Shell />
|
|
132
|
+
</StrictMode>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await act(async () => send());
|
|
136
|
+
await waitFor(() => expect(isRunning()).toBe(true));
|
|
137
|
+
|
|
138
|
+
await act(async () => setMode?.("hidden"));
|
|
139
|
+
await act(nextTask);
|
|
140
|
+
expect(getCancelCount()).toBe(0);
|
|
141
|
+
expect(isRunning()).toBe(true);
|
|
142
|
+
|
|
143
|
+
await act(async () => setMode?.("visible"));
|
|
144
|
+
await act(nextTask);
|
|
145
|
+
expect(getCancelCount()).toBe(0);
|
|
146
|
+
expect(isRunning()).toBe(true);
|
|
147
|
+
|
|
148
|
+
await act(async () => setMode?.("hidden"));
|
|
149
|
+
view.unmount();
|
|
150
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("aborts a nested runtime's stream when the provider above it unmounts", async () => {
|
|
154
|
+
const outer = createCancellableTransport();
|
|
155
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
156
|
+
let nested: AssistantRuntime | undefined;
|
|
157
|
+
|
|
158
|
+
// allowNesting: the inner useChatRuntime runs its thread hook directly, as
|
|
159
|
+
// a plain React hook under the provider rather than inside a tap resource.
|
|
160
|
+
const NestedChat = () => {
|
|
161
|
+
nested = useChatRuntime({ transport });
|
|
162
|
+
return null;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const view = render(
|
|
166
|
+
<StrictMode>
|
|
167
|
+
<AuiProvider
|
|
168
|
+
config={AuiConfig({
|
|
169
|
+
threads: AISDKChat({ transport: outer.transport }),
|
|
170
|
+
})}
|
|
171
|
+
>
|
|
172
|
+
<NestedChat />
|
|
173
|
+
</AuiProvider>
|
|
174
|
+
</StrictMode>,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await waitFor(() => expect(nested).toBeDefined());
|
|
178
|
+
await act(async () => {
|
|
179
|
+
await nested!.thread.append("keep streaming");
|
|
180
|
+
});
|
|
181
|
+
await waitFor(() => expect(nested!.thread.getState().isRunning).toBe(true));
|
|
182
|
+
|
|
183
|
+
view.unmount();
|
|
184
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
185
|
+
});
|
|
68
186
|
});
|
|
69
187
|
|
|
188
|
+
const StreamingApp = ({
|
|
189
|
+
transport,
|
|
190
|
+
probe,
|
|
191
|
+
}: {
|
|
192
|
+
transport: ChatTransport<UIMessage>;
|
|
193
|
+
probe: ReactNode;
|
|
194
|
+
}) => {
|
|
195
|
+
const runtime = useChatRuntime({ transport });
|
|
196
|
+
return (
|
|
197
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
198
|
+
{probe}
|
|
199
|
+
</AssistantRuntimeProvider>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
70
203
|
const UsageProbe = () => {
|
|
71
204
|
const usage = useThreadTokenUsage();
|
|
72
205
|
return (
|
|
@@ -143,6 +143,9 @@ export const useExternalHistory = <TMessage>(
|
|
|
143
143
|
|
|
144
144
|
const remoteId = optionalThreadListItem()?.getState().remoteId;
|
|
145
145
|
if (!remoteId) {
|
|
146
|
+
// History loads asynchronously against the thread list item; without a
|
|
147
|
+
// remote id there is nothing to await, so the flag settles here.
|
|
148
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
146
149
|
setHasLoaded(true);
|
|
147
150
|
return aui.subscribe(() => {
|
|
148
151
|
if (optionalThreadListItem()?.getState().remoteId) {
|
|
@@ -139,6 +139,30 @@ describe("AISDKToolkit", () => {
|
|
|
139
139
|
mocks.createMCPClient.mockReset();
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
it("preserves prototype-named MCP tools", async () => {
|
|
143
|
+
const prototypeTool = { inputSchema: {} };
|
|
144
|
+
mocks.tools.mockResolvedValue(
|
|
145
|
+
Object.fromEntries([["__proto__", prototypeTool]]),
|
|
146
|
+
);
|
|
147
|
+
mocks.createMCPClient.mockResolvedValue({
|
|
148
|
+
tools: mocks.tools,
|
|
149
|
+
close: mocks.close,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const toolkit = new AISDKToolkit({
|
|
153
|
+
toolkit: {
|
|
154
|
+
docs: {
|
|
155
|
+
type: "mcp",
|
|
156
|
+
server: { type: "http", url: "http://localhost:3001/mcp" },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const tools = await toolkit.tools();
|
|
162
|
+
expect(Object.hasOwn(tools, "__proto__")).toBe(true);
|
|
163
|
+
expect(tools["__proto__"]).toBe(prototypeTool);
|
|
164
|
+
});
|
|
165
|
+
|
|
142
166
|
it("loads MCP tools through pooled clients", async () => {
|
|
143
167
|
mocks.tools.mockResolvedValue({ echo: { inputSchema: {} } });
|
|
144
168
|
mocks.createMCPClient.mockResolvedValue({
|
|
@@ -454,6 +478,61 @@ describe("AISDKToolkit", () => {
|
|
|
454
478
|
}
|
|
455
479
|
});
|
|
456
480
|
|
|
481
|
+
it("does not evict a replacement client after an older listing timeout", async () => {
|
|
482
|
+
vi.useFakeTimers();
|
|
483
|
+
const oldClient = {
|
|
484
|
+
tools: vi.fn(() => never()),
|
|
485
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
486
|
+
};
|
|
487
|
+
const replacementClient = {
|
|
488
|
+
tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
|
|
489
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
490
|
+
};
|
|
491
|
+
mocks.createMCPClient
|
|
492
|
+
.mockResolvedValueOnce(oldClient)
|
|
493
|
+
.mockResolvedValue(replacementClient);
|
|
494
|
+
|
|
495
|
+
const toolkit = new AISDKToolkit({
|
|
496
|
+
toolkit: {
|
|
497
|
+
docs: {
|
|
498
|
+
type: "mcp",
|
|
499
|
+
server: {
|
|
500
|
+
type: "http",
|
|
501
|
+
url: "http://localhost:3001/mcp",
|
|
502
|
+
connectionTimeout: 100,
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
const first = toolkit.tools();
|
|
510
|
+
const firstRejection = expect(first).rejects.toThrow(
|
|
511
|
+
/timed out while listing tools/,
|
|
512
|
+
);
|
|
513
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
514
|
+
|
|
515
|
+
const second = toolkit.tools();
|
|
516
|
+
const secondRejection = expect(second).rejects.toThrow(
|
|
517
|
+
/timed out while listing tools/,
|
|
518
|
+
);
|
|
519
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
520
|
+
await firstRejection;
|
|
521
|
+
|
|
522
|
+
await expect(toolkit.tools()).resolves.toHaveProperty("echo");
|
|
523
|
+
expect(mocks.createMCPClient).toHaveBeenCalledTimes(2);
|
|
524
|
+
|
|
525
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
526
|
+
await secondRejection;
|
|
527
|
+
|
|
528
|
+
await expect(toolkit.tools()).resolves.toHaveProperty("echo");
|
|
529
|
+
expect(mocks.createMCPClient).toHaveBeenCalledTimes(2);
|
|
530
|
+
expect(oldClient.close).toHaveBeenCalledTimes(1);
|
|
531
|
+
} finally {
|
|
532
|
+
vi.useRealTimers();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
457
536
|
it("includes the MCP toolkit entry name when listing tools fails", async () => {
|
|
458
537
|
const error = new Error("list failed");
|
|
459
538
|
mocks.tools.mockRejectedValue(error);
|
|
@@ -227,11 +227,8 @@ export class AISDKToolkit {
|
|
|
227
227
|
)
|
|
228
228
|
.map(async ([name, tool]) => {
|
|
229
229
|
const startedAt = Date.now();
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
tool.server,
|
|
233
|
-
startedAt,
|
|
234
|
-
).catch((error: unknown) => {
|
|
230
|
+
const clientPromise = this.#mcpClient(name, tool.server, startedAt);
|
|
231
|
+
const client = await clientPromise.catch((error: unknown) => {
|
|
235
232
|
if (error instanceof MCPConnectionTimeoutError) throw error;
|
|
236
233
|
throw toMcpToolkitError(name, "connect", error);
|
|
237
234
|
});
|
|
@@ -245,8 +242,10 @@ export class AISDKToolkit {
|
|
|
245
242
|
return [name, tool, tools] as const;
|
|
246
243
|
} catch (error) {
|
|
247
244
|
if (error instanceof MCPConnectionTimeoutError) {
|
|
248
|
-
this.#mcpClients.
|
|
249
|
-
|
|
245
|
+
if (this.#mcpClients.get(name) === clientPromise) {
|
|
246
|
+
this.#mcpClients.delete(name);
|
|
247
|
+
void client.close().catch(() => {});
|
|
248
|
+
}
|
|
250
249
|
throw error;
|
|
251
250
|
}
|
|
252
251
|
throw toMcpToolkitError(name, "list tools", error);
|
|
@@ -254,7 +253,7 @@ export class AISDKToolkit {
|
|
|
254
253
|
}),
|
|
255
254
|
);
|
|
256
255
|
|
|
257
|
-
const tools
|
|
256
|
+
const tools = Object.create(null) as ToolSet;
|
|
258
257
|
const toolSources = new Map<string, string>();
|
|
259
258
|
for (const [serverName, mcpTool, toolSet] of toolSets) {
|
|
260
259
|
for (const [toolName, tool] of Object.entries(toolSet)) {
|
package/src/usage.ts
CHANGED
|
@@ -163,6 +163,14 @@ function findLatestMessageWithUsage(
|
|
|
163
163
|
return undefined;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Reads token usage from the newest assistant message that reports any.
|
|
168
|
+
*
|
|
169
|
+
* A route attaches usage through the AI SDK's `messageMetadata` option. Because
|
|
170
|
+
* a thread message carries a fixed metadata shape, the converter moves every
|
|
171
|
+
* other key the route returns into `metadata.custom`, which is where this hook
|
|
172
|
+
* looks.
|
|
173
|
+
*/
|
|
166
174
|
export function useThreadTokenUsage(): ThreadTokenUsage | undefined {
|
|
167
175
|
const msg = useAuiState((s) => findLatestMessageWithUsage(s.thread.messages));
|
|
168
176
|
return useMemo(() => getThreadMessageTokenUsage(msg), [msg]);
|