@assistant-ui/ai-sdk 0.0.6 → 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 (107) 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 +2 -5
  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 +6 -9
  9. package/dist/aiSDKExtras.d.ts.map +1 -1
  10. package/dist/converters/convertMessage.d.ts +34 -25
  11. package/dist/converters/convertMessage.d.ts.map +1 -1
  12. package/dist/converters/convertMessage.js +78 -8
  13. package/dist/converters/convertMessage.js.map +1 -1
  14. package/dist/converters/modelContentEnvelope.d.ts +10 -12
  15. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  16. package/dist/converters/toCreateMessage.d.ts +3 -6
  17. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  18. package/dist/converters/toolOutputConversion.d.ts +22 -25
  19. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  20. package/dist/hooks.d.ts +4 -7
  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 +3 -6
  27. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  28. package/dist/model-context/injectQuoteContext.d.ts +2 -5
  29. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  30. package/dist/runtime/AISDKChat.d.ts +4 -7
  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 -32
  34. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  35. package/dist/runtime/AISDKThreads.js +26 -33
  36. package/dist/runtime/AISDKThreads.js.map +1 -1
  37. package/dist/runtime/sdkIdentity.d.ts +2 -5
  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 +72 -61
  41. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  42. package/dist/runtime/useAISDKRuntime.js +170 -56
  43. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  44. package/dist/runtime/useChatRuntime.d.ts +8 -11
  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 +100 -81
  49. package/dist/runtime/useChatThread.d.ts.map +1 -1
  50. package/dist/runtime/useChatThread.js +33 -13
  51. package/dist/runtime/useChatThread.js.map +1 -1
  52. package/dist/runtime/useExternalHistory.d.ts +8 -9
  53. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  54. package/dist/runtime/useExternalHistory.js +236 -58
  55. package/dist/runtime/useExternalHistory.js.map +1 -1
  56. package/dist/runtime/useResourceCleanup.d.ts +1 -4
  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 +5 -8
  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 +24 -27
  64. package/dist/tools/frontendTools.d.ts.map +1 -1
  65. package/dist/tools/generativeTools.d.ts +29 -32
  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 -5
  70. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  71. package/dist/transport/AssistantChatTransport.d.ts +17 -20
  72. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  73. package/dist/transport/resumable.d.ts +29 -32
  74. package/dist/transport/resumable.d.ts.map +1 -1
  75. package/dist/usage.d.ts +12 -15
  76. package/dist/usage.d.ts.map +1 -1
  77. package/dist/usage.js.map +1 -1
  78. package/dist/utils/getVercelAIMessages.d.ts +3 -6
  79. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  80. package/dist/utils/sliceMessagesUntil.d.ts +2 -5
  81. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  82. package/package.json +16 -14
  83. package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
  84. package/src/adapters/vercelAttachmentAdapter.ts +2 -2
  85. package/src/converters/convertMessage.test.ts +616 -3
  86. package/src/converters/convertMessage.ts +187 -12
  87. package/src/index.native.ts +1 -1
  88. package/src/index.ts +1 -1
  89. package/src/runtime/AISDKChat.test.ts +4 -5
  90. package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
  91. package/src/runtime/AISDKThreads.test.ts +88 -51
  92. package/src/runtime/AISDKThreads.ts +4 -13
  93. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +338 -0
  94. package/src/runtime/useAISDKRuntime.approval.test.tsx +476 -4
  95. package/src/runtime/useAISDKRuntime.test.ts +282 -8
  96. package/src/runtime/useAISDKRuntime.ts +274 -46
  97. package/src/runtime/useAISDKRuntime.voice.test.tsx +319 -0
  98. package/src/runtime/useChatRuntime.integration.test.tsx +56 -0
  99. package/src/runtime/useChatRuntime.test.ts +77 -5
  100. package/src/runtime/useChatRuntime.ts +6 -2
  101. package/src/runtime/useChatThread.transport.test.tsx +149 -0
  102. package/src/runtime/useChatThread.ts +77 -10
  103. package/src/runtime/useExternalHistory.test.ts +989 -3
  104. package/src/runtime/useExternalHistory.ts +390 -24
  105. package/src/runtime/useResourceCleanup.ts +41 -15
  106. package/src/tools/generativeTools.test.ts +7 -1
  107. package/src/usage.ts +1 -1
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  isMcpAppUri,
14
14
  type ReasoningMessagePart,
15
+ type ToolApprovalOption,
15
16
  type ToolCallMessagePart,
16
17
  type TextMessagePart,
17
18
  type DataMessagePart,
@@ -22,6 +23,8 @@ import {
22
23
  type ThreadMessageLike,
23
24
  type McpAppMetadata,
24
25
  type MessagePartStreamStatus,
26
+ type RespondToToolApprovalOptions,
27
+ type Unstable_ToolInteractionLog,
25
28
  } from "@assistant-ui/core";
26
29
  import { stableStringifyToolArgs } from "@assistant-ui/core/internal";
27
30
  import {
@@ -47,7 +50,7 @@ const THREAD_METADATA_KEYS = new Set([
47
50
  const toThreadMetadata = (metadata: unknown): MessageMetadata => {
48
51
  if (!metadata || typeof metadata !== "object") return undefined;
49
52
  const result: Record<string, unknown> = {};
50
- const extra: Record<string, unknown> = {};
53
+ const extra = Object.create(null) as Record<string, unknown>;
51
54
  for (const [key, value] of Object.entries(metadata)) {
52
55
  (THREAD_METADATA_KEYS.has(key) ? result : extra)[key] = value;
53
56
  }
@@ -59,8 +62,20 @@ const toThreadMetadata = (metadata: unknown): MessageMetadata => {
59
62
  export type AISDKMessageConverterMetadata =
60
63
  useExternalMessageConverter.Metadata & {
61
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>>;
62
73
  toolLastInputCache?: Map<string, ReadonlyJSONObject>;
63
74
  mcpAppMetadataCache?: Map<string, McpAppMetadata>;
75
+ toolArtifacts?: ReadonlyMap<string, unknown>;
76
+ toolInteractions?: ReadonlyMap<string, Unstable_ToolInteractionLog>;
77
+ supportsRichToolApprovalResponses?: boolean;
78
+ toolApprovalResponses?: ReadonlyMap<string, RespondToToolApprovalOptions>;
64
79
  /** Id of the currently-streaming message, flagged optimistic (#4037). */
65
80
  optimisticMessageId?: string | undefined;
66
81
  };
@@ -153,19 +168,115 @@ function extractMcpAppMetadata(
153
168
  return out;
154
169
  }
155
170
 
171
+ const normalizeToolApprovalOptions = (
172
+ options: unknown,
173
+ ): readonly ToolApprovalOption[] | undefined => {
174
+ if (!Array.isArray(options)) return undefined;
175
+
176
+ return options.flatMap<ToolApprovalOption>((value) => {
177
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
178
+ const option = value as Record<string, unknown>;
179
+ if (typeof option.id !== "string" || typeof option.kind !== "string")
180
+ return [];
181
+
182
+ const confirm = option.confirm;
183
+ const confirmDetails =
184
+ confirm && typeof confirm === "object" && !Array.isArray(confirm)
185
+ ? (confirm as Record<string, unknown>)
186
+ : undefined;
187
+
188
+ return [
189
+ {
190
+ id: option.id,
191
+ kind: option.kind,
192
+ ...(typeof option.label === "string" && { label: option.label }),
193
+ ...(typeof option.description === "string" && {
194
+ description: option.description,
195
+ }),
196
+ ...(Array.isArray(option.grants) && {
197
+ grants: option.grants.filter(
198
+ (grant): grant is string => typeof grant === "string",
199
+ ),
200
+ }),
201
+ ...(typeof confirm === "boolean"
202
+ ? { confirm }
203
+ : confirmDetails
204
+ ? {
205
+ confirm: {
206
+ ...(typeof confirmDetails.title === "string" && {
207
+ title: confirmDetails.title,
208
+ }),
209
+ ...(typeof confirmDetails.description === "string" && {
210
+ description: confirmDetails.description,
211
+ }),
212
+ },
213
+ }
214
+ : {}),
215
+ },
216
+ ];
217
+ });
218
+ };
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
+
156
252
  function getToolApprovalAndInterrupt(
157
253
  part: {
158
254
  approval?: Record<string, unknown> | undefined;
159
255
  },
160
256
  toolStatus: { type: string; payload?: unknown } | undefined,
257
+ supportsRichToolApprovalResponses: boolean,
258
+ toolApprovalResponses:
259
+ | ReadonlyMap<string, RespondToToolApprovalOptions>
260
+ | undefined,
161
261
  ): {
162
262
  approval?: NonNullable<ToolCallMessagePart["approval"]>;
163
263
  interrupt?: NonNullable<ToolCallMessagePart["interrupt"]>;
164
264
  } {
165
265
  if (part.approval) {
166
- // The AI SDK sends only id, approved and reason back to the server, so a
167
- // request shape promising any other answer would render controls whose
168
- // response cannot travel.
266
+ const approval = {
267
+ ...readApprovalDescriptor(part.approval.descriptor),
268
+ ...part.approval,
269
+ };
270
+ const response =
271
+ typeof approval.id === "string" &&
272
+ approval.approved === undefined &&
273
+ approval.resolution !== "cancelled" &&
274
+ approval.resolution !== "expired"
275
+ ? toolApprovalResponses?.get(approval.id)
276
+ : undefined;
277
+ // The built-in AI SDK channel sends only id, approved and reason back to
278
+ // the server, so a request shape promising any other answer would render
279
+ // controls whose response cannot travel.
169
280
  const {
170
281
  id,
171
282
  prompt,
@@ -175,11 +286,23 @@ function getToolApprovalAndInterrupt(
175
286
  resolution,
176
287
  display,
177
288
  allowFreeform,
289
+ dismissible,
178
290
  options,
179
291
  optionId,
180
292
  text,
181
293
  ...additionalApprovalFields
182
- } = part.approval;
294
+ } = response
295
+ ? {
296
+ ...approval,
297
+ approved: response.approved,
298
+ ...(response.reason != null && { reason: response.reason }),
299
+ ...(response.optionId != null && { optionId: response.optionId }),
300
+ ...(response.text != null && { text: response.text }),
301
+ }
302
+ : approval;
303
+ const normalizedOptions = supportsRichToolApprovalResponses
304
+ ? normalizeToolApprovalOptions(options)
305
+ : undefined;
183
306
  const requestReason = additionalApprovalFields.requestReason;
184
307
  if (typeof id === "string")
185
308
  return {
@@ -194,6 +317,16 @@ function getToolApprovalAndInterrupt(
194
317
  ...(typeof approved === "boolean" && { approved }),
195
318
  ...(typeof reason === "string" && { reason }),
196
319
  ...(isAutomatic === true && { isAutomatic: true }),
320
+ ...(supportsRichToolApprovalResponses && {
321
+ ...((display === "decision" ||
322
+ display === "select" ||
323
+ display === "text") && { display }),
324
+ ...(typeof allowFreeform === "boolean" && { allowFreeform }),
325
+ ...(typeof dismissible === "boolean" && { dismissible }),
326
+ ...(normalizedOptions && { options: normalizedOptions }),
327
+ ...(typeof optionId === "string" && { optionId }),
328
+ ...(typeof text === "string" && { text }),
329
+ }),
197
330
  ...((resolution === "cancelled" || resolution === "expired") && {
198
331
  resolution,
199
332
  }),
@@ -270,7 +403,14 @@ function convertParts(
270
403
  const toolCallId = part.toolCallId;
271
404
  const argsKeyOrderCacheKey = `${message.id}:${toolCallId}`;
272
405
 
273
- 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;
274
414
  let args: ReadonlyJSONObject;
275
415
  if (
276
416
  rawInput != null &&
@@ -303,12 +443,13 @@ function convertParts(
303
443
  };
304
444
  }
305
445
 
306
- let argsText = stableStringifyToolArgs(
307
- metadata.toolArgsKeyOrderCache,
308
- argsKeyOrderCacheKey,
309
- args,
310
- );
446
+ let argsText: string;
311
447
  if (part.state === "input-streaming") {
448
+ argsText = stableStringifyToolArgs(
449
+ metadata.toolArgsKeyOrderCache,
450
+ argsKeyOrderCacheKey,
451
+ args,
452
+ );
312
453
  // strip closing delimiters added by the AI SDK's fix-json
313
454
  argsText = stripClosingDelimiters(argsText);
314
455
  // Re-parse so args carries the partial-JSON meta that marks which
@@ -317,6 +458,27 @@ function convertParts(
317
458
  // of the stripped text is the streaming frontier.
318
459
  args = parsePartialJsonObject(argsText) ?? args;
319
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
+ }
320
482
  metadata.toolArgsKeyOrderCache?.delete(argsKeyOrderCacheKey);
321
483
  if (
322
484
  part.state === "output-available" ||
@@ -332,6 +494,8 @@ function convertParts(
332
494
  part,
333
495
  metadata.mcpAppMetadataCache,
334
496
  );
497
+ const artifact = metadata.toolArtifacts?.get(toolCallId);
498
+ const interactions = metadata.toolInteractions?.get(toolCallId);
335
499
  return {
336
500
  type: "tool-call",
337
501
  toolName,
@@ -340,6 +504,12 @@ function convertParts(
340
504
  args,
341
505
  result,
342
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 }),
343
513
  ...(modelContent !== undefined && { modelContent }),
344
514
  ...(mcpApp && { mcp: { app: mcpApp } }),
345
515
  ...(part.callProviderMetadata != null
@@ -348,7 +518,12 @@ function convertParts(
348
518
  part.callProviderMetadata as PartProviderMetadata,
349
519
  }
350
520
  : undefined),
351
- ...getToolApprovalAndInterrupt(part, toolStatus),
521
+ ...getToolApprovalAndInterrupt(
522
+ part,
523
+ toolStatus,
524
+ metadata.supportsRichToolApprovalResponses === true,
525
+ metadata.toolApprovalResponses,
526
+ ),
352
527
  } satisfies ToolCallMessagePart;
353
528
  }
354
529
 
@@ -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";
@@ -153,7 +153,9 @@ describe("AISDKChat as a standalone client config entry", () => {
153
153
  ]
154
154
  .map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
155
155
  .join("");
156
- const fetchMock = vi.fn(
156
+ const fetchMock = vi.fn<
157
+ (input: RequestInfo | URL, init: RequestInit) => Promise<Response>
158
+ >(
157
159
  async () =>
158
160
  new Response(sse, {
159
161
  headers: { "content-type": "text/event-stream" },
@@ -178,10 +180,7 @@ describe("AISDKChat as a standalone client config entry", () => {
178
180
  expect(state.messages).toHaveLength(2);
179
181
  });
180
182
 
181
- const [url, init] = fetchMock.mock.calls[0]! as [
182
- RequestInfo,
183
- RequestInit,
184
- ];
183
+ const [url, init] = fetchMock.mock.calls[0]!;
185
184
  expect(String(url)).toContain("/api/chat");
186
185
  const body = JSON.parse(init.body as string);
187
186
  expect(body.id).toBe("test-thread-1");
@@ -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());
@@ -455,7 +488,9 @@ describe("AISDKThreads", () => {
455
488
  const list = vi.fn(async () => ({
456
489
  threads: [cloudThread("cloud-1"), cloudThread("cloud-2")],
457
490
  }));
458
- const create = vi.fn(async () => ({ thread_id: "cloud-created" }));
491
+ const create = vi.fn<AssistantCloud["threads"]["create"]>(async () => ({
492
+ thread_id: "cloud-created",
493
+ }));
459
494
  const deleteThread = vi.fn(async () => {});
460
495
  const cloud = {
461
496
  threads: {
@@ -521,7 +556,9 @@ describe("AISDKThreads", () => {
521
556
  external_id: null,
522
557
  metadata: null,
523
558
  });
524
- const create = vi.fn(async () => ({ message_id: "remote-message-1" }));
559
+ const create = vi.fn<AssistantCloud["threads"]["messages"]["create"]>(
560
+ async () => ({ message_id: "remote-message-1" }),
561
+ );
525
562
  const cloud = {
526
563
  threads: {
527
564
  list: vi.fn(async () => ({
@@ -602,9 +639,9 @@ describe("AISDKThreads", () => {
602
639
  external_id: null,
603
640
  metadata: null,
604
641
  });
605
- const createMessage = vi.fn(async () => ({
606
- message_id: "remote-message-1",
607
- }));
642
+ const createMessage = vi.fn<
643
+ AssistantCloud["threads"]["messages"]["create"]
644
+ >(async () => ({ message_id: "remote-message-1" }));
608
645
  const cloud = {
609
646
  threads: {
610
647
  list: vi.fn(async () => ({ threads: [cloudThread("t1")] })),
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { resource, useResource, withKey } from "@assistant-ui/tap";
4
4
  import { useEffect, useMemo, useState } from "react";
5
- import { Chat, type UIMessage } from "@ai-sdk/react";
5
+ import type { Chat, UIMessage } from "@ai-sdk/react";
6
6
  import type { ChatTransport } from "ai";
7
7
  import type { AssistantCloud } from "assistant-cloud";
8
8
  import {
@@ -20,6 +20,7 @@ import {
20
20
  import { useAui } from "@assistant-ui/store";
21
21
  import { AssistantChatTransport } from "../transport/AssistantChatTransport";
22
22
  import {
23
+ createChat,
23
24
  splitChatThreadOptions,
24
25
  useChatThread,
25
26
  type ChatThreadOptions,
@@ -91,17 +92,7 @@ const createChatEntry = <UI_MESSAGE extends UIMessage>(
91
92
  ? options.transport.__internal_clone()
92
93
  : options.transport;
93
94
  return {
94
- chat: new Chat<UI_MESSAGE>({
95
- ...chatInit,
96
- id: threadId,
97
- transport,
98
- onToolCall: (arg) => optionsRef.current?.onToolCall?.(arg),
99
- onData: (arg) => optionsRef.current?.onData?.(arg),
100
- onFinish: (arg) => optionsRef.current?.onFinish?.(arg),
101
- onError: (arg) => optionsRef.current?.onError?.(arg),
102
- sendAutomaticallyWhen: (arg) =>
103
- optionsRef.current?.sendAutomaticallyWhen?.(arg) ?? false,
104
- }),
95
+ chat: createChat({ ...chatInit, id: threadId, transport }, optionsRef),
105
96
  transport,
106
97
  repository: new MessageRepository(),
107
98
  optionsRef,
@@ -171,7 +162,7 @@ const useAISDKChatThread = <UI_MESSAGE extends UIMessage = UIMessage>({
171
162
  : fallbackItem,
172
163
  chat,
173
164
  messageRepositoryInstance: repository,
174
- stopOnClientDestroy: cloud,
165
+ stopOnClientDestroy: false,
175
166
  },
176
167
  );
177
168