@assistant-ui/ai-sdk 0.0.6 → 0.0.7

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 (79) hide show
  1. package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -2
  2. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
  3. package/dist/aiSDKExtras.d.ts +2 -3
  4. package/dist/aiSDKExtras.d.ts.map +1 -1
  5. package/dist/converters/convertMessage.d.ts +5 -4
  6. package/dist/converters/convertMessage.d.ts.map +1 -1
  7. package/dist/converters/convertMessage.js +39 -3
  8. package/dist/converters/convertMessage.js.map +1 -1
  9. package/dist/converters/modelContentEnvelope.d.ts +4 -5
  10. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  11. package/dist/converters/toCreateMessage.d.ts +1 -2
  12. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  13. package/dist/converters/toolOutputConversion.d.ts +2 -3
  14. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  15. package/dist/hooks.d.ts +2 -3
  16. package/dist/hooks.d.ts.map +1 -1
  17. package/dist/model-context/injectInteractableContext.d.ts +1 -2
  18. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  19. package/dist/model-context/injectQuoteContext.d.ts +1 -2
  20. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  21. package/dist/runtime/AISDKChat.d.ts +2 -3
  22. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  23. package/dist/runtime/AISDKThreads.d.ts +2 -3
  24. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  25. package/dist/runtime/AISDKThreads.js +4 -10
  26. package/dist/runtime/AISDKThreads.js.map +1 -1
  27. package/dist/runtime/sdkIdentity.d.ts +1 -2
  28. package/dist/runtime/sdkIdentity.d.ts.map +1 -1
  29. package/dist/runtime/sdkIdentity.js +1 -1
  30. package/dist/runtime/useAISDKRuntime.d.ts +17 -5
  31. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  32. package/dist/runtime/useAISDKRuntime.js +95 -37
  33. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  34. package/dist/runtime/useChatRuntime.d.ts +2 -3
  35. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  36. package/dist/runtime/useChatThread.d.ts +19 -5
  37. package/dist/runtime/useChatThread.d.ts.map +1 -1
  38. package/dist/runtime/useChatThread.js +30 -9
  39. package/dist/runtime/useChatThread.js.map +1 -1
  40. package/dist/runtime/useExternalHistory.d.ts +2 -3
  41. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  42. package/dist/runtime/useExternalHistory.js +28 -20
  43. package/dist/runtime/useExternalHistory.js.map +1 -1
  44. package/dist/runtime/useResourceCleanup.d.ts +1 -2
  45. package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
  46. package/dist/runtime/useStreamingTiming.d.ts +2 -3
  47. package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
  48. package/dist/tools/frontendTools.d.ts +3 -4
  49. package/dist/tools/frontendTools.d.ts.map +1 -1
  50. package/dist/tools/generativeTools.d.ts +5 -6
  51. package/dist/tools/generativeTools.d.ts.map +1 -1
  52. package/dist/tools/mcp-stdio.unsupported.d.ts +1 -2
  53. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  54. package/dist/transport/AssistantChatTransport.d.ts +3 -4
  55. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  56. package/dist/transport/resumable.d.ts +4 -5
  57. package/dist/transport/resumable.d.ts.map +1 -1
  58. package/dist/usage.d.ts +4 -5
  59. package/dist/usage.d.ts.map +1 -1
  60. package/dist/utils/getVercelAIMessages.d.ts +1 -2
  61. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  62. package/dist/utils/sliceMessagesUntil.d.ts +1 -2
  63. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  64. package/package.json +15 -14
  65. package/src/converters/convertMessage.test.ts +138 -0
  66. package/src/converters/convertMessage.ts +94 -5
  67. package/src/runtime/AISDKChat.test.ts +4 -5
  68. package/src/runtime/AISDKThreads.test.ts +9 -5
  69. package/src/runtime/AISDKThreads.ts +3 -12
  70. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +287 -0
  71. package/src/runtime/useAISDKRuntime.approval.test.tsx +257 -1
  72. package/src/runtime/useAISDKRuntime.test.ts +7 -3
  73. package/src/runtime/useAISDKRuntime.ts +147 -23
  74. package/src/runtime/useAISDKRuntime.voice.test.tsx +270 -0
  75. package/src/runtime/useChatRuntime.test.ts +72 -4
  76. package/src/runtime/useChatThread.ts +44 -5
  77. package/src/runtime/useExternalHistory.test.ts +46 -0
  78. package/src/runtime/useExternalHistory.ts +24 -10
  79. package/src/tools/generativeTools.test.ts +7 -1
@@ -0,0 +1,287 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { useChat } from "@ai-sdk/react";
5
+ import {
6
+ lastAssistantMessageIsCompleteWithApprovalResponses,
7
+ type ChatTransport,
8
+ type UIMessage,
9
+ type UIMessageChunk,
10
+ } from "ai";
11
+ import { describe, expect, it, vi } from "vitest";
12
+ import { useAISDKRuntime } from "./useAISDKRuntime";
13
+
14
+ type ApprovalHandler = NonNullable<
15
+ NonNullable<Parameters<typeof useAISDKRuntime>[1]>["onRespondToToolApproval"]
16
+ >;
17
+
18
+ const streamOf = (chunks: UIMessageChunk[]) =>
19
+ new ReadableStream<UIMessageChunk>({
20
+ start(controller) {
21
+ for (const chunk of chunks) controller.enqueue(chunk);
22
+ controller.close();
23
+ },
24
+ });
25
+
26
+ const approvalStep = (extra: UIMessageChunk[] = []): UIMessageChunk[] => [
27
+ { type: "start", messageId: "assistant-1" },
28
+ { type: "start-step" },
29
+ {
30
+ type: "tool-input-available",
31
+ toolCallId: "tool-1",
32
+ toolName: "deploy",
33
+ input: {},
34
+ },
35
+ {
36
+ type: "tool-approval-request",
37
+ approvalId: "approval-1",
38
+ toolCallId: "tool-1",
39
+ },
40
+ ...extra,
41
+ { type: "finish-step" },
42
+ { type: "finish" },
43
+ ];
44
+
45
+ const toolOutput: UIMessageChunk[] = [
46
+ { type: "start" },
47
+ { type: "tool-output-available", toolCallId: "tool-1", output: "deployed" },
48
+ { type: "finish" },
49
+ ];
50
+
51
+ const setup = async (
52
+ createHandler: (chat: () => ReturnType<typeof useChat>) => ApprovalHandler,
53
+ {
54
+ messages,
55
+ request = approvalStep(),
56
+ continuation = () => streamOf(toolOutput),
57
+ }: {
58
+ messages?: UIMessage[];
59
+ request?: UIMessageChunk[];
60
+ continuation?: () => ReadableStream<UIMessageChunk>;
61
+ } = {},
62
+ ) => {
63
+ let requests = 0;
64
+ const sendMessages = vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
65
+ async () => {
66
+ requests += 1;
67
+ return requests === 1 && !messages ? streamOf(request) : continuation();
68
+ },
69
+ );
70
+ const sendAutomaticallyWhen = vi.fn(
71
+ lastAssistantMessageIsCompleteWithApprovalResponses,
72
+ );
73
+
74
+ let handler: ApprovalHandler | undefined;
75
+ const { result } = renderHook(() => {
76
+ const chat = useChat({
77
+ id: "chat-1",
78
+ ...(messages && { messages }),
79
+ transport: { sendMessages, reconnectToStream: async () => null },
80
+ sendAutomaticallyWhen,
81
+ });
82
+ return {
83
+ chat,
84
+ runtime: useAISDKRuntime(chat, {
85
+ onRespondToToolApproval: (response, context) =>
86
+ handler?.(response, context),
87
+ }),
88
+ };
89
+ });
90
+ const chat = () => result.current.chat;
91
+ handler = createHandler(chat);
92
+
93
+ if (!messages) {
94
+ await act(() => chat().sendMessage({ text: "deploy" }));
95
+ await waitFor(() => expect(chat().status).toBe("ready"));
96
+ }
97
+
98
+ const part = () =>
99
+ result.current.runtime.thread
100
+ .getMessageByIndex(1)
101
+ .getMessagePartByToolCallId("tool-1");
102
+
103
+ return {
104
+ chat,
105
+ part,
106
+ approval: () =>
107
+ (part().getState() as { approval?: Record<string, unknown> }).approval,
108
+ toolPart: () =>
109
+ chat()
110
+ .messages.flatMap((message) => message.parts)
111
+ .find((candidate) => candidate.type === "tool-deploy"),
112
+ sendMessages,
113
+ sendAutomaticallyWhen,
114
+ respond: (approved = true) =>
115
+ act(() => part().respondToToolApproval({ approved })),
116
+ };
117
+ };
118
+
119
+ describe("useAISDKRuntime tool approvals with a Chat", () => {
120
+ it("applies a host answer without writing it into the chat", async () => {
121
+ const { approval, toolPart, sendMessages, sendAutomaticallyWhen, respond } =
122
+ await setup(() => async () => {});
123
+ const automaticSendChecks = sendAutomaticallyWhen.mock.calls.length;
124
+
125
+ await respond();
126
+
127
+ expect(approval()).toMatchObject({ id: "approval-1", approved: true });
128
+ expect(toolPart()).toMatchObject({ state: "approval-requested" });
129
+ expect(sendAutomaticallyWhen).toHaveBeenCalledTimes(automaticSendChecks);
130
+ expect(sendMessages).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it("keeps a host answer out of the chat's automatic sends", async () => {
134
+ const { chat, sendMessages, respond } = await setup(() => async () => {}, {
135
+ request: approvalStep([
136
+ {
137
+ type: "tool-input-available",
138
+ toolCallId: "tool-2",
139
+ toolName: "lookup",
140
+ input: {},
141
+ },
142
+ ]),
143
+ });
144
+
145
+ await respond();
146
+ await act(async () => {
147
+ await chat().addToolOutput({
148
+ tool: "lookup",
149
+ toolCallId: "tool-2",
150
+ output: "found",
151
+ } as never);
152
+ await new Promise((resolve) => setTimeout(resolve, 0));
153
+ });
154
+
155
+ expect(sendMessages).toHaveBeenCalledTimes(1);
156
+ });
157
+
158
+ it("reopens the request when the handler throws", async () => {
159
+ const { approval, respond } = await setup(() => async () => {
160
+ throw new Error("resume failed");
161
+ });
162
+
163
+ await expect(respond()).rejects.toThrow("resume failed");
164
+
165
+ expect(approval()).toMatchObject({ id: "approval-1" });
166
+ expect(approval()).not.toHaveProperty("approved");
167
+ });
168
+
169
+ it("keeps the answer on a run the handler continues on the chat", async () => {
170
+ let stream!: ReadableStreamDefaultController<UIMessageChunk>;
171
+ const { approval, toolPart, respond } = await setup(
172
+ (chat) => async () => {
173
+ void chat().sendMessage();
174
+ },
175
+ {
176
+ continuation: () =>
177
+ new ReadableStream<UIMessageChunk>({
178
+ start(controller) {
179
+ stream = controller;
180
+ },
181
+ }),
182
+ },
183
+ );
184
+
185
+ await respond();
186
+ await act(async () => {
187
+ stream.enqueue({ type: "start" });
188
+ stream.enqueue({ type: "text-start", id: "text-1" });
189
+ stream.enqueue({ type: "text-delta", id: "text-1", delta: "Deploying" });
190
+ });
191
+ expect(approval()).toMatchObject({ id: "approval-1", approved: true });
192
+
193
+ await act(async () => {
194
+ stream.enqueue({ type: "text-end", id: "text-1" });
195
+ for (const chunk of toolOutput.slice(1)) stream.enqueue(chunk);
196
+ stream.close();
197
+ });
198
+ await waitFor(() =>
199
+ expect(toolPart()).toMatchObject({ state: "output-available" }),
200
+ );
201
+ expect(approval()).toMatchObject({ id: "approval-1", approved: true });
202
+ });
203
+
204
+ it("delivers one decision when a request is answered twice at once", async () => {
205
+ const decisions: boolean[] = [];
206
+ const releases: (() => void)[] = [];
207
+ const { approval, part } = await setup(() => async ({ approved }) => {
208
+ decisions.push(approved);
209
+ await new Promise<void>((resolve) => releases.push(resolve));
210
+ });
211
+
212
+ let approve: Promise<void> | undefined;
213
+ let deny: Promise<void> | undefined;
214
+ act(() => {
215
+ approve = part().respondToToolApproval({ approved: true });
216
+ deny = part().respondToToolApproval({ approved: false });
217
+ });
218
+ await expect(deny).rejects.toThrow(
219
+ "Tool approval approval-1 is not waiting for a response.",
220
+ );
221
+ for (const release of releases) release();
222
+ await act(() => approve);
223
+
224
+ expect(decisions).toEqual([true]);
225
+ expect(approval()).toMatchObject({ id: "approval-1", approved: true });
226
+ });
227
+
228
+ it("continues the run through the AI SDK for a request handed back", async () => {
229
+ const { toolPart, sendMessages, respond } = await setup(
230
+ () =>
231
+ (_response, { respondViaAISDK }) =>
232
+ respondViaAISDK(),
233
+ );
234
+
235
+ await respond();
236
+
237
+ await waitFor(() =>
238
+ expect(toolPart()).toMatchObject({
239
+ state: "output-available",
240
+ approval: { id: "approval-1", approved: true },
241
+ output: "deployed",
242
+ }),
243
+ );
244
+ expect(sendMessages).toHaveBeenCalledTimes(2);
245
+ });
246
+
247
+ it("leaves a request handed back to the AI SDK as the AI SDK does", async () => {
248
+ const { approval, toolPart, sendMessages, respond } = await setup(
249
+ () =>
250
+ (_response, { respondViaAISDK }) =>
251
+ respondViaAISDK(),
252
+ {
253
+ messages: [
254
+ {
255
+ id: "user-1",
256
+ role: "user",
257
+ parts: [{ type: "text", text: "deploy" }],
258
+ },
259
+ {
260
+ id: "assistant-1",
261
+ role: "assistant",
262
+ parts: [
263
+ {
264
+ type: "tool-deploy",
265
+ toolCallId: "tool-1",
266
+ state: "approval-requested",
267
+ input: {},
268
+ approval: { id: "approval-1" },
269
+ },
270
+ ],
271
+ },
272
+ {
273
+ id: "assistant-2",
274
+ role: "assistant",
275
+ parts: [{ type: "text", text: "Waiting for approval." }],
276
+ },
277
+ ],
278
+ },
279
+ );
280
+
281
+ await respond();
282
+
283
+ expect(toolPart()).toMatchObject({ state: "approval-requested" });
284
+ expect(approval()).not.toHaveProperty("approved");
285
+ expect(sendMessages).not.toHaveBeenCalled();
286
+ });
287
+ });
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { renderHook } from "@testing-library/react";
3
+ import { act, renderHook } from "@testing-library/react";
4
4
  import type { ExternalStoreAdapter } from "@assistant-ui/core";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
 
@@ -66,4 +66,260 @@ describe("useAISDKRuntime tool approvals", () => {
66
66
  options: { metadata: undefined },
67
67
  });
68
68
  });
69
+
70
+ const setupPendingApproval = (
71
+ onRespondToToolApproval: NonNullable<
72
+ Parameters<typeof useAISDKRuntime>[1]
73
+ >["onRespondToToolApproval"],
74
+ ) => {
75
+ const messages = [
76
+ {
77
+ id: "message-1",
78
+ role: "assistant",
79
+ parts: [
80
+ {
81
+ type: "tool-deploy",
82
+ toolCallId: "tool-1",
83
+ state: "approval-requested",
84
+ input: {},
85
+ approval: { id: "approval-1" },
86
+ },
87
+ ],
88
+ },
89
+ ];
90
+ const setMessages = vi.fn();
91
+ const addToolApprovalResponse = vi.fn();
92
+ const chat = {
93
+ id: "chat-1",
94
+ status: "ready",
95
+ error: undefined,
96
+ messages,
97
+ setMessages,
98
+ sendMessage: vi.fn(),
99
+ regenerate: vi.fn(),
100
+ addToolOutput: vi.fn(),
101
+ addToolApprovalResponse,
102
+ stop: vi.fn(),
103
+ };
104
+
105
+ renderHook(() =>
106
+ useAISDKRuntime(chat as never, { onRespondToToolApproval }),
107
+ );
108
+
109
+ return {
110
+ respond: (response: {
111
+ approvalId: string;
112
+ approved: boolean;
113
+ optionId?: string;
114
+ text?: string;
115
+ reason?: string;
116
+ }) => mocks.adapter?.onRespondToToolApproval?.(response),
117
+ setMessages,
118
+ addToolApprovalResponse,
119
+ getApproval: () =>
120
+ mocks.adapter?.messages?.[0]?.content.find(
121
+ (part) => part.type === "tool-call",
122
+ )?.approval,
123
+ };
124
+ };
125
+
126
+ it("hands the complete response to a custom handler and applies the answer", async () => {
127
+ const onRespondToToolApproval = vi.fn(async () => {});
128
+ const { respond, setMessages, addToolApprovalResponse, getApproval } =
129
+ setupPendingApproval(onRespondToToolApproval);
130
+
131
+ const response = {
132
+ approvalId: "approval-1",
133
+ approved: true,
134
+ optionId: "allow-session",
135
+ text: "Only for this environment",
136
+ reason: "Approved by operator",
137
+ };
138
+ await act(async () => {
139
+ await respond(response);
140
+ });
141
+
142
+ expect(onRespondToToolApproval).toHaveBeenCalledWith(response, {
143
+ toolCallId: "tool-1",
144
+ toolName: "deploy",
145
+ respondViaAISDK: expect.any(Function),
146
+ });
147
+ expect(addToolApprovalResponse).not.toHaveBeenCalled();
148
+ expect(setMessages).not.toHaveBeenCalled();
149
+ expect(getApproval()).toEqual({
150
+ id: "approval-1",
151
+ approved: true,
152
+ reason: "Approved by operator",
153
+ optionId: "allow-session",
154
+ text: "Only for this environment",
155
+ });
156
+ });
157
+
158
+ it("sends a request the handler hands back through the AI SDK", async () => {
159
+ const { respond, addToolApprovalResponse } = setupPendingApproval(
160
+ (_response, { respondViaAISDK }) => respondViaAISDK(),
161
+ );
162
+
163
+ await act(async () => {
164
+ await respond({
165
+ approvalId: "approval-1",
166
+ approved: false,
167
+ optionId: "reject-once",
168
+ reason: "Not now",
169
+ });
170
+ });
171
+
172
+ expect(addToolApprovalResponse).toHaveBeenCalledWith({
173
+ id: "approval-1",
174
+ approved: false,
175
+ reason: "Not now",
176
+ options: { metadata: undefined },
177
+ });
178
+ });
179
+
180
+ it("reopens a request when a handed-back AI SDK response fails inside the handler", async () => {
181
+ const { respond, addToolApprovalResponse, getApproval } =
182
+ setupPendingApproval(async (_response, { respondViaAISDK }) => {
183
+ await respondViaAISDK().catch(() => {});
184
+ });
185
+ addToolApprovalResponse.mockRejectedValueOnce(new Error("offline"));
186
+
187
+ await act(async () => {
188
+ await respond({ approvalId: "approval-1", approved: true });
189
+ });
190
+
191
+ expect(getApproval()).toEqual({ id: "approval-1" });
192
+ await act(async () => {
193
+ await respond({ approvalId: "approval-1", approved: true });
194
+ });
195
+ expect(addToolApprovalResponse).toHaveBeenCalledTimes(2);
196
+ });
197
+
198
+ it("keeps a host answer when the runtime switches chats and back", async () => {
199
+ const chatWith = (id: string, approvalId: string) => ({
200
+ id,
201
+ status: "ready",
202
+ error: undefined,
203
+ messages: [
204
+ {
205
+ id: `message-${id}`,
206
+ role: "assistant",
207
+ parts: [
208
+ {
209
+ type: "tool-deploy",
210
+ toolCallId: `tool-${id}`,
211
+ state: "approval-requested",
212
+ input: {},
213
+ approval: { id: approvalId },
214
+ },
215
+ ],
216
+ },
217
+ ],
218
+ setMessages: vi.fn(),
219
+ sendMessage: vi.fn(),
220
+ regenerate: vi.fn(),
221
+ addToolOutput: vi.fn(),
222
+ addToolApprovalResponse: vi.fn(),
223
+ stop: vi.fn(),
224
+ });
225
+ const chatA = chatWith("chat-a", "approval-a");
226
+ const chatB = chatWith("chat-b", "approval-b");
227
+ const onRespondToToolApproval = vi.fn(async () => {});
228
+ const { rerender } = renderHook(
229
+ ({ chat }: { chat: typeof chatA }) =>
230
+ useAISDKRuntime(chat as never, { onRespondToToolApproval }),
231
+ { initialProps: { chat: chatA } },
232
+ );
233
+ const respond = (approvalId: string) =>
234
+ act(async () => {
235
+ await mocks.adapter?.onRespondToToolApproval?.({
236
+ approvalId,
237
+ approved: true,
238
+ });
239
+ });
240
+ const getApproval = () =>
241
+ mocks.adapter?.messages?.[0]?.content.find(
242
+ (part) => part.type === "tool-call",
243
+ )?.approval;
244
+
245
+ await respond("approval-a");
246
+ rerender({ chat: chatB });
247
+ await respond("approval-b");
248
+ rerender({ chat: chatA });
249
+
250
+ expect(getApproval()).toEqual({ id: "approval-a", approved: true });
251
+ await expect(respond("approval-a")).rejects.toThrow(
252
+ "Tool approval approval-a is not waiting for a response.",
253
+ );
254
+ expect(onRespondToToolApproval).toHaveBeenCalledTimes(2);
255
+ });
256
+
257
+ it("rejects an approval that is not waiting for a response", async () => {
258
+ const onRespondToToolApproval = vi.fn();
259
+ const { respond } = setupPendingApproval(onRespondToToolApproval);
260
+
261
+ await expect(
262
+ respond({ approvalId: "approval-2", approved: true }),
263
+ ).rejects.toThrow(
264
+ "Tool approval approval-2 is not waiting for a response.",
265
+ );
266
+ expect(onRespondToToolApproval).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it("updates the rendered approval shape with the response channel", () => {
270
+ const onRespondToToolApproval = vi.fn();
271
+ const chat = {
272
+ id: "chat-1",
273
+ status: "ready",
274
+ error: undefined,
275
+ messages: [
276
+ {
277
+ id: "message-1",
278
+ role: "assistant",
279
+ parts: [
280
+ {
281
+ type: "tool-deploy",
282
+ toolCallId: "tool-1",
283
+ state: "approval-requested",
284
+ input: {},
285
+ approval: {
286
+ id: "approval-1",
287
+ display: "select",
288
+ options: [{ id: "allow-session", kind: "allow-once" }],
289
+ },
290
+ },
291
+ ],
292
+ },
293
+ ],
294
+ setMessages: vi.fn(),
295
+ sendMessage: vi.fn(),
296
+ regenerate: vi.fn(),
297
+ addToolOutput: vi.fn(),
298
+ addToolApprovalResponse: vi.fn(),
299
+ stop: vi.fn(),
300
+ };
301
+
302
+ const { rerender } = renderHook(
303
+ ({ useCustomHandler }: { useCustomHandler: boolean }) =>
304
+ useAISDKRuntime(chat as never, {
305
+ ...(useCustomHandler && { onRespondToToolApproval }),
306
+ }),
307
+ { initialProps: { useCustomHandler: false } },
308
+ );
309
+
310
+ const getApproval = () =>
311
+ mocks.adapter?.messages?.[0]?.content.find(
312
+ (part) => part.type === "tool-call",
313
+ )?.approval;
314
+
315
+ expect(getApproval()).toEqual({ id: "approval-1" });
316
+
317
+ rerender({ useCustomHandler: true });
318
+
319
+ expect(getApproval()).toEqual({
320
+ id: "approval-1",
321
+ display: "select",
322
+ options: [{ id: "allow-session", kind: "allow-once" }],
323
+ });
324
+ });
69
325
  });
@@ -3,6 +3,8 @@
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 type { UIMessage } from "@ai-sdk/react";
7
+ import type { MessageFormatRepository } from "@assistant-ui/core";
6
8
 
7
9
  // Mock only the sibling module that requires AUI store context (not available
8
10
  // in isolation). Every other dependency — useExternalStoreRuntime,
@@ -981,7 +983,7 @@ describe("useAISDKRuntime", () => {
981
983
  it("imports a message tree without replacing the chat feed", async () => {
982
984
  const chat = createChatHelpers();
983
985
  const onBranchChange = vi.fn();
984
- const messageRepository = {
986
+ const messageRepository: MessageFormatRepository<UIMessage> = {
985
987
  headId: "a2",
986
988
  messages: [
987
989
  {
@@ -1077,7 +1079,7 @@ describe("useAISDKRuntime", () => {
1077
1079
  const chat = createChatHelpers([
1078
1080
  { id: "live", role: "user", parts: [{ type: "text", text: "keep me" }] },
1079
1081
  ]);
1080
- const messageRepository = {
1082
+ const messageRepository: MessageFormatRepository<UIMessage> = {
1081
1083
  headId: "a1",
1082
1084
  messages: [
1083
1085
  {
@@ -1115,7 +1117,9 @@ describe("useAISDKRuntime", () => {
1115
1117
 
1116
1118
  it("does not reseed when the repository object identity changes", async () => {
1117
1119
  const chat = createChatHelpers();
1118
- const makeRepository = (text: string) => ({
1120
+ const makeRepository = (
1121
+ text: string,
1122
+ ): MessageFormatRepository<UIMessage> => ({
1119
1123
  headId: "a1",
1120
1124
  messages: [
1121
1125
  {