@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/ai-sdk",
3
- "version": "0.0.4",
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.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",
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.41"
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.25",
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.5",
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 && typeof part.approval.id === "string") {
172
- const { id, approved, reason, isAutomatic } = part.approval;
173
- return {
174
- approval: {
175
- id,
176
- ...(typeof approved === "boolean" && { approved }),
177
- ...(typeof reason === "string" && { reason }),
178
- ...(isAutomatic === true && { isAutomatic: true }),
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 { useEffect } from "react";
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 { flushTapSync } from "@assistant-ui/tap";
6
+ import type { ChatTransport, UIMessage } from "ai";
8
7
  import { describe, expect, it } from "vitest";
9
8
  import { AISDKChat } from "./AISDKChat";
10
- import { createCancellableTransport } from "./__tests__/controlled-transport";
9
+ import {
10
+ createCancellableTransport,
11
+ createStreamHarness,
12
+ } from "./__tests__/controlled-transport";
11
13
 
12
14
  describe("AISDKChat React integration", () => {
13
- it("does not treat React provider unmount as client destruction", async () => {
14
- const { transport, getCancelCount, close } = createCancellableTransport();
15
- let started = false;
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
- <SendOnMount />
22
+ <Probe />
34
23
  </AuiProvider>
35
24
  </StrictMode>,
36
25
  );
37
26
 
38
- await waitFor(() => {
39
- expect(isRunning()).toBe(true);
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 new Promise((resolve) => setTimeout(resolve, 0));
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
- close();
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 { UIMessage } from "ai";
7
- import { StrictMode, useState } from "react";
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 (
@@ -29,6 +29,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
29
29
  isMainThread,
30
30
  getThreadListItem: () =>
31
31
  aui.threadListItem.source ? aui.threadListItem : undefined,
32
+ stopOnClientDestroy: true,
32
33
  });
33
34
  };
34
35
 
@@ -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 client = await this.#mcpClient(
231
- name,
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.delete(name);
249
- void client.close().catch(() => {});
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: ToolSet = {};
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]);