@assistant-ui/ai-sdk 0.0.7 → 0.0.9

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 (115) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1 -1
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -2
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -3
  6. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/vercelAttachmentAdapter.js +3 -3
  8. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -1
  9. package/dist/aiSDKExtras.d.ts +4 -6
  10. package/dist/aiSDKExtras.d.ts.map +1 -1
  11. package/dist/converters/convertMessage.d.ts +34 -24
  12. package/dist/converters/convertMessage.d.ts.map +1 -1
  13. package/dist/converters/convertMessage.js +56 -12
  14. package/dist/converters/convertMessage.js.map +1 -1
  15. package/dist/converters/modelContentEnvelope.d.ts +6 -7
  16. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  17. package/dist/converters/toCreateMessage.d.ts +2 -4
  18. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  19. package/dist/converters/toolOutputConversion.d.ts +20 -22
  20. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  21. package/dist/hooks.d.ts +2 -4
  22. package/dist/hooks.d.ts.map +1 -1
  23. package/dist/index.d.ts +17 -14
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.native.d.ts +15 -12
  26. package/dist/index.native.d.ts.map +1 -0
  27. package/dist/model-context/injectInteractableContext.d.ts +2 -4
  28. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  29. package/dist/model-context/injectQuoteContext.d.ts +1 -3
  30. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  31. package/dist/runtime/AISDKChat.d.ts +2 -4
  32. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  33. package/dist/runtime/AISDKChat.js +1 -1
  34. package/dist/runtime/AISDKChat.js.map +1 -1
  35. package/dist/runtime/AISDKThreads.d.ts +29 -31
  36. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  37. package/dist/runtime/AISDKThreads.js +22 -23
  38. package/dist/runtime/AISDKThreads.js.map +1 -1
  39. package/dist/runtime/sdkIdentity.d.ts +1 -3
  40. package/dist/runtime/sdkIdentity.d.ts.map +1 -1
  41. package/dist/runtime/sdkIdentity.js +1 -1
  42. package/dist/runtime/useAISDKRuntime.d.ts +76 -70
  43. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  44. package/dist/runtime/useAISDKRuntime.js +311 -77
  45. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  46. package/dist/runtime/useChatRuntime.d.ts +6 -8
  47. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  48. package/dist/runtime/useChatRuntime.js +14 -10
  49. package/dist/runtime/useChatRuntime.js.map +1 -1
  50. package/dist/runtime/useChatThread.d.ts +94 -89
  51. package/dist/runtime/useChatThread.d.ts.map +1 -1
  52. package/dist/runtime/useChatThread.js +44 -15
  53. package/dist/runtime/useChatThread.js.map +1 -1
  54. package/dist/runtime/useExternalHistory.d.ts +7 -7
  55. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  56. package/dist/runtime/useExternalHistory.js +231 -60
  57. package/dist/runtime/useExternalHistory.js.map +1 -1
  58. package/dist/runtime/useResourceCleanup.d.ts +1 -3
  59. package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
  60. package/dist/runtime/useResourceCleanup.js +38 -14
  61. package/dist/runtime/useResourceCleanup.js.map +1 -1
  62. package/dist/runtime/useStreamingTiming.d.ts +3 -5
  63. package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
  64. package/dist/runtime/useStreamingTiming.js +1 -1
  65. package/dist/tools/frontendTools.d.ts +21 -23
  66. package/dist/tools/frontendTools.d.ts.map +1 -1
  67. package/dist/tools/generativeTools.d.ts +24 -26
  68. package/dist/tools/generativeTools.d.ts.map +1 -1
  69. package/dist/tools/mcp-stdio.node.d.ts +2 -2
  70. package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
  71. package/dist/tools/mcp-stdio.unsupported.d.ts +2 -4
  72. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  73. package/dist/transport/AssistantChatTransport.d.ts +14 -16
  74. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  75. package/dist/transport/AssistantChatTransport.js +9 -2
  76. package/dist/transport/AssistantChatTransport.js.map +1 -1
  77. package/dist/transport/resumable.d.ts +25 -27
  78. package/dist/transport/resumable.d.ts.map +1 -1
  79. package/dist/usage.d.ts +8 -10
  80. package/dist/usage.d.ts.map +1 -1
  81. package/dist/usage.js.map +1 -1
  82. package/dist/utils/getVercelAIMessages.d.ts +2 -4
  83. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  84. package/dist/utils/sliceMessagesUntil.d.ts +1 -3
  85. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  86. package/package.json +13 -9
  87. package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
  88. package/src/adapters/vercelAttachmentAdapter.ts +2 -2
  89. package/src/converters/convertMessage.test.ts +541 -3
  90. package/src/converters/convertMessage.ts +123 -15
  91. package/src/index.native.ts +1 -1
  92. package/src/index.ts +1 -1
  93. package/src/runtime/AISDKChat.ts +0 -4
  94. package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
  95. package/src/runtime/AISDKThreads.test.ts +105 -46
  96. package/src/runtime/AISDKThreads.ts +1 -1
  97. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1639 -19
  98. package/src/runtime/useAISDKRuntime.approval.test.tsx +252 -9
  99. package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +182 -0
  100. package/src/runtime/useAISDKRuntime.test.ts +275 -5
  101. package/src/runtime/useAISDKRuntime.ts +533 -60
  102. package/src/runtime/useAISDKRuntime.voice.test.tsx +50 -1
  103. package/src/runtime/useChatRuntime.fast-refresh.test.tsx +94 -0
  104. package/src/runtime/useChatRuntime.integration.test.tsx +143 -65
  105. package/src/runtime/useChatRuntime.test.ts +11 -7
  106. package/src/runtime/useChatRuntime.ts +6 -2
  107. package/src/runtime/useChatThread.test.ts +166 -2
  108. package/src/runtime/useChatThread.transport.test.tsx +152 -0
  109. package/src/runtime/useChatThread.ts +89 -22
  110. package/src/runtime/useExternalHistory.test.ts +943 -3
  111. package/src/runtime/useExternalHistory.ts +378 -15
  112. package/src/runtime/useResourceCleanup.ts +41 -15
  113. package/src/transport/AssistantChatTransport.test.ts +164 -0
  114. package/src/transport/AssistantChatTransport.ts +22 -2
  115. package/src/usage.ts +1 -1
@@ -1,11 +1,18 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { act, renderHook } from "@testing-library/react";
4
- import type { ExternalStoreAdapter } from "@assistant-ui/core";
5
- import { describe, expect, it, vi } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ bindExternalStoreMessage,
6
+ type ExternalStoreAdapter,
7
+ type ThreadAssistantMessage,
8
+ type ThreadMessage,
9
+ } from "@assistant-ui/core";
10
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
11
 
7
12
  const mocks = vi.hoisted(() => ({
8
13
  adapter: undefined as ExternalStoreAdapter | undefined,
14
+ persistToolApprovalResponses: vi.fn(),
15
+ threadMessages: [] as ThreadMessage[],
9
16
  }));
10
17
 
11
18
  vi.mock("@assistant-ui/core/react", async (importOriginal) => {
@@ -15,7 +22,11 @@ vi.mock("@assistant-ui/core/react", async (importOriginal) => {
15
22
  ...original,
16
23
  useExternalStoreRuntime: vi.fn((adapter: ExternalStoreAdapter) => {
17
24
  mocks.adapter = adapter;
18
- return {};
25
+ return {
26
+ thread: {
27
+ getState: () => ({ messages: mocks.threadMessages }),
28
+ },
29
+ } as never;
19
30
  }),
20
31
  useRuntimeAdapters: vi.fn(() => ({})),
21
32
  };
@@ -29,13 +40,30 @@ vi.mock("./useExternalHistory", async (importOriginal) => {
29
40
  useExternalHistory: vi.fn(() => ({
30
41
  isLoading: false,
31
42
  deleteMessage: vi.fn().mockResolvedValue(undefined),
43
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
44
+ persistToolApprovalResponses: mocks.persistToolApprovalResponses,
32
45
  })),
33
46
  };
34
47
  });
35
48
 
36
49
  import { useAISDKRuntime } from "./useAISDKRuntime";
50
+ import { useExternalHistory } from "./useExternalHistory";
37
51
 
38
52
  describe("useAISDKRuntime tool approvals", () => {
53
+ beforeEach(() => {
54
+ mocks.adapter = undefined;
55
+ mocks.threadMessages = [];
56
+ mocks.persistToolApprovalResponses.mockReset().mockResolvedValue(undefined);
57
+ vi.mocked(useExternalHistory)
58
+ .mockReset()
59
+ .mockImplementation(() => ({
60
+ isLoading: false,
61
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
62
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
63
+ persistToolApprovalResponses: mocks.persistToolApprovalResponses,
64
+ }));
65
+ });
66
+
39
67
  it("forwards the AI SDK approval promise to the external-store adapter", () => {
40
68
  const approvalPromise = Promise.resolve();
41
69
  const addToolApprovalResponse = vi.fn(() => approvalPromise);
@@ -88,6 +116,7 @@ describe("useAISDKRuntime tool approvals", () => {
88
116
  },
89
117
  ];
90
118
  const setMessages = vi.fn();
119
+ const sendMessage = vi.fn();
91
120
  const addToolApprovalResponse = vi.fn();
92
121
  const chat = {
93
122
  id: "chat-1",
@@ -95,7 +124,7 @@ describe("useAISDKRuntime tool approvals", () => {
95
124
  error: undefined,
96
125
  messages,
97
126
  setMessages,
98
- sendMessage: vi.fn(),
127
+ sendMessage,
99
128
  regenerate: vi.fn(),
100
129
  addToolOutput: vi.fn(),
101
130
  addToolApprovalResponse,
@@ -116,17 +145,29 @@ describe("useAISDKRuntime tool approvals", () => {
116
145
  }) => mocks.adapter?.onRespondToToolApproval?.(response),
117
146
  setMessages,
118
147
  addToolApprovalResponse,
148
+ sendMessage,
149
+ messages,
119
150
  getApproval: () =>
120
151
  mocks.adapter?.messages?.[0]?.content.find(
121
152
  (part) => part.type === "tool-call",
122
153
  )?.approval,
154
+ getToolCall: () =>
155
+ mocks.adapter?.messages?.[0]?.content.find(
156
+ (part) => part.type === "tool-call",
157
+ ),
123
158
  };
124
159
  };
125
160
 
126
- it("hands the complete response to a custom handler and applies the answer", async () => {
161
+ it("stores a host answer after the handler resolves without starting a run", async () => {
127
162
  const onRespondToToolApproval = vi.fn(async () => {});
128
- const { respond, setMessages, addToolApprovalResponse, getApproval } =
129
- setupPendingApproval(onRespondToToolApproval);
163
+ const {
164
+ respond,
165
+ setMessages,
166
+ addToolApprovalResponse,
167
+ sendMessage,
168
+ messages,
169
+ getApproval,
170
+ } = setupPendingApproval(onRespondToToolApproval);
130
171
 
131
172
  const response = {
132
173
  approvalId: "approval-1",
@@ -146,6 +187,13 @@ describe("useAISDKRuntime tool approvals", () => {
146
187
  });
147
188
  expect(addToolApprovalResponse).not.toHaveBeenCalled();
148
189
  expect(setMessages).not.toHaveBeenCalled();
190
+ expect(sendMessage).not.toHaveBeenCalled();
191
+ expect(mocks.persistToolApprovalResponses).toHaveBeenCalledExactlyOnceWith(
192
+ "message-1",
193
+ );
194
+ expect(messages[0]).not.toHaveProperty(
195
+ "metadata.__aui_toolApprovalResponses",
196
+ );
149
197
  expect(getApproval()).toEqual({
150
198
  id: "approval-1",
151
199
  approved: true,
@@ -155,7 +203,7 @@ describe("useAISDKRuntime tool approvals", () => {
155
203
  });
156
204
  });
157
205
 
158
- it("sends a request the handler hands back through the AI SDK", async () => {
206
+ it("does not store a request the handler hands back through the AI SDK", async () => {
159
207
  const { respond, addToolApprovalResponse } = setupPendingApproval(
160
208
  (_response, { respondViaAISDK }) => respondViaAISDK(),
161
209
  );
@@ -175,6 +223,27 @@ describe("useAISDKRuntime tool approvals", () => {
175
223
  reason: "Not now",
176
224
  options: { metadata: undefined },
177
225
  });
226
+ expect(mocks.persistToolApprovalResponses).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it("does not store a host answer when the handler rejects", async () => {
230
+ const { respond, messages, getApproval } = setupPendingApproval(
231
+ async () => {
232
+ throw new Error("resume failed");
233
+ },
234
+ );
235
+
236
+ await expect(
237
+ act(async () => {
238
+ await respond({ approvalId: "approval-1", approved: true });
239
+ }),
240
+ ).rejects.toThrow("resume failed");
241
+
242
+ expect(mocks.persistToolApprovalResponses).not.toHaveBeenCalled();
243
+ expect(getApproval()).toEqual({ id: "approval-1" });
244
+ expect(messages[0]).not.toHaveProperty(
245
+ "metadata.__aui_toolApprovalResponses",
246
+ );
178
247
  });
179
248
 
180
249
  it("reopens a request when a handed-back AI SDK response fails inside the handler", async () => {
@@ -254,6 +323,180 @@ describe("useAISDKRuntime tool approvals", () => {
254
323
  expect(onRespondToToolApproval).toHaveBeenCalledTimes(2);
255
324
  });
256
325
 
326
+ it("restores a host answer and refuses a second response", async () => {
327
+ const onRespondToToolApproval = vi.fn(async () => {});
328
+ const { respond, getApproval, messages } = setupPendingApproval(
329
+ onRespondToToolApproval,
330
+ );
331
+ const historyCall = vi.mocked(useExternalHistory).mock.calls.at(-1)!;
332
+ const toolApprovalResponses = historyCall[9] as Map<
333
+ string,
334
+ { approvalId: string; approved: boolean; reason?: string }
335
+ >;
336
+ const onToolApprovalResponsesRestored = historyCall[10] as () => void;
337
+
338
+ await act(async () => {
339
+ toolApprovalResponses.set("approval-1", {
340
+ approvalId: "approval-1",
341
+ approved: true,
342
+ reason: "Approved by operator",
343
+ });
344
+ onToolApprovalResponsesRestored();
345
+ });
346
+
347
+ await waitFor(() =>
348
+ expect(getApproval()).toEqual({
349
+ id: "approval-1",
350
+ approved: true,
351
+ reason: "Approved by operator",
352
+ }),
353
+ );
354
+ await expect(
355
+ respond({ approvalId: "approval-1", approved: true }),
356
+ ).rejects.toThrow(
357
+ "Tool approval approval-1 is not waiting for a response.",
358
+ );
359
+ expect(onRespondToToolApproval).not.toHaveBeenCalled();
360
+ expect(messages[0]).not.toHaveProperty(
361
+ "metadata.__aui_toolApprovalResponses",
362
+ );
363
+ });
364
+
365
+ it("reopens a restored approval when history clears its response map", async () => {
366
+ const onRespondToToolApproval = vi.fn(async () => {});
367
+ const { respond, getApproval } = setupPendingApproval(
368
+ onRespondToToolApproval,
369
+ );
370
+ const historyCall = vi.mocked(useExternalHistory).mock.calls.at(-1)!;
371
+ const toolApprovalResponses = historyCall[9] as Map<
372
+ string,
373
+ { approvalId: string; approved: boolean }
374
+ >;
375
+ const onToolApprovalResponsesRestored = historyCall[10] as () => void;
376
+
377
+ await act(async () => {
378
+ toolApprovalResponses.set("approval-1", {
379
+ approvalId: "approval-1",
380
+ approved: true,
381
+ });
382
+ onToolApprovalResponsesRestored();
383
+ });
384
+ await waitFor(() =>
385
+ expect(getApproval()).toEqual({ id: "approval-1", approved: true }),
386
+ );
387
+
388
+ await act(async () => {
389
+ toolApprovalResponses.clear();
390
+ onToolApprovalResponsesRestored();
391
+ });
392
+ await waitFor(() => expect(getApproval()).toEqual({ id: "approval-1" }));
393
+
394
+ await act(async () => {
395
+ await respond({ approvalId: "approval-1", approved: true });
396
+ });
397
+ expect(onRespondToToolApproval).toHaveBeenCalledOnce();
398
+ });
399
+
400
+ it("clears deleted tool sidecars and its host approval reservation", async () => {
401
+ const onRespondToToolApproval = vi.fn(async () => {});
402
+ const { getApproval, getToolCall, messages, respond } =
403
+ setupPendingApproval(onRespondToToolApproval);
404
+ const threadMessage: ThreadAssistantMessage = {
405
+ id: "message-1",
406
+ role: "assistant",
407
+ content: [
408
+ {
409
+ type: "tool-call",
410
+ toolCallId: "tool-1",
411
+ toolName: "deploy",
412
+ args: {},
413
+ argsText: "{}",
414
+ result: undefined,
415
+ isError: false,
416
+ approval: { id: "approval-1" },
417
+ },
418
+ ],
419
+ createdAt: new Date(),
420
+ status: { type: "requires-action", reason: "tool-calls" },
421
+ metadata: {
422
+ unstable_state: null,
423
+ unstable_annotations: [],
424
+ unstable_data: [],
425
+ steps: [],
426
+ custom: {},
427
+ },
428
+ };
429
+ bindExternalStoreMessage(threadMessage, messages[0]!);
430
+ mocks.threadMessages = [threadMessage];
431
+
432
+ await act(async () => {
433
+ await respond({ approvalId: "approval-1", approved: true });
434
+ await mocks.adapter?.onAddToolResult?.({
435
+ messageId: "message-1",
436
+ toolCallId: "tool-1",
437
+ toolName: "deploy",
438
+ result: "deployed",
439
+ artifact: { preview: "deployment complete" },
440
+ isError: false,
441
+ });
442
+ await mocks.adapter?.unstable_onRecordToolInteraction?.({
443
+ messageId: "message-1",
444
+ toolCallId: "tool-1",
445
+ interaction: {
446
+ type: "action",
447
+ occurredAt: 1,
448
+ payload: { copied: true },
449
+ },
450
+ });
451
+ });
452
+ expect(getToolCall()).toMatchObject({
453
+ artifact: { preview: "deployment complete" },
454
+ unstable_interactions: {
455
+ entries: [{ type: "action", occurredAt: 1, payload: { copied: true } }],
456
+ },
457
+ approval: { id: "approval-1", approved: true },
458
+ });
459
+
460
+ await act(async () => {
461
+ await mocks.adapter?.onDelete?.("message-1");
462
+ });
463
+ expect(getToolCall()).not.toHaveProperty("artifact");
464
+ expect(getToolCall()).not.toHaveProperty("unstable_interactions");
465
+ expect(getApproval()).toEqual({ id: "approval-1" });
466
+
467
+ await act(async () => {
468
+ await respond({ approvalId: "approval-1", approved: true });
469
+ });
470
+ expect(onRespondToToolApproval).toHaveBeenCalledTimes(2);
471
+ });
472
+
473
+ it("answers through the host past an earlier approval-requested part without an approval", async () => {
474
+ const onRespondToToolApproval = vi.fn(async () => {});
475
+ const { respond, messages } = setupPendingApproval(onRespondToToolApproval);
476
+ messages.unshift({
477
+ id: "message-0",
478
+ role: "assistant",
479
+ parts: [
480
+ {
481
+ type: "tool-legacy",
482
+ toolCallId: "tool-0",
483
+ state: "approval-requested",
484
+ input: {},
485
+ } as (typeof messages)[number]["parts"][number],
486
+ ],
487
+ });
488
+
489
+ await act(async () => {
490
+ await respond({ approvalId: "approval-1", approved: true });
491
+ });
492
+
493
+ expect(onRespondToToolApproval).toHaveBeenCalledOnce();
494
+ expect(onRespondToToolApproval).toHaveBeenCalledWith(
495
+ expect.objectContaining({ approvalId: "approval-1" }),
496
+ expect.objectContaining({ toolCallId: "tool-1" }),
497
+ );
498
+ });
499
+
257
500
  it("rejects an approval that is not waiting for a response", async () => {
258
501
  const onRespondToToolApproval = vi.fn();
259
502
  const { respond } = setupPendingApproval(onRespondToToolApproval);
@@ -0,0 +1,182 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { Activity, act, version } from "react";
4
+ import type { UIMessage } from "ai";
5
+ import type { SuggestionAdapter } from "@assistant-ui/core";
6
+ import { afterAll, afterEach, expect, it, vi } from "vitest";
7
+
8
+ const onReact18 = version.startsWith("18.");
9
+
10
+ type Family = { current: unknown };
11
+ type RendererInternals = {
12
+ setRefreshHandler: (resolve: (type: unknown) => Family | undefined) => void;
13
+ scheduleRefresh: (
14
+ root: unknown,
15
+ update: { staleFamilies: Set<Family>; updatedFamilies: Set<Family> },
16
+ ) => void;
17
+ };
18
+
19
+ let renderer: RendererInternals | undefined;
20
+ const fiberRoots = new Set<unknown>();
21
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
22
+ vi.stubGlobal("__REACT_DEVTOOLS_GLOBAL_HOOK__", {
23
+ supportsFiber: true,
24
+ inject: (internals: RendererInternals) => {
25
+ renderer = internals;
26
+ return 1;
27
+ },
28
+ onScheduleFiberRoot: () => {},
29
+ onCommitFiberRoot: (_id: number, root: unknown) => fiberRoots.add(root),
30
+ onCommitFiberUnmount: () => {},
31
+ });
32
+ const { cleanup, render, waitFor } = await import("@testing-library/react");
33
+ const { useAISDKRuntime } = await import("./useAISDKRuntime");
34
+
35
+ afterEach(() => {
36
+ cleanup();
37
+ renderer!.setRefreshHandler(() => undefined);
38
+ fiberRoots.clear();
39
+ });
40
+ afterAll(() => vi.unstubAllGlobals());
41
+
42
+ const refresh = async (Before: unknown, After: unknown) => {
43
+ const family: Family = { current: After };
44
+ renderer!.setRefreshHandler((type) =>
45
+ type === Before || type === After ? family : undefined,
46
+ );
47
+ await act(async () => {
48
+ for (const root of fiberRoots) {
49
+ renderer!.scheduleRefresh(root, {
50
+ staleFamilies: new Set(),
51
+ updatedFamilies: new Set([family]),
52
+ });
53
+ }
54
+ });
55
+ await act(async () => {});
56
+ };
57
+
58
+ const createChat = () => ({
59
+ id: "chat-1",
60
+ status: "submitted",
61
+ error: null,
62
+ messages: [
63
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
64
+ ] as UIMessage[],
65
+ setMessages: vi.fn(),
66
+ sendMessage: vi.fn(async () => {}),
67
+ regenerate: vi.fn(async () => {}),
68
+ addToolResult: vi.fn(),
69
+ addToolOutput: vi.fn(),
70
+ stop: vi.fn(),
71
+ });
72
+
73
+ const settleChat = (chat: ReturnType<typeof createChat>) => {
74
+ chat.status = "ready";
75
+ chat.messages = [
76
+ ...chat.messages,
77
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "hello" }] },
78
+ ];
79
+ };
80
+
81
+ const makeGeneration = () => {
82
+ let resolve!: (value: readonly { prompt: string }[]) => void;
83
+ const generate = vi.fn(
84
+ (_input: Parameters<SuggestionAdapter["generate"]>[0]) =>
85
+ new Promise<readonly { prompt: string }[]>((done) => {
86
+ resolve = done;
87
+ }),
88
+ );
89
+ return {
90
+ generate,
91
+ finish: (value: readonly { prompt: string }[]) => resolve(value),
92
+ };
93
+ };
94
+
95
+ it("completes AI SDK suggestion generation after Fast Refresh", async () => {
96
+ const chat = createChat();
97
+ const { generate, finish } = makeGeneration();
98
+ let runtime!: ReturnType<typeof useAISDKRuntime>;
99
+ let rendered = "";
100
+ const Before = () => {
101
+ rendered = "before";
102
+ runtime = useAISDKRuntime(chat as never, {
103
+ adapters: { suggestion: { generate } },
104
+ });
105
+ return null;
106
+ };
107
+ const After = () => {
108
+ rendered = "after";
109
+ runtime = useAISDKRuntime(chat as never, {
110
+ adapters: { suggestion: { generate } },
111
+ });
112
+ return null;
113
+ };
114
+ const view = render(<Before />);
115
+ settleChat(chat);
116
+ view.rerender(<Before />);
117
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
118
+ const signal = generate.mock.calls[0]![0].signal!;
119
+
120
+ await refresh(Before, After);
121
+ expect(rendered).toBe("after");
122
+ expect(signal.aborted).toBe(false);
123
+ await act(async () => finish([{ prompt: "next" }]));
124
+ await waitFor(() =>
125
+ expect(runtime.thread.getState().suggestions).toEqual([{ prompt: "next" }]),
126
+ );
127
+ view.unmount();
128
+ });
129
+
130
+ it("aborts pending AI SDK suggestion generation on unmount", async () => {
131
+ const chat = createChat();
132
+ const { generate } = makeGeneration();
133
+ const Host = () => {
134
+ useAISDKRuntime(chat as never, { adapters: { suggestion: { generate } } });
135
+ return null;
136
+ };
137
+ const view = render(<Host />);
138
+ settleChat(chat);
139
+ view.rerender(<Host />);
140
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
141
+ const signal = generate.mock.calls[0]![0].signal!;
142
+
143
+ view.unmount();
144
+ await act(async () => {});
145
+ expect(signal.aborted).toBe(true);
146
+ });
147
+
148
+ // Activity is React 19 only.
149
+ it.skipIf(onReact18)(
150
+ "aborts pending AI SDK suggestion generation when Activity hides",
151
+ async () => {
152
+ const chat = createChat();
153
+ const { generate } = makeGeneration();
154
+ const Host = () => {
155
+ useAISDKRuntime(chat as never, {
156
+ adapters: { suggestion: { generate } },
157
+ });
158
+ return null;
159
+ };
160
+ const view = render(
161
+ <Activity mode="visible">
162
+ <Host />
163
+ </Activity>,
164
+ );
165
+ settleChat(chat);
166
+ view.rerender(
167
+ <Activity mode="visible">
168
+ <Host />
169
+ </Activity>,
170
+ );
171
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
172
+ const signal = generate.mock.calls[0]![0].signal!;
173
+
174
+ view.rerender(
175
+ <Activity mode="hidden">
176
+ <Host />
177
+ </Activity>,
178
+ );
179
+ await act(async () => {});
180
+ expect(signal.aborted).toBe(true);
181
+ },
182
+ );