@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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/converters/convertMessage.d.ts.map +1 -1
  3. package/dist/converters/convertMessage.js +23 -2
  4. package/dist/converters/convertMessage.js.map +1 -1
  5. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  6. package/dist/converters/toCreateMessage.js +6 -2
  7. package/dist/converters/toCreateMessage.js.map +1 -1
  8. package/dist/model-context/injectInteractableContext.d.ts +1 -1
  9. package/dist/model-context/injectInteractableContext.js +1 -1
  10. package/dist/model-context/injectInteractableContext.js.map +1 -1
  11. package/dist/model-context/injectQuoteContext.d.ts +1 -1
  12. package/dist/model-context/injectQuoteContext.js +1 -1
  13. package/dist/model-context/injectQuoteContext.js.map +1 -1
  14. package/dist/runtime/AISDKChat.js +1 -1
  15. package/dist/runtime/AISDKThreads.d.ts +10 -8
  16. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  17. package/dist/runtime/AISDKThreads.js +34 -27
  18. package/dist/runtime/AISDKThreads.js.map +1 -1
  19. package/dist/runtime/useAISDKRuntime.d.ts +18 -3
  20. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  21. package/dist/runtime/useAISDKRuntime.js +199 -73
  22. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  23. package/dist/runtime/useChatRuntime.js +1 -1
  24. package/dist/runtime/useChatThread.d.ts +14 -1
  25. package/dist/runtime/useChatThread.d.ts.map +1 -1
  26. package/dist/runtime/useChatThread.js +11 -4
  27. package/dist/runtime/useChatThread.js.map +1 -1
  28. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  29. package/dist/runtime/useExternalHistory.js +52 -47
  30. package/dist/runtime/useExternalHistory.js.map +1 -1
  31. package/dist/runtime/useResourceCleanup.js +1 -1
  32. package/dist/usage.d.ts +1 -2
  33. package/dist/usage.d.ts.map +1 -1
  34. package/dist/usage.js +5 -7
  35. package/dist/usage.js.map +1 -1
  36. package/package.json +12 -12
  37. package/src/converters/convertMessage.test.ts +22 -0
  38. package/src/converters/convertMessage.ts +26 -2
  39. package/src/converters/toCreateMessage.ts +6 -5
  40. package/src/model-context/injectInteractableContext.ts +1 -1
  41. package/src/model-context/injectQuoteContext.ts +1 -1
  42. package/src/runtime/AISDKThreads.cloud.test.ts +11 -1
  43. package/src/runtime/AISDKThreads.test.ts +102 -0
  44. package/src/runtime/AISDKThreads.ts +16 -9
  45. package/src/runtime/useAISDKRuntime.denied-tool.test.tsx +122 -0
  46. package/src/runtime/useAISDKRuntime.test.ts +479 -10
  47. package/src/runtime/useAISDKRuntime.ts +199 -55
  48. package/src/runtime/useChatRuntime.integration.test.tsx +46 -0
  49. package/src/runtime/useChatRuntime.test.ts +16 -0
  50. package/src/runtime/useChatThread.ts +26 -0
  51. package/src/runtime/useExternalHistory.test.ts +143 -1
  52. package/src/runtime/useExternalHistory.ts +53 -56
  53. package/src/usage.test.ts +26 -8
  54. 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.2",
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.32",
50
- "@ai-sdk/react": "^4.0.69",
51
- "@assistant-ui/core": "^0.3.15",
52
- "@assistant-ui/store": "^0.3.10",
53
- "@assistant-ui/tap": "^0.9.14",
54
- "ai": "^7.0.66",
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.39"
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
- "@testing-library/react": "^16.3.2",
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.4",
75
+ "@types/react-dom": "^19.2.5",
75
76
  "jsdom": "^30.0.1",
76
77
  "react": "^19.2.8",
77
- "vitest": "^4.1.10",
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 as MessageMetadata,
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 as MessageMetadata),
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: httpUrlPattern.test(data)
78
- ? data
79
- : `data:${mediaType};base64,${parseDataUrl(data)?.data ?? data}`,
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/react-ai-sdk";
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/react-ai-sdk";
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("stops an in-flight cloud chat when switching away", async () => {
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. The thread
45
- * factory is keyed so cloud history reloads on a switch, and an
46
- * in-flight run does not continue in the background.
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; each thread's chat id is its thread id.
216
- * With `cloud`, the list is a `RemoteThreadList` and the factory is keyed so
217
- * cloud history reloads on a switch. The store entry mounts only the visible
218
- * thread, so a switch cancels an in-flight run. Model context is
219
- * registered on the visible thread only.
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
+ });