@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
@@ -24,6 +24,7 @@ import {
24
24
  type McpAppMetadata,
25
25
  type MessagePartStreamStatus,
26
26
  type RespondToToolApprovalOptions,
27
+ type Unstable_ToolInteractionLog,
27
28
  } from "@assistant-ui/core";
28
29
  import { stableStringifyToolArgs } from "@assistant-ui/core/internal";
29
30
  import {
@@ -49,7 +50,7 @@ const THREAD_METADATA_KEYS = new Set([
49
50
  const toThreadMetadata = (metadata: unknown): MessageMetadata => {
50
51
  if (!metadata || typeof metadata !== "object") return undefined;
51
52
  const result: Record<string, unknown> = {};
52
- const extra: Record<string, unknown> = {};
53
+ const extra = Object.create(null) as Record<string, unknown>;
53
54
  for (const [key, value] of Object.entries(metadata)) {
54
55
  (THREAD_METADATA_KEYS.has(key) ? result : extra)[key] = value;
55
56
  }
@@ -61,8 +62,18 @@ const toThreadMetadata = (metadata: unknown): MessageMetadata => {
61
62
  export type AISDKMessageConverterMetadata =
62
63
  useExternalMessageConverter.Metadata & {
63
64
  toolArgsKeyOrderCache?: Map<string, Map<string, string[]>>;
65
+ /**
66
+ * Frozen `argsText` keyed weakly by a settled tool call's input object, then
67
+ * by call, since the text carries the call's streamed key order. A known
68
+ * call/input pair skips serialization; the entries become collectible once
69
+ * the input is unreachable. A fresh input object re-serializes in its own
70
+ * deterministic key order.
71
+ */
72
+ toolArgsTextCache?: WeakMap<ReadonlyJSONObject, Map<string, string>>;
64
73
  toolLastInputCache?: Map<string, ReadonlyJSONObject>;
65
74
  mcpAppMetadataCache?: Map<string, McpAppMetadata>;
75
+ toolArtifacts?: ReadonlyMap<string, unknown>;
76
+ toolInteractions?: ReadonlyMap<string, Unstable_ToolInteractionLog>;
66
77
  supportsRichToolApprovalResponses?: boolean;
67
78
  toolApprovalResponses?: ReadonlyMap<string, RespondToToolApprovalOptions>;
68
79
  /** Id of the currently-streaming message, flagged optimistic (#4037). */
@@ -206,6 +217,38 @@ const normalizeToolApprovalOptions = (
206
217
  });
207
218
  };
208
219
 
220
+ const APPROVAL_DESCRIPTOR_FIELDS = [
221
+ "prompt",
222
+ "display",
223
+ "allowFreeform",
224
+ "dismissible",
225
+ "options",
226
+ "optionId",
227
+ "text",
228
+ "resolution",
229
+ ] as const;
230
+
231
+ // The AI SDK's approval object declares none of the core request and answer
232
+ // fields and `validateUIMessages` strips unknown ones, so a host streams or
233
+ // persists them inside the opaque `approvalDescriptor`. Only those fields are
234
+ // read from it: a descriptor cannot approve its own request.
235
+ const readApprovalDescriptor = (
236
+ descriptor: unknown,
237
+ ): Record<string, unknown> => {
238
+ if (
239
+ !descriptor ||
240
+ typeof descriptor !== "object" ||
241
+ Array.isArray(descriptor)
242
+ )
243
+ return {};
244
+ const fields: Record<string, unknown> = {};
245
+ for (const key of APPROVAL_DESCRIPTOR_FIELDS) {
246
+ if (Object.hasOwn(descriptor, key))
247
+ fields[key] = (descriptor as Record<string, unknown>)[key];
248
+ }
249
+ return fields;
250
+ };
251
+
209
252
  function getToolApprovalAndInterrupt(
210
253
  part: {
211
254
  approval?: Record<string, unknown> | undefined;
@@ -220,12 +263,16 @@ function getToolApprovalAndInterrupt(
220
263
  interrupt?: NonNullable<ToolCallMessagePart["interrupt"]>;
221
264
  } {
222
265
  if (part.approval) {
266
+ const approval = {
267
+ ...readApprovalDescriptor(part.approval.descriptor),
268
+ ...part.approval,
269
+ };
223
270
  const response =
224
- typeof part.approval.id === "string" &&
225
- part.approval.approved === undefined &&
226
- part.approval.resolution !== "cancelled" &&
227
- part.approval.resolution !== "expired"
228
- ? toolApprovalResponses?.get(part.approval.id)
271
+ typeof approval.id === "string" &&
272
+ approval.approved === undefined &&
273
+ approval.resolution !== "cancelled" &&
274
+ approval.resolution !== "expired"
275
+ ? toolApprovalResponses?.get(approval.id)
229
276
  : undefined;
230
277
  // The built-in AI SDK channel sends only id, approved and reason back to
231
278
  // the server, so a request shape promising any other answer would render
@@ -239,19 +286,20 @@ function getToolApprovalAndInterrupt(
239
286
  resolution,
240
287
  display,
241
288
  allowFreeform,
289
+ dismissible,
242
290
  options,
243
291
  optionId,
244
292
  text,
245
293
  ...additionalApprovalFields
246
294
  } = response
247
295
  ? {
248
- ...part.approval,
296
+ ...approval,
249
297
  approved: response.approved,
250
298
  ...(response.reason != null && { reason: response.reason }),
251
299
  ...(response.optionId != null && { optionId: response.optionId }),
252
300
  ...(response.text != null && { text: response.text }),
253
301
  }
254
- : part.approval;
302
+ : approval;
255
303
  const normalizedOptions = supportsRichToolApprovalResponses
256
304
  ? normalizeToolApprovalOptions(options)
257
305
  : undefined;
@@ -274,6 +322,7 @@ function getToolApprovalAndInterrupt(
274
322
  display === "select" ||
275
323
  display === "text") && { display }),
276
324
  ...(typeof allowFreeform === "boolean" && { allowFreeform }),
325
+ ...(typeof dismissible === "boolean" && { dismissible }),
277
326
  ...(normalizedOptions && { options: normalizedOptions }),
278
327
  ...(typeof optionId === "string" && { optionId }),
279
328
  ...(typeof text === "string" && { text }),
@@ -354,7 +403,14 @@ function convertParts(
354
403
  const toolCallId = part.toolCallId;
355
404
  const argsKeyOrderCacheKey = `${message.id}:${toolCallId}`;
356
405
 
357
- const rawInput = part.input as ReadonlyJSONObject | null | undefined;
406
+ // A tool call that streamed complete arguments then failed schema
407
+ // validation keeps them in `rawInput`, not `input`; reading `input`
408
+ // alone would convert the error snapshot to `{}` and hide the input.
409
+ const rawInput = (part.input ??
410
+ ("rawInput" in part ? part.rawInput : undefined)) as
411
+ | ReadonlyJSONObject
412
+ | null
413
+ | undefined;
358
414
  let args: ReadonlyJSONObject;
359
415
  if (
360
416
  rawInput != null &&
@@ -387,12 +443,13 @@ function convertParts(
387
443
  };
388
444
  }
389
445
 
390
- let argsText = stableStringifyToolArgs(
391
- metadata.toolArgsKeyOrderCache,
392
- argsKeyOrderCacheKey,
393
- args,
394
- );
446
+ let argsText: string;
395
447
  if (part.state === "input-streaming") {
448
+ argsText = stableStringifyToolArgs(
449
+ metadata.toolArgsKeyOrderCache,
450
+ argsKeyOrderCacheKey,
451
+ args,
452
+ );
396
453
  // strip closing delimiters added by the AI SDK's fix-json
397
454
  argsText = stripClosingDelimiters(argsText);
398
455
  // Re-parse so args carries the partial-JSON meta that marks which
@@ -401,6 +458,27 @@ function convertParts(
401
458
  // of the stripped text is the streaming frontier.
402
459
  args = parsePartialJsonObject(argsText) ?? args;
403
460
  } else {
461
+ // A settled part is re-converted whenever its message or the converter
462
+ // metadata changes; the text frozen on its input object skips
463
+ // re-serializing large args while the call keeps that input. Arrival
464
+ // order only matters while args stream, so the key-order entry is
465
+ // released.
466
+ const frozen =
467
+ metadata.toolArgsTextCache?.get(args) ?? new Map<string, string>();
468
+ const frozenText = frozen.get(argsKeyOrderCacheKey);
469
+ if (frozenText !== undefined) {
470
+ argsText = frozenText;
471
+ } else {
472
+ argsText = stableStringifyToolArgs(
473
+ metadata.toolArgsKeyOrderCache,
474
+ argsKeyOrderCacheKey,
475
+ args,
476
+ );
477
+ metadata.toolArgsTextCache?.set(
478
+ args,
479
+ frozen.set(argsKeyOrderCacheKey, argsText),
480
+ );
481
+ }
404
482
  metadata.toolArgsKeyOrderCache?.delete(argsKeyOrderCacheKey);
405
483
  if (
406
484
  part.state === "output-available" ||
@@ -416,6 +494,8 @@ function convertParts(
416
494
  part,
417
495
  metadata.mcpAppMetadataCache,
418
496
  );
497
+ const artifact = metadata.toolArtifacts?.get(toolCallId);
498
+ const interactions = metadata.toolInteractions?.get(toolCallId);
419
499
  return {
420
500
  type: "tool-call",
421
501
  toolName,
@@ -424,6 +504,12 @@ function convertParts(
424
504
  args,
425
505
  result,
426
506
  isError,
507
+ ...(artifact !== undefined && { artifact }),
508
+ ...(interactions !== undefined && {
509
+ unstable_interactions: interactions,
510
+ }),
511
+ ...(part.state === "output-available" &&
512
+ part.preliminary === true && { isPreliminary: true }),
427
513
  ...(modelContent !== undefined && { modelContent }),
428
514
  ...(mcpApp && { mcp: { app: mcpApp } }),
429
515
  ...(part.callProviderMetadata != null
@@ -1,4 +1,4 @@
1
- /// <reference types="@assistant-ui/core/react" />
1
+ /// <reference types="@assistant-ui/core/react" preserve="true" />
2
2
 
3
3
  export { useAISDKRuntime } from "./runtime/useAISDKRuntime";
4
4
  export { useChatRuntime } from "./runtime/useChatRuntime";
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- /// <reference types="@assistant-ui/core/react" />
1
+ /// <reference types="@assistant-ui/core/react" preserve="true" />
2
2
 
3
3
  export { useAISDKRuntime } from "./runtime/useAISDKRuntime";
4
4
  export { useChatRuntime } from "./runtime/useChatRuntime";
@@ -1,8 +1,15 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
+ import { getEventListeners } from "node:events";
3
4
  import { describe, expect, it, vi } from "vitest";
4
- import { flushTapSync } from "@assistant-ui/tap";
5
- import { AuiConfig, createAssistantClient } from "@assistant-ui/store/client";
5
+ import { flushTapSync, resource, useResource } from "@assistant-ui/tap";
6
+ import {
7
+ attachTransformScopes,
8
+ AuiConfig,
9
+ createAssistantClient,
10
+ } from "@assistant-ui/store/client";
11
+ import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
12
+ import { inMemoryThreadListTransformScopes } from "@assistant-ui/core/store";
6
13
  import type { AssistantCloud } from "assistant-cloud";
7
14
  import type { RemoteThreadListAdapter } from "@assistant-ui/core";
8
15
  import type { ThreadHistoryAdapter } from "@assistant-ui/core";
@@ -152,4 +159,55 @@ describe("AISDKThreads cloud", () => {
152
159
  handle.destroy();
153
160
  }
154
161
  });
162
+
163
+ it("keeps cloud threads off the client destroy signal and stops them on destroy", async () => {
164
+ const chat = createCancellableTransport();
165
+ let destroySignal: AbortSignal | undefined;
166
+ function useThreads() {
167
+ destroySignal = useAssistantClientDestroySignal();
168
+ return useResource(
169
+ AISDKThreads({
170
+ cloud: {} as AssistantCloud,
171
+ threadId: "t1",
172
+ transport: () => chat.transport,
173
+ }),
174
+ );
175
+ }
176
+ attachTransformScopes(useThreads, inMemoryThreadListTransformScopes);
177
+ const handle = createAssistantClient(
178
+ AuiConfig({ threads: resource(useThreads)() }),
179
+ );
180
+ handle.subscribe(() => {});
181
+ const listeners = () => getEventListeners(destroySignal!, "abort").length;
182
+ try {
183
+ await handle.getClient().threads.getLoadThreadsPromise();
184
+ await vi.waitFor(() => {
185
+ expect(handle.getClient().threads.getState().mainThreadId).toBe("t1");
186
+ });
187
+ await vi.waitFor(() => {
188
+ expect(handle.getClient().thread.getState().isLoading).toBe(false);
189
+ });
190
+ const withFirstThread = listeners();
191
+
192
+ flushTapSync(() => handle.getClient().threads.switchToThread("t2"));
193
+ await vi.waitFor(() => {
194
+ expect(handle.getClient().threads.getState().mainThreadId).toBe("t2");
195
+ });
196
+ await vi.waitFor(() => {
197
+ expect(handle.getClient().thread.getState().isLoading).toBe(false);
198
+ });
199
+ expect(listeners()).toBe(withFirstThread);
200
+
201
+ flushTapSync(() => handle.getClient().composer.setText("stream me"));
202
+ flushTapSync(() => handle.getClient().composer.send());
203
+ await vi.waitFor(() => {
204
+ expect(handle.getClient().thread.getState().isRunning).toBe(true);
205
+ });
206
+ } finally {
207
+ handle.destroy();
208
+ }
209
+ await vi.waitFor(() => {
210
+ expect(chat.getCancelCount()).toBe(1);
211
+ });
212
+ });
155
213
  });
@@ -28,6 +28,33 @@ const threadText = (aui: ReturnType<typeof createAssistantClient>) =>
28
28
  m.content.map((part) => (part.type === "text" ? part.text : "")).join(""),
29
29
  );
30
30
 
31
+ const createLiveHandle = (
32
+ getOptions: () => Parameters<typeof AISDKThreads>[0],
33
+ ) => {
34
+ const listeners = new Set<() => void>();
35
+ const handle = createAssistantClient({
36
+ getConfig: () => AuiConfig({ threads: AISDKThreads(getOptions()) }),
37
+ subscribe: (listener) => {
38
+ listeners.add(listener);
39
+ return () => listeners.delete(listener);
40
+ },
41
+ });
42
+ handle.subscribe(() => {});
43
+ return {
44
+ handle,
45
+ rerender: () =>
46
+ flushTapSync(() => listeners.forEach((listener) => listener())),
47
+ };
48
+ };
49
+
50
+ const chatCallbacks = () => ({
51
+ onToolCall: vi.fn(),
52
+ onData: vi.fn(),
53
+ onFinish: vi.fn(),
54
+ onError: vi.fn(),
55
+ sendAutomaticallyWhen: vi.fn(() => false),
56
+ });
57
+
31
58
  describe("AISDKThreads", () => {
32
59
  it("runs one chat per thread and keeps histories isolated across switches", async () => {
33
60
  const { transport, emit, close } = createControlledTransport();
@@ -253,25 +280,19 @@ describe("AISDKThreads", () => {
253
280
 
254
281
  it("forwards ChatInit callbacks to each thread's chat from the latest render", async () => {
255
282
  const { transport, emit, close } = createControlledTransport();
256
- const onFinishA = vi.fn();
257
- const onFinishB = vi.fn();
258
- let onFinish = onFinishA;
259
- const listeners = new Set<() => void>();
260
- const handle = createAssistantClient({
261
- getConfig: () =>
262
- AuiConfig({
263
- threads: AISDKThreads({ transport: () => transport, onFinish }),
264
- }),
265
- subscribe: (listener) => {
266
- listeners.add(listener);
267
- return () => listeners.delete(listener);
268
- },
269
- });
270
- handle.subscribe(() => {});
283
+ const sendMessages = vi.spyOn(transport, "sendMessages");
284
+ const mounted = chatCallbacks();
285
+ const latest = chatCallbacks();
286
+ latest.sendAutomaticallyWhen.mockReturnValueOnce(true);
287
+ let callbacks = mounted;
288
+ const { handle, rerender } = createLiveHandle(() => ({
289
+ transport: () => transport,
290
+ ...callbacks,
291
+ }));
271
292
  const aui = handle.getClient();
272
293
 
273
- onFinish = onFinishB;
274
- flushTapSync(() => listeners.forEach((listener) => listener()));
294
+ callbacks = latest;
295
+ rerender();
275
296
 
276
297
  flushTapSync(() => aui.composer.setText("hi"));
277
298
  flushTapSync(() => aui.composer.send());
@@ -280,10 +301,36 @@ describe("AISDKThreads", () => {
280
301
  handle.getClient().thread.getState().messages.length,
281
302
  ).toBeGreaterThan(0);
282
303
  });
283
- emit(...textReply("done"));
304
+ emit(
305
+ { type: "start" },
306
+ {
307
+ type: "tool-input-available",
308
+ toolCallId: "call-1",
309
+ toolName: "lookup",
310
+ input: {},
311
+ },
312
+ { type: "data-note", data: "note" },
313
+ { type: "finish" },
314
+ );
284
315
  close();
285
- await vi.waitFor(() => expect(onFinishB).toHaveBeenCalledTimes(1));
286
- expect(onFinishA).not.toHaveBeenCalled();
316
+ await vi.waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
317
+ emit({ type: "start" }, { type: "error", errorText: "boom" });
318
+ close();
319
+ await vi.waitFor(() => expect(latest.onFinish).toHaveBeenCalledTimes(2));
320
+
321
+ expect(latest.onToolCall).toHaveBeenCalledExactlyOnceWith({
322
+ toolCall: expect.objectContaining({ toolCallId: "call-1" }),
323
+ });
324
+ expect(latest.onData).toHaveBeenCalledExactlyOnceWith(
325
+ expect.objectContaining({ type: "data-note", data: "note" }),
326
+ );
327
+ expect(latest.onError).toHaveBeenCalledExactlyOnceWith(
328
+ expect.objectContaining({ message: "boom" }),
329
+ );
330
+ expect(latest.sendAutomaticallyWhen).toHaveBeenCalledOnce();
331
+ for (const callback of Object.values(mounted)) {
332
+ expect(callback).not.toHaveBeenCalled();
333
+ }
287
334
 
288
335
  handle.destroy();
289
336
  });
@@ -293,18 +340,10 @@ describe("AISDKThreads", () => {
293
340
  const onFinishA = vi.fn();
294
341
  const onFinishB = vi.fn();
295
342
  let onFinish = onFinishA;
296
- const listeners = new Set<() => void>();
297
- const handle = createAssistantClient({
298
- getConfig: () =>
299
- AuiConfig({
300
- threads: AISDKThreads({ transport: () => transport, onFinish }),
301
- }),
302
- subscribe: (listener) => {
303
- listeners.add(listener);
304
- return () => listeners.delete(listener);
305
- },
306
- });
307
- handle.subscribe(() => {});
343
+ const { handle, rerender } = createLiveHandle(() => ({
344
+ transport: () => transport,
345
+ onFinish,
346
+ }));
308
347
  const aui = handle.getClient();
309
348
 
310
349
  flushTapSync(() => aui.composer.setText("stream me"));
@@ -322,7 +361,7 @@ describe("AISDKThreads", () => {
322
361
 
323
362
  flushTapSync(() => aui.threads.switchToNewThread());
324
363
  onFinish = onFinishB;
325
- flushTapSync(() => listeners.forEach((listener) => listener()));
364
+ rerender();
326
365
 
327
366
  emit({ type: "text-end", id: "t1" }, { type: "finish" });
328
367
  close();
@@ -361,18 +400,12 @@ describe("AISDKThreads", () => {
361
400
  const onFinishA = vi.fn();
362
401
  const onFinishB = vi.fn();
363
402
  let onFinish = onFinishA;
364
- const listeners = new Set<() => void>();
365
- const handle = createAssistantClient({
366
- getConfig: () =>
367
- AuiConfig({
368
- threads: AISDKThreads({ cloud, threadId: "t1", transport, onFinish }),
369
- }),
370
- subscribe: (listener) => {
371
- listeners.add(listener);
372
- return () => listeners.delete(listener);
373
- },
374
- });
375
- handle.subscribe(() => {});
403
+ const { handle, rerender } = createLiveHandle(() => ({
404
+ cloud,
405
+ threadId: "t1",
406
+ transport,
407
+ onFinish,
408
+ }));
376
409
  try {
377
410
  await handle.getClient().threads.getLoadThreadsPromise();
378
411
  await vi.waitFor(() => {
@@ -383,7 +416,7 @@ describe("AISDKThreads", () => {
383
416
  });
384
417
 
385
418
  onFinish = onFinishB;
386
- flushTapSync(() => listeners.forEach((listener) => listener()));
419
+ rerender();
387
420
 
388
421
  flushTapSync(() => handle.getClient().composer.setText("hi"));
389
422
  flushTapSync(() => handle.getClient().composer.send());
@@ -162,7 +162,7 @@ const useAISDKChatThread = <UI_MESSAGE extends UIMessage = UIMessage>({
162
162
  : fallbackItem,
163
163
  chat,
164
164
  messageRepositoryInstance: repository,
165
- stopOnClientDestroy: cloud,
165
+ stopOnClientDestroy: false,
166
166
  },
167
167
  );
168
168
 
@@ -130,6 +130,57 @@ describe("useAISDKRuntime tool approvals with a Chat", () => {
130
130
  expect(sendMessages).toHaveBeenCalledTimes(1);
131
131
  });
132
132
 
133
+ it("renders a streamed request as its approvalDescriptor declares", async () => {
134
+ const descriptor = {
135
+ prompt: "Which environment?",
136
+ display: "select",
137
+ options: [{ id: "once", kind: "allow-once", label: "Staging once" }],
138
+ scope: "deploy",
139
+ };
140
+ const handler = vi.fn<ApprovalHandler>(async () => {});
141
+ const { approval, part, toolPart } = await setup(() => handler, {
142
+ request: [
143
+ { type: "start", messageId: "assistant-1" },
144
+ { type: "start-step" },
145
+ {
146
+ type: "tool-input-available",
147
+ toolCallId: "tool-1",
148
+ toolName: "deploy",
149
+ input: {},
150
+ },
151
+ {
152
+ type: "tool-approval-request",
153
+ approvalId: "approval-1",
154
+ toolCallId: "tool-1",
155
+ approvalDescriptor: descriptor,
156
+ },
157
+ { type: "finish-step" },
158
+ { type: "finish" },
159
+ ],
160
+ });
161
+
162
+ expect(toolPart()).toMatchObject({
163
+ state: "approval-requested",
164
+ approval: { id: "approval-1", descriptor },
165
+ });
166
+ expect(approval()).toEqual({
167
+ id: "approval-1",
168
+ prompt: "Which environment?",
169
+ display: "select",
170
+ options: [{ id: "once", kind: "allow-once", label: "Staging once" }],
171
+ descriptor,
172
+ });
173
+
174
+ await act(() => part().respondToToolApproval({ optionId: "once" }));
175
+
176
+ expect(handler).toHaveBeenCalledWith(
177
+ { approvalId: "approval-1", approved: true, optionId: "once" },
178
+ expect.objectContaining({ toolCallId: "tool-1", toolName: "deploy" }),
179
+ );
180
+ expect(approval()).toMatchObject({ approved: true, optionId: "once" });
181
+ expect(toolPart()).toMatchObject({ state: "approval-requested" });
182
+ });
183
+
133
184
  it("keeps a host answer out of the chat's automatic sends", async () => {
134
185
  const { chat, sendMessages, respond } = await setup(() => async () => {}, {
135
186
  request: approvalStep([