@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
@@ -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,16 +62,30 @@ 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>;
79
+ cancelledToolApprovalIds?: ReadonlySet<string>;
80
+ cancelledStatusMessageIds?: ReadonlySet<string>;
68
81
  /** Id of the currently-streaming message, flagged optimistic (#4037). */
69
82
  optimisticMessageId?: string | undefined;
70
83
  };
71
84
 
72
85
  function stripClosingDelimiters(json: string): string {
73
- return json.replace(/[}\]"]+$/, "");
86
+ let end = json.length;
87
+ while (end > 0 && '}]"'.includes(json[end - 1]!)) end--;
88
+ return json.slice(0, end);
74
89
  }
75
90
 
76
91
  const MCP_APP_METADATA_CACHE_MAX = 100;
@@ -206,6 +221,38 @@ const normalizeToolApprovalOptions = (
206
221
  });
207
222
  };
208
223
 
224
+ const APPROVAL_DESCRIPTOR_FIELDS = [
225
+ "prompt",
226
+ "display",
227
+ "allowFreeform",
228
+ "dismissible",
229
+ "options",
230
+ "optionId",
231
+ "text",
232
+ "resolution",
233
+ ] as const;
234
+
235
+ // The AI SDK's approval object declares none of the core request and answer
236
+ // fields and `validateUIMessages` strips unknown ones, so a host streams or
237
+ // persists them inside the opaque `approvalDescriptor`. Only those fields are
238
+ // read from it: a descriptor cannot approve its own request.
239
+ const readApprovalDescriptor = (
240
+ descriptor: unknown,
241
+ ): Record<string, unknown> => {
242
+ if (
243
+ !descriptor ||
244
+ typeof descriptor !== "object" ||
245
+ Array.isArray(descriptor)
246
+ )
247
+ return {};
248
+ const fields: Record<string, unknown> = {};
249
+ for (const key of APPROVAL_DESCRIPTOR_FIELDS) {
250
+ if (Object.hasOwn(descriptor, key))
251
+ fields[key] = (descriptor as Record<string, unknown>)[key];
252
+ }
253
+ return fields;
254
+ };
255
+
209
256
  function getToolApprovalAndInterrupt(
210
257
  part: {
211
258
  approval?: Record<string, unknown> | undefined;
@@ -215,17 +262,29 @@ function getToolApprovalAndInterrupt(
215
262
  toolApprovalResponses:
216
263
  | ReadonlyMap<string, RespondToToolApprovalOptions>
217
264
  | undefined,
265
+ cancelledToolApprovalIds: ReadonlySet<string> | undefined,
218
266
  ): {
219
267
  approval?: NonNullable<ToolCallMessagePart["approval"]>;
220
268
  interrupt?: NonNullable<ToolCallMessagePart["interrupt"]>;
221
269
  } {
222
270
  if (part.approval) {
271
+ const rawApproval = {
272
+ ...readApprovalDescriptor(part.approval.descriptor),
273
+ ...part.approval,
274
+ };
275
+ const approval =
276
+ typeof rawApproval.id === "string" &&
277
+ rawApproval.approved === undefined &&
278
+ rawApproval.resolution === undefined &&
279
+ cancelledToolApprovalIds?.has(rawApproval.id)
280
+ ? { ...rawApproval, resolution: "cancelled" as const }
281
+ : rawApproval;
223
282
  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)
283
+ typeof approval.id === "string" &&
284
+ approval.approved === undefined &&
285
+ approval.resolution !== "cancelled" &&
286
+ approval.resolution !== "expired"
287
+ ? toolApprovalResponses?.get(approval.id)
229
288
  : undefined;
230
289
  // The built-in AI SDK channel sends only id, approved and reason back to
231
290
  // the server, so a request shape promising any other answer would render
@@ -239,19 +298,20 @@ function getToolApprovalAndInterrupt(
239
298
  resolution,
240
299
  display,
241
300
  allowFreeform,
301
+ dismissible,
242
302
  options,
243
303
  optionId,
244
304
  text,
245
305
  ...additionalApprovalFields
246
306
  } = response
247
307
  ? {
248
- ...part.approval,
308
+ ...approval,
249
309
  approved: response.approved,
250
310
  ...(response.reason != null && { reason: response.reason }),
251
311
  ...(response.optionId != null && { optionId: response.optionId }),
252
312
  ...(response.text != null && { text: response.text }),
253
313
  }
254
- : part.approval;
314
+ : approval;
255
315
  const normalizedOptions = supportsRichToolApprovalResponses
256
316
  ? normalizeToolApprovalOptions(options)
257
317
  : undefined;
@@ -274,6 +334,7 @@ function getToolApprovalAndInterrupt(
274
334
  display === "select" ||
275
335
  display === "text") && { display }),
276
336
  ...(typeof allowFreeform === "boolean" && { allowFreeform }),
337
+ ...(typeof dismissible === "boolean" && { dismissible }),
277
338
  ...(normalizedOptions && { options: normalizedOptions }),
278
339
  ...(typeof optionId === "string" && { optionId }),
279
340
  ...(typeof text === "string" && { text }),
@@ -354,7 +415,14 @@ function convertParts(
354
415
  const toolCallId = part.toolCallId;
355
416
  const argsKeyOrderCacheKey = `${message.id}:${toolCallId}`;
356
417
 
357
- const rawInput = part.input as ReadonlyJSONObject | null | undefined;
418
+ // A tool call that streamed complete arguments then failed schema
419
+ // validation keeps them in `rawInput`, not `input`; reading `input`
420
+ // alone would convert the error snapshot to `{}` and hide the input.
421
+ const rawInput = (part.input ??
422
+ ("rawInput" in part ? part.rawInput : undefined)) as
423
+ | ReadonlyJSONObject
424
+ | null
425
+ | undefined;
358
426
  let args: ReadonlyJSONObject;
359
427
  if (
360
428
  rawInput != null &&
@@ -387,12 +455,13 @@ function convertParts(
387
455
  };
388
456
  }
389
457
 
390
- let argsText = stableStringifyToolArgs(
391
- metadata.toolArgsKeyOrderCache,
392
- argsKeyOrderCacheKey,
393
- args,
394
- );
458
+ let argsText: string;
395
459
  if (part.state === "input-streaming") {
460
+ argsText = stableStringifyToolArgs(
461
+ metadata.toolArgsKeyOrderCache,
462
+ argsKeyOrderCacheKey,
463
+ args,
464
+ );
396
465
  // strip closing delimiters added by the AI SDK's fix-json
397
466
  argsText = stripClosingDelimiters(argsText);
398
467
  // Re-parse so args carries the partial-JSON meta that marks which
@@ -401,6 +470,27 @@ function convertParts(
401
470
  // of the stripped text is the streaming frontier.
402
471
  args = parsePartialJsonObject(argsText) ?? args;
403
472
  } else {
473
+ // A settled part is re-converted whenever its message or the converter
474
+ // metadata changes; the text frozen on its input object skips
475
+ // re-serializing large args while the call keeps that input. Arrival
476
+ // order only matters while args stream, so the key-order entry is
477
+ // released.
478
+ const frozen =
479
+ metadata.toolArgsTextCache?.get(args) ?? new Map<string, string>();
480
+ const frozenText = frozen.get(argsKeyOrderCacheKey);
481
+ if (frozenText !== undefined) {
482
+ argsText = frozenText;
483
+ } else {
484
+ argsText = stableStringifyToolArgs(
485
+ metadata.toolArgsKeyOrderCache,
486
+ argsKeyOrderCacheKey,
487
+ args,
488
+ );
489
+ metadata.toolArgsTextCache?.set(
490
+ args,
491
+ frozen.set(argsKeyOrderCacheKey, argsText),
492
+ );
493
+ }
404
494
  metadata.toolArgsKeyOrderCache?.delete(argsKeyOrderCacheKey);
405
495
  if (
406
496
  part.state === "output-available" ||
@@ -416,6 +506,8 @@ function convertParts(
416
506
  part,
417
507
  metadata.mcpAppMetadataCache,
418
508
  );
509
+ const artifact = metadata.toolArtifacts?.get(toolCallId);
510
+ const interactions = metadata.toolInteractions?.get(toolCallId);
419
511
  return {
420
512
  type: "tool-call",
421
513
  toolName,
@@ -424,6 +516,12 @@ function convertParts(
424
516
  args,
425
517
  result,
426
518
  isError,
519
+ ...(artifact !== undefined && { artifact }),
520
+ ...(interactions !== undefined && {
521
+ unstable_interactions: interactions,
522
+ }),
523
+ ...(part.state === "output-available" &&
524
+ part.preliminary === true && { isPreliminary: true }),
427
525
  ...(modelContent !== undefined && { modelContent }),
428
526
  ...(mcpApp && { mcp: { app: mcpApp } }),
429
527
  ...(part.callProviderMetadata != null
@@ -437,6 +535,7 @@ function convertParts(
437
535
  toolStatus,
438
536
  metadata.supportsRichToolApprovalResponses === true,
439
537
  metadata.toolApprovalResponses,
538
+ metadata.cancelledToolApprovalIds,
440
539
  ),
441
540
  } satisfies ToolCallMessagePart;
442
541
  }
@@ -575,6 +674,15 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
575
674
  id: message.id,
576
675
  createdAt,
577
676
  content,
677
+ ...(message.role === "assistant" &&
678
+ metadata.cancelledStatusMessageIds?.has(message.id)
679
+ ? {
680
+ status: {
681
+ type: "incomplete" as const,
682
+ reason: "cancelled" as const,
683
+ },
684
+ }
685
+ : undefined),
578
686
  metadata: {
579
687
  ...toThreadMetadata(message.metadata),
580
688
  ...(timing && { timing }),
@@ -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";
@@ -18,10 +18,6 @@ const useAISDKChat = <UI_MESSAGE extends UIMessage = UIMessage>(
18
18
  options?: AISDKChatOptions<UI_MESSAGE>,
19
19
  ) => {
20
20
  const [id] = useState(() => options?.id ?? generateId());
21
- // The transport resolves the request id from the thread list item, falling
22
- // back to the runtime's main item, whose id here is the external store's
23
- // placeholder constant. The single thread of this entry is the chat itself,
24
- // so the handed-over item initializes to the chat id.
25
21
  const [threadListItem] = useState(() => ({
26
22
  initialize: async () => ({ remoteId: id, externalId: undefined }),
27
23
  }));
@@ -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,7 +28,60 @@ 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", () => {
59
+ it("emits the selected thread id when its composer sends", async () => {
60
+ const { transport } = createControlledTransport();
61
+ const handle = createAssistantClient(
62
+ AuiConfig({ threads: AISDKThreads({ transport }) }),
63
+ );
64
+ try {
65
+ handle.subscribe(() => {});
66
+ const aui = handle.getClient();
67
+ const sent = vi.fn();
68
+ aui.on({ scope: "thread", event: "composer.send" }, sent);
69
+
70
+ flushTapSync(() => aui.composer.setText("hello"));
71
+ flushTapSync(() => aui.composer.send());
72
+
73
+ await vi.waitFor(() => {
74
+ expect(sent).toHaveBeenCalledWith(
75
+ expect.objectContaining({
76
+ threadId: aui.threads.getState().mainThreadId,
77
+ }),
78
+ );
79
+ });
80
+ } finally {
81
+ handle.destroy();
82
+ }
83
+ });
84
+
32
85
  it("runs one chat per thread and keeps histories isolated across switches", async () => {
33
86
  const { transport, emit, close } = createControlledTransport();
34
87
  const handle = createAssistantClient(
@@ -253,25 +306,19 @@ describe("AISDKThreads", () => {
253
306
 
254
307
  it("forwards ChatInit callbacks to each thread's chat from the latest render", async () => {
255
308
  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(() => {});
309
+ const sendMessages = vi.spyOn(transport, "sendMessages");
310
+ const mounted = chatCallbacks();
311
+ const latest = chatCallbacks();
312
+ latest.sendAutomaticallyWhen.mockReturnValueOnce(true);
313
+ let callbacks = mounted;
314
+ const { handle, rerender } = createLiveHandle(() => ({
315
+ transport: () => transport,
316
+ ...callbacks,
317
+ }));
271
318
  const aui = handle.getClient();
272
319
 
273
- onFinish = onFinishB;
274
- flushTapSync(() => listeners.forEach((listener) => listener()));
320
+ callbacks = latest;
321
+ rerender();
275
322
 
276
323
  flushTapSync(() => aui.composer.setText("hi"));
277
324
  flushTapSync(() => aui.composer.send());
@@ -280,10 +327,36 @@ describe("AISDKThreads", () => {
280
327
  handle.getClient().thread.getState().messages.length,
281
328
  ).toBeGreaterThan(0);
282
329
  });
283
- emit(...textReply("done"));
330
+ emit(
331
+ { type: "start" },
332
+ {
333
+ type: "tool-input-available",
334
+ toolCallId: "call-1",
335
+ toolName: "lookup",
336
+ input: {},
337
+ },
338
+ { type: "data-note", data: "note" },
339
+ { type: "finish" },
340
+ );
284
341
  close();
285
- await vi.waitFor(() => expect(onFinishB).toHaveBeenCalledTimes(1));
286
- expect(onFinishA).not.toHaveBeenCalled();
342
+ await vi.waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
343
+ emit({ type: "start" }, { type: "error", errorText: "boom" });
344
+ close();
345
+ await vi.waitFor(() => expect(latest.onFinish).toHaveBeenCalledTimes(2));
346
+
347
+ expect(latest.onToolCall).toHaveBeenCalledExactlyOnceWith({
348
+ toolCall: expect.objectContaining({ toolCallId: "call-1" }),
349
+ });
350
+ expect(latest.onData).toHaveBeenCalledExactlyOnceWith(
351
+ expect.objectContaining({ type: "data-note", data: "note" }),
352
+ );
353
+ expect(latest.onError).toHaveBeenCalledExactlyOnceWith(
354
+ expect.objectContaining({ message: "boom" }),
355
+ );
356
+ expect(latest.sendAutomaticallyWhen).toHaveBeenCalledOnce();
357
+ for (const callback of Object.values(mounted)) {
358
+ expect(callback).not.toHaveBeenCalled();
359
+ }
287
360
 
288
361
  handle.destroy();
289
362
  });
@@ -293,18 +366,10 @@ describe("AISDKThreads", () => {
293
366
  const onFinishA = vi.fn();
294
367
  const onFinishB = vi.fn();
295
368
  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(() => {});
369
+ const { handle, rerender } = createLiveHandle(() => ({
370
+ transport: () => transport,
371
+ onFinish,
372
+ }));
308
373
  const aui = handle.getClient();
309
374
 
310
375
  flushTapSync(() => aui.composer.setText("stream me"));
@@ -322,7 +387,7 @@ describe("AISDKThreads", () => {
322
387
 
323
388
  flushTapSync(() => aui.threads.switchToNewThread());
324
389
  onFinish = onFinishB;
325
- flushTapSync(() => listeners.forEach((listener) => listener()));
390
+ rerender();
326
391
 
327
392
  emit({ type: "text-end", id: "t1" }, { type: "finish" });
328
393
  close();
@@ -361,18 +426,12 @@ describe("AISDKThreads", () => {
361
426
  const onFinishA = vi.fn();
362
427
  const onFinishB = vi.fn();
363
428
  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(() => {});
429
+ const { handle, rerender } = createLiveHandle(() => ({
430
+ cloud,
431
+ threadId: "t1",
432
+ transport,
433
+ onFinish,
434
+ }));
376
435
  try {
377
436
  await handle.getClient().threads.getLoadThreadsPromise();
378
437
  await vi.waitFor(() => {
@@ -383,7 +442,7 @@ describe("AISDKThreads", () => {
383
442
  });
384
443
 
385
444
  onFinish = onFinishB;
386
- flushTapSync(() => listeners.forEach((listener) => listener()));
445
+ rerender();
387
446
 
388
447
  flushTapSync(() => handle.getClient().composer.setText("hi"));
389
448
  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