@assistant-ui/ai-sdk 0.0.7 → 0.0.8

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 (105) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -2
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  4. package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -3
  5. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
  6. package/dist/adapters/vercelAttachmentAdapter.js +3 -3
  7. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -1
  8. package/dist/aiSDKExtras.d.ts +4 -6
  9. package/dist/aiSDKExtras.d.ts.map +1 -1
  10. package/dist/converters/convertMessage.d.ts +32 -24
  11. package/dist/converters/convertMessage.d.ts.map +1 -1
  12. package/dist/converters/convertMessage.js +43 -9
  13. package/dist/converters/convertMessage.js.map +1 -1
  14. package/dist/converters/modelContentEnvelope.d.ts +6 -7
  15. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  16. package/dist/converters/toCreateMessage.d.ts +2 -4
  17. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  18. package/dist/converters/toolOutputConversion.d.ts +20 -22
  19. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  20. package/dist/hooks.d.ts +2 -4
  21. package/dist/hooks.d.ts.map +1 -1
  22. package/dist/index.d.ts +17 -14
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.native.d.ts +15 -12
  25. package/dist/index.native.d.ts.map +1 -0
  26. package/dist/model-context/injectInteractableContext.d.ts +2 -4
  27. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  28. package/dist/model-context/injectQuoteContext.d.ts +1 -3
  29. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  30. package/dist/runtime/AISDKChat.d.ts +2 -4
  31. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  32. package/dist/runtime/AISDKChat.js +1 -1
  33. package/dist/runtime/AISDKThreads.d.ts +29 -31
  34. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  35. package/dist/runtime/AISDKThreads.js +22 -23
  36. package/dist/runtime/AISDKThreads.js.map +1 -1
  37. package/dist/runtime/sdkIdentity.d.ts +1 -3
  38. package/dist/runtime/sdkIdentity.d.ts.map +1 -1
  39. package/dist/runtime/sdkIdentity.js +1 -1
  40. package/dist/runtime/useAISDKRuntime.d.ts +69 -70
  41. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  42. package/dist/runtime/useAISDKRuntime.js +112 -56
  43. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  44. package/dist/runtime/useChatRuntime.d.ts +6 -8
  45. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  46. package/dist/runtime/useChatRuntime.js +14 -10
  47. package/dist/runtime/useChatRuntime.js.map +1 -1
  48. package/dist/runtime/useChatThread.d.ts +94 -89
  49. package/dist/runtime/useChatThread.d.ts.map +1 -1
  50. package/dist/runtime/useChatThread.js +4 -5
  51. package/dist/runtime/useChatThread.js.map +1 -1
  52. package/dist/runtime/useExternalHistory.d.ts +7 -7
  53. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  54. package/dist/runtime/useExternalHistory.js +229 -59
  55. package/dist/runtime/useExternalHistory.js.map +1 -1
  56. package/dist/runtime/useResourceCleanup.d.ts +1 -3
  57. package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
  58. package/dist/runtime/useResourceCleanup.js +38 -14
  59. package/dist/runtime/useResourceCleanup.js.map +1 -1
  60. package/dist/runtime/useStreamingTiming.d.ts +3 -5
  61. package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
  62. package/dist/runtime/useStreamingTiming.js +1 -1
  63. package/dist/tools/frontendTools.d.ts +21 -23
  64. package/dist/tools/frontendTools.d.ts.map +1 -1
  65. package/dist/tools/generativeTools.d.ts +24 -26
  66. package/dist/tools/generativeTools.d.ts.map +1 -1
  67. package/dist/tools/mcp-stdio.node.d.ts +2 -2
  68. package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
  69. package/dist/tools/mcp-stdio.unsupported.d.ts +2 -4
  70. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  71. package/dist/transport/AssistantChatTransport.d.ts +14 -16
  72. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  73. package/dist/transport/resumable.d.ts +25 -27
  74. package/dist/transport/resumable.d.ts.map +1 -1
  75. package/dist/usage.d.ts +8 -10
  76. package/dist/usage.d.ts.map +1 -1
  77. package/dist/usage.js.map +1 -1
  78. package/dist/utils/getVercelAIMessages.d.ts +2 -4
  79. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  80. package/dist/utils/sliceMessagesUntil.d.ts +1 -3
  81. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  82. package/package.json +9 -8
  83. package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
  84. package/src/adapters/vercelAttachmentAdapter.ts +2 -2
  85. package/src/converters/convertMessage.test.ts +478 -3
  86. package/src/converters/convertMessage.ts +100 -14
  87. package/src/index.native.ts +1 -1
  88. package/src/index.ts +1 -1
  89. package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
  90. package/src/runtime/AISDKThreads.test.ts +79 -46
  91. package/src/runtime/AISDKThreads.ts +1 -1
  92. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +51 -0
  93. package/src/runtime/useAISDKRuntime.approval.test.tsx +225 -9
  94. package/src/runtime/useAISDKRuntime.test.ts +275 -5
  95. package/src/runtime/useAISDKRuntime.ts +141 -37
  96. package/src/runtime/useAISDKRuntime.voice.test.tsx +50 -1
  97. package/src/runtime/useChatRuntime.integration.test.tsx +56 -0
  98. package/src/runtime/useChatRuntime.test.ts +5 -1
  99. package/src/runtime/useChatRuntime.ts +6 -2
  100. package/src/runtime/useChatThread.transport.test.tsx +149 -0
  101. package/src/runtime/useChatThread.ts +33 -5
  102. package/src/runtime/useExternalHistory.test.ts +943 -3
  103. package/src/runtime/useExternalHistory.ts +366 -14
  104. package/src/runtime/useResourceCleanup.ts +41 -15
  105. 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,153 @@ 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
+
257
473
  it("rejects an approval that is not waiting for a response", async () => {
258
474
  const onRespondToToolApproval = vi.fn();
259
475
  const { respond } = setupPendingApproval(onRespondToToolApproval);
@@ -3,6 +3,7 @@
3
3
  import { act, renderHook, waitFor } from "@testing-library/react";
4
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { validateUIMessages } from "ai";
6
+ import { ToolResponse } from "assistant-stream";
6
7
  import type { UIMessage } from "@ai-sdk/react";
7
8
  import type { MessageFormatRepository } from "@assistant-ui/core";
8
9
 
@@ -17,6 +18,8 @@ vi.mock("./useExternalHistory", async (importOriginal) => {
17
18
  useExternalHistory: vi.fn(() => ({
18
19
  isLoading: false,
19
20
  deleteMessage: vi.fn().mockResolvedValue(undefined),
21
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
22
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
20
23
  })),
21
24
  };
22
25
  });
@@ -82,6 +85,8 @@ describe("useAISDKRuntime", () => {
82
85
  vi.mocked(useExternalHistory).mockReturnValue({
83
86
  isLoading: false,
84
87
  deleteMessage: vi.fn().mockResolvedValue(undefined),
88
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
89
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
85
90
  });
86
91
  });
87
92
 
@@ -121,13 +126,13 @@ describe("useAISDKRuntime", () => {
121
126
  result.current.thread.append({
122
127
  role: "user",
123
128
  content: [{ type: "text", text: "hello" }],
124
- runConfig: { custom: { model: "gpt-5.6-luna" } },
129
+ runConfig: { custom: { model: "gpt-6-luna" } },
125
130
  });
126
131
  });
127
132
 
128
133
  await waitFor(() => {
129
134
  expect(chat.sendMessage).toHaveBeenCalledWith(expect.anything(), {
130
- metadata: { custom: { model: "gpt-5.6-luna" } },
135
+ metadata: { custom: { model: "gpt-6-luna" } },
131
136
  });
132
137
  });
133
138
  });
@@ -571,7 +576,65 @@ describe("useAISDKRuntime", () => {
571
576
  ).resolves.toBeDefined();
572
577
  });
573
578
 
574
- it("forwards a successful tool result through addToolOutput, not the deprecated addToolResult", async () => {
579
+ it("cancels a pending tool call left behind a staged message", async () => {
580
+ const chat = createChatHelpers([
581
+ {
582
+ id: "a1",
583
+ role: "assistant",
584
+ parts: [
585
+ {
586
+ type: "dynamic-tool",
587
+ toolName: "mcp_search",
588
+ toolCallId: "tc-1",
589
+ state: "approval-requested",
590
+ input: { q: "hi" },
591
+ approval: { id: "appr-1" },
592
+ },
593
+ ],
594
+ },
595
+ ]);
596
+
597
+ const { result } = renderHook(() => useAISDKRuntime(chat));
598
+
599
+ await waitFor(() => {
600
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
601
+ 0,
602
+ );
603
+ });
604
+
605
+ act(() => {
606
+ result.current.thread.append({
607
+ role: "user",
608
+ content: [{ type: "text", text: "context" }],
609
+ startRun: false,
610
+ });
611
+ });
612
+
613
+ await waitFor(() => {
614
+ expect(chat.messages).toHaveLength(2);
615
+ });
616
+
617
+ act(() => {
618
+ result.current.thread.append({
619
+ role: "user",
620
+ content: [{ type: "text", text: "what" }],
621
+ });
622
+ });
623
+
624
+ await waitFor(() => {
625
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
626
+ });
627
+
628
+ const part = chat.messages[0].parts[0];
629
+ expect(part.state).toBe("output-error");
630
+ expect(part.approval).toBeUndefined();
631
+
632
+ await expect(
633
+ validateUIMessages({ messages: chat.messages }),
634
+ ).resolves.toBeDefined();
635
+ });
636
+
637
+ it("attaches a tool artifact to the live part and forwards its result through addToolOutput", async () => {
575
638
  const chat = createChatHelpers([
576
639
  {
577
640
  id: "a1",
@@ -587,7 +650,7 @@ describe("useAISDKRuntime", () => {
587
650
  },
588
651
  ]);
589
652
 
590
- const { result } = renderHook(() => useAISDKRuntime(chat));
653
+ const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
591
654
 
592
655
  await waitFor(() => {
593
656
  expect(result.current.thread.getState().messages.length).toBeGreaterThan(
@@ -599,13 +662,26 @@ describe("useAISDKRuntime", () => {
599
662
  result.current.thread
600
663
  .getMessageById("a1")
601
664
  .getMessagePartByToolCallId("tc-1")
602
- .addToolResult({ temp: 72 });
665
+ .addToolResult(
666
+ new ToolResponse({
667
+ result: { temp: 72 },
668
+ artifact: { preview: "72°F and sunny" },
669
+ }),
670
+ );
603
671
  });
604
672
 
605
673
  await waitFor(() => {
606
674
  expect(chat.addToolOutput).toHaveBeenCalledTimes(1);
607
675
  });
608
676
 
677
+ const livePart = result.current.thread
678
+ .getMessageById("a1")
679
+ .getMessagePartByToolCallId("tc-1")
680
+ .getState();
681
+ expect(
682
+ livePart.type === "tool-call" ? livePart.artifact : undefined,
683
+ ).toEqual({ preview: "72°F and sunny" });
684
+
609
685
  expect(chat.addToolOutput).toHaveBeenCalledWith(
610
686
  expect.objectContaining({
611
687
  tool: "weather",
@@ -614,7 +690,90 @@ describe("useAISDKRuntime", () => {
614
690
  options: { metadata: undefined },
615
691
  }),
616
692
  );
693
+ expect(chat.addToolOutput.mock.calls[0]?.[0]).not.toHaveProperty(
694
+ "artifact",
695
+ );
617
696
  expect(chat.addToolResult).not.toHaveBeenCalled();
697
+ expect(
698
+ vi.mocked(useExternalHistory).mock.calls.at(-1)?.[5]?.get("tc-1"),
699
+ ).toEqual({ preview: "72°F and sunny" });
700
+
701
+ chat.messages = chat.messages.map((message: UIMessage) => ({
702
+ ...message,
703
+ }));
704
+ rerender();
705
+
706
+ await waitFor(() => {
707
+ const part = result.current.thread
708
+ .getMessageById("a1")
709
+ .getMessagePartByToolCallId("tc-1")
710
+ .getState();
711
+ expect(part.type === "tool-call" ? part.artifact : undefined).toEqual({
712
+ preview: "72°F and sunny",
713
+ });
714
+ });
715
+ expect(chat.messages[0]?.metadata).toBeUndefined();
716
+ });
717
+
718
+ it("shows recorded tool interactions without writing them to chat messages", async () => {
719
+ const chat = createChatHelpers([
720
+ {
721
+ id: "a1",
722
+ role: "assistant",
723
+ parts: [
724
+ {
725
+ type: "tool-weather",
726
+ toolCallId: "tc-1",
727
+ state: "input-available",
728
+ input: { city: "NYC" },
729
+ },
730
+ ],
731
+ },
732
+ ]);
733
+ const persistToolInteractions = vi.fn().mockResolvedValue(undefined);
734
+ vi.mocked(useExternalHistory).mockReturnValue({
735
+ isLoading: false,
736
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
737
+ persistToolInteractions,
738
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
739
+ });
740
+
741
+ const { result } = renderHook(() => useAISDKRuntime(chat));
742
+
743
+ await waitFor(() => {
744
+ expect(result.current.thread.getState().messages).toHaveLength(1);
745
+ });
746
+
747
+ await act(async () => {
748
+ await result.current.thread
749
+ .getMessageById("a1")
750
+ .getMessagePartByToolCallId("tc-1").unstable_recordInteraction!({
751
+ type: "action",
752
+ payload: { refresh: true },
753
+ });
754
+ });
755
+
756
+ await waitFor(() => {
757
+ const part = result.current.thread
758
+ .getMessageById("a1")
759
+ .getMessagePartByToolCallId("tc-1")
760
+ .getState();
761
+ expect(
762
+ part.type === "tool-call" ? part.unstable_interactions : undefined,
763
+ ).toEqual({
764
+ entries: [
765
+ {
766
+ type: "action",
767
+ occurredAt: expect.any(Number),
768
+ payload: { refresh: true },
769
+ },
770
+ ],
771
+ });
772
+ });
773
+ expect(persistToolInteractions).toHaveBeenCalledExactlyOnceWith("a1");
774
+ expect(chat.messages[0]?.metadata).toBeUndefined();
775
+ expect(chat.addToolOutput).not.toHaveBeenCalled();
776
+ expect(chat.sendMessage).not.toHaveBeenCalled();
618
777
  });
619
778
 
620
779
  it("appends a new user message without sending when startRun is false", async () => {
@@ -704,6 +863,8 @@ describe("useAISDKRuntime", () => {
704
863
  vi.mocked(useExternalHistory).mockReturnValue({
705
864
  isLoading: false,
706
865
  deleteMessage,
866
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
867
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
707
868
  });
708
869
  const chat = createChatHelpers([
709
870
  { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
@@ -738,6 +899,115 @@ describe("useAISDKRuntime", () => {
738
899
  ]);
739
900
  });
740
901
 
902
+ it("removes tool artifacts with their deleted message", async () => {
903
+ const deleteMessage = vi.fn().mockResolvedValue(undefined);
904
+ vi.mocked(useExternalHistory).mockReturnValue({
905
+ isLoading: false,
906
+ deleteMessage,
907
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
908
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
909
+ });
910
+ const chat = createChatHelpers([
911
+ {
912
+ id: "a1",
913
+ role: "assistant",
914
+ parts: [
915
+ {
916
+ type: "tool-weather",
917
+ toolCallId: "tc-1",
918
+ state: "input-available",
919
+ input: { city: "NYC" },
920
+ },
921
+ ],
922
+ },
923
+ ]);
924
+
925
+ const { result } = renderHook(() => useAISDKRuntime(chat));
926
+
927
+ await waitFor(() => {
928
+ expect(result.current.thread.getState().messages).toHaveLength(1);
929
+ });
930
+
931
+ act(() => {
932
+ result.current.thread
933
+ .getMessageById("a1")
934
+ .getMessagePartByToolCallId("tc-1")
935
+ .addToolResult(
936
+ new ToolResponse({
937
+ result: { temp: 72 },
938
+ artifact: { preview: "72°F and sunny" },
939
+ }),
940
+ );
941
+ });
942
+
943
+ await waitFor(() => {
944
+ expect(chat.addToolOutput).toHaveBeenCalledTimes(1);
945
+ });
946
+
947
+ const toolArtifacts = vi.mocked(useExternalHistory).mock.calls.at(-1)?.[5];
948
+ expect(toolArtifacts?.get("tc-1")).toEqual({
949
+ preview: "72°F and sunny",
950
+ });
951
+ await act(async () => {
952
+ await result.current.thread.getMessageById("a1").delete();
953
+ });
954
+
955
+ expect(deleteMessage).toHaveBeenCalledWith("a1");
956
+ expect(toolArtifacts?.has("tc-1")).toBe(false);
957
+ });
958
+
959
+ it("removes tool interactions with their deleted message", async () => {
960
+ const deleteMessage = vi.fn().mockResolvedValue(undefined);
961
+ vi.mocked(useExternalHistory).mockReturnValue({
962
+ isLoading: false,
963
+ deleteMessage,
964
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
965
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
966
+ });
967
+ const chat = createChatHelpers([
968
+ {
969
+ id: "a1",
970
+ role: "assistant",
971
+ parts: [
972
+ {
973
+ type: "tool-weather",
974
+ toolCallId: "tc-1",
975
+ state: "output-available",
976
+ input: { city: "NYC" },
977
+ output: { temp: 72 },
978
+ },
979
+ ],
980
+ },
981
+ ]);
982
+
983
+ const { result } = renderHook(() => useAISDKRuntime(chat));
984
+
985
+ await waitFor(() => {
986
+ expect(result.current.thread.getState().messages).toHaveLength(1);
987
+ });
988
+
989
+ await act(async () => {
990
+ await result.current.thread
991
+ .getMessageById("a1")
992
+ .getMessagePartByToolCallId("tc-1").unstable_recordInteraction!({
993
+ type: "action",
994
+ payload: { type: "refresh" },
995
+ });
996
+ });
997
+
998
+ const toolInteractions = vi
999
+ .mocked(useExternalHistory)
1000
+ .mock.calls.at(-1)?.[7];
1001
+ expect(toolInteractions?.has("tc-1")).toBe(true);
1002
+
1003
+ await act(async () => {
1004
+ await result.current.thread.getMessageById("a1").delete();
1005
+ });
1006
+
1007
+ expect(deleteMessage).toHaveBeenCalledWith("a1");
1008
+ expect(toolInteractions?.has("tc-1")).toBe(false);
1009
+ });
1010
+
741
1011
  it("edit slices history to parentId and sends the edited message", async () => {
742
1012
  const chat = createChatHelpers([
743
1013
  { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },