@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
@@ -20,6 +20,7 @@ import {
20
20
  useRuntimeAdapters,
21
21
  type JoinStrategy,
22
22
  } from "@assistant-ui/core/react";
23
+ import { useReplaySafeEffect } from "@assistant-ui/store/internal";
23
24
  import type {
24
25
  SuggestionAdapter,
25
26
  ThreadSuggestion,
@@ -38,12 +39,14 @@ import type {
38
39
  RunConfig,
39
40
  McpAppMetadata,
40
41
  RespondToToolApprovalOptions,
42
+ Unstable_ToolInteractionLog,
41
43
  } from "@assistant-ui/core";
42
44
  import {
43
45
  getExternalStoreMessages,
44
46
  pickExternalStoreSharedOptions,
45
47
  } from "@assistant-ui/core";
46
48
  import {
49
+ appendToolInteraction,
47
50
  consumeSuggestionResult,
48
51
  MessageRepository,
49
52
  } from "@assistant-ui/core/internal";
@@ -95,7 +98,7 @@ const toVoiceTranscriptUIMessage = <UI_MESSAGE extends UIMessage>(
95
98
  .filter((part) => part.type === "text")
96
99
  .map((part) => ({ type: "text", text: part.text })),
97
100
  metadata: {
98
- modality: "voice",
101
+ ...(message.metadata.modality && { modality: message.metadata.modality }),
99
102
  ...(Object.keys(message.metadata.custom).length > 0 && {
100
103
  custom: message.metadata.custom,
101
104
  }),
@@ -112,6 +115,13 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
112
115
  | undefined;
113
116
  toCreateMessage?: CustomToCreateMessageFunction;
114
117
  unstable_messageRepositoryInstance?: MessageRepository | undefined;
118
+ /**
119
+ * The object a host answer belongs to, normally the `Chat` the runtime
120
+ * renders. A host answer never reaches the `useChat` messages, so without
121
+ * an owner it lives only as long as this runtime. With an owner, it
122
+ * survives runtime remounts over the same chat.
123
+ */
124
+ unstable_hostApprovalOwner?: object | undefined;
115
125
  /**
116
126
  * Whether to automatically cancel pending interactive tool calls when the user sends a new message.
117
127
  *
@@ -139,9 +149,9 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
139
149
  /**
140
150
  * Answers tool approval requests through a host-owned channel instead of the AI SDK's `addToolApprovalResponse`.
141
151
  *
142
- * Called for every approval request in the thread with the complete response, including option and free-form answers. Hand requests the host does not own to `respondViaAISDK`, which is what runs when this option is omitted. The answer applies to the approval when the handler starts and is removed if it throws. It is never written into the `useChat` messages, so `sendAutomaticallyWhen` cannot forward it, and it lasts as long as this runtime: until then a second response to the same request rejects, and a runtime mounted again over the same chat shows the request open until the resumed run records its resolution in the chat.
152
+ * Called for every approval request in the thread with the complete response, including option and free-form answers. Hand requests the host does not own to `respondViaAISDK`, which is what runs when this option is omitted. The answer applies to the approval when the handler starts and is removed if it throws. It is never written into the `useChat` messages, so `sendAutomaticallyWhen` cannot forward it. With a history adapter, the answer is stored with its message once the handler resolves and returns on reload. With `unstable_hostApprovalOwner`, in-memory answers survive runtime remounts over the same chat; without an owner or history adapter, they last only as long as the runtime. Bring the resumed run back into the chat, for example with `resumeStream`, and have the endpoint refuse a second resume.
143
153
  *
144
- * While a handler is set, an approval's `display`, `allowFreeform` and `options` reach the renderer, because the handler can receive answers the AI SDK cannot carry.
154
+ * While a handler is set, an approval's `display`, `allowFreeform`, `dismissible` and `options` reach the renderer, because the handler can receive answers the AI SDK cannot carry. A stream declares them through the `approvalDescriptor` of its `tool-approval-request` chunk, the one approval field the AI SDK keeps opaque; the converter reads the request and answer fields from that descriptor when the approval itself lacks them.
145
155
  */
146
156
  onRespondToToolApproval?:
147
157
  | ((
@@ -248,7 +258,7 @@ const useGeneratedSuggestions = (
248
258
  })();
249
259
  }, [hasAdapter, isRunning]);
250
260
 
251
- useEffect(() => {
261
+ useReplaySafeEffect(() => {
252
262
  return () => {
253
263
  controllerRef.current?.abort();
254
264
  };
@@ -259,11 +269,188 @@ const useGeneratedSuggestions = (
259
269
 
260
270
  const NO_CANCELLED_MESSAGE_IDS: ReadonlySet<string> = new Set();
261
271
 
272
+ const NO_SUPERSEDED_APPROVAL_PROJECTION = Object.freeze({
273
+ approvalIds: Object.freeze(new Set<string>()),
274
+ statusMessageIds: Object.freeze(new Set<string>()),
275
+ });
276
+
262
277
  const NO_TOOL_APPROVAL_RESPONSES: ReadonlyMap<
263
278
  string,
264
279
  RespondToToolApprovalOptions
265
280
  > = new Map();
266
281
 
282
+ /**
283
+ * A host answer is deliberately kept out of the `useChat` messages, so nothing
284
+ * in the chat records it. Held in runtime state it would die with the runtime,
285
+ * and a runtime mounted again over the same chat would show the request open
286
+ * and take a second answer. Keyed on the chat instead, the answer lives as
287
+ * long as the chat it belongs to, and is collected with it.
288
+ */
289
+ type OwnedApproval = {
290
+ response: RespondToToolApprovalOptions;
291
+ };
292
+
293
+ const hostToolApprovalsByChat = new WeakMap<
294
+ object,
295
+ Map<string, OwnedApproval>
296
+ >();
297
+
298
+ const toApprovalResponses = (
299
+ owned: ReadonlyMap<string, OwnedApproval> | undefined,
300
+ ): ReadonlyMap<string, RespondToToolApprovalOptions> =>
301
+ owned && owned.size > 0
302
+ ? new Map([...owned].map(([id, entry]) => [id, entry.response]))
303
+ : NO_TOOL_APPROVAL_RESPONSES;
304
+
305
+ /**
306
+ * The answers live on the owner, but each mounted runtime renders them from
307
+ * its own state, so a write has to be announced: the runtime that performed it
308
+ * may already be unmounted (a rollback resolving after a remount), and another
309
+ * runtime may be mounted over the same owner.
310
+ */
311
+ const hostApprovalListenersByChat = new WeakMap<object, Set<() => void>>();
312
+
313
+ const subscribeToHostApprovals = (owner: object, listener: () => void) => {
314
+ const listeners = hostApprovalListenersByChat.get(owner) ?? new Set();
315
+ hostApprovalListenersByChat.set(owner, listeners);
316
+ listeners.add(listener);
317
+ return () => {
318
+ listeners.delete(listener);
319
+ };
320
+ };
321
+
322
+ const notifyHostApprovals = (owner: object) => {
323
+ for (const listener of [...(hostApprovalListenersByChat.get(owner) ?? [])]) {
324
+ listener();
325
+ }
326
+ };
327
+
328
+ const getSupersededApprovalProjection = <UI_MESSAGE extends UIMessage>(
329
+ messages: readonly UI_MESSAGE[],
330
+ hostApprovalIds: ReadonlySet<string>,
331
+ joinStrategy: JoinStrategy | undefined,
332
+ isRunning: boolean,
333
+ ) => {
334
+ const approvalIds = new Set<string>();
335
+ const statusMessageIds = new Set<string>();
336
+ const lastIndex = messages.length - 1;
337
+ let lastAssistant: UI_MESSAGE | undefined;
338
+ let hasSupersededApproval = false;
339
+ let previousWasVoice = false;
340
+
341
+ const flush = () => {
342
+ const hasOpenToolPart =
343
+ lastAssistant === messages[lastIndex] &&
344
+ lastAssistant?.parts?.some((part) => {
345
+ if (
346
+ !isToolUIPart(part) ||
347
+ part.state === "output-available" ||
348
+ part.state === "output-error" ||
349
+ part.state === "output-denied"
350
+ )
351
+ return false;
352
+
353
+ const approval = (
354
+ part as {
355
+ approval?: {
356
+ resolution?: unknown;
357
+ descriptor?: unknown;
358
+ };
359
+ }
360
+ ).approval;
361
+ const resolution =
362
+ approval?.resolution ??
363
+ (approval?.descriptor as { resolution?: unknown } | undefined)
364
+ ?.resolution;
365
+ return resolution !== "cancelled" && resolution !== "expired";
366
+ });
367
+ if (
368
+ lastAssistant &&
369
+ hasSupersededApproval &&
370
+ !hasOpenToolPart &&
371
+ !(isRunning && lastAssistant === messages[lastIndex])
372
+ ) {
373
+ statusMessageIds.add(lastAssistant.id);
374
+ }
375
+ lastAssistant = undefined;
376
+ hasSupersededApproval = false;
377
+ previousWasVoice = false;
378
+ };
379
+
380
+ for (let index = 0; index < messages.length; index++) {
381
+ const message = messages[index]!;
382
+ if (message.role !== "assistant") {
383
+ flush();
384
+ } else {
385
+ const isVoice =
386
+ (message.metadata as { modality?: unknown } | undefined)?.modality ===
387
+ "voice";
388
+ if (isVoice || previousWasVoice || joinStrategy === "none") flush();
389
+ lastAssistant = message;
390
+ previousWasVoice = isVoice;
391
+ }
392
+
393
+ for (const part of message.parts ?? []) {
394
+ if (!isToolUIPart(part) || part.state !== "approval-requested") continue;
395
+
396
+ const approval = part.approval;
397
+ if (!approval) continue;
398
+
399
+ const approvalId = approval.id;
400
+ if (hostApprovalIds.has(approvalId)) continue;
401
+
402
+ const resolution =
403
+ (approval as { resolution?: unknown }).resolution ??
404
+ (approval.descriptor as { resolution?: unknown } | null | undefined)
405
+ ?.resolution;
406
+ if (resolution === "cancelled" || resolution === "expired") continue;
407
+
408
+ if (index !== lastIndex) {
409
+ approvalIds.add(approvalId);
410
+ if (message.role === "assistant") hasSupersededApproval = true;
411
+ }
412
+ }
413
+ }
414
+ flush();
415
+
416
+ if (approvalIds.size === 0 && statusMessageIds.size === 0)
417
+ return NO_SUPERSEDED_APPROVAL_PROJECTION;
418
+
419
+ return { approvalIds, statusMessageIds };
420
+ };
421
+
422
+ const findRawToolMessageIndex = <UI_MESSAGE extends UIMessage>(
423
+ messages: readonly UI_MESSAGE[],
424
+ messageId: string,
425
+ toolCallId: string,
426
+ joinStrategy: JoinStrategy | undefined,
427
+ ) => {
428
+ const containsToolCall = (message: UI_MESSAGE) =>
429
+ message.parts?.some(
430
+ (part) => isToolUIPart(part) && part.toolCallId === toolCallId,
431
+ ) === true;
432
+
433
+ const messageIndex = messages.findIndex(
434
+ (message) => message.id === messageId,
435
+ );
436
+ if (messageIndex === -1) return -1;
437
+ if (containsToolCall(messages[messageIndex]!)) return messageIndex;
438
+ if (joinStrategy === "none" || messages[messageIndex]?.role !== "assistant")
439
+ return -1;
440
+
441
+ let start = messageIndex;
442
+ while (start > 0 && messages[start - 1]?.role === "assistant") start--;
443
+
444
+ let end = messageIndex;
445
+ while (end + 1 < messages.length && messages[end + 1]?.role === "assistant")
446
+ end++;
447
+
448
+ for (let index = start; index <= end; index++) {
449
+ if (containsToolCall(messages[index]!)) return index;
450
+ }
451
+ return -1;
452
+ };
453
+
267
454
  const toChatError = (error: Error): AssistantError => {
268
455
  const code = (error as { code?: unknown }).code;
269
456
  return {
@@ -301,18 +488,91 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
301
488
  chatId: string;
302
489
  ids: ReadonlySet<string>;
303
490
  } | null>(null);
491
+ // Set by hosts that own the chat across runtime lifetimes, so a host answer
492
+ // outlives a remount over that same chat.
493
+ const approvalOwner = adapter.unstable_hostApprovalOwner;
494
+ const ownedApprovals = approvalOwner
495
+ ? (hostToolApprovalsByChat.get(approvalOwner) ??
496
+ (() => {
497
+ const created = new Map<string, OwnedApproval>();
498
+ hostToolApprovalsByChat.set(approvalOwner, created);
499
+ return created;
500
+ })())
501
+ : undefined;
304
502
  const [toolApprovalResponses, setToolApprovalResponses] = useState<
305
503
  ReadonlyMap<string, RespondToToolApprovalOptions>
306
- >(NO_TOOL_APPROVAL_RESPONSES);
307
- const hostApprovalIdsRef = useRef(new Set<string>());
504
+ >(() => toApprovalResponses(ownedApprovals));
505
+ const [toolArtifactEpoch, setToolArtifactEpoch] = useState(0);
506
+ const [toolInteractionEpoch, setToolInteractionEpoch] = useState(0);
507
+ const toolApprovalResponsesRef = useRef(
508
+ new Map(toApprovalResponses(ownedApprovals)),
509
+ );
510
+ const ownedApprovalIdsRef = useRef(new Set(ownedApprovals?.keys()));
511
+ const hostApprovalIdsRef = useRef(new Set<string>(ownedApprovals?.keys()));
512
+
513
+ // The owner's record is shared, so this runtime re-reads it whenever it is
514
+ // written rather than only at mount: the write may come from a runtime that
515
+ // has since unmounted, or from another runtime mounted over the same owner.
516
+ useEffect(() => {
517
+ if (!approvalOwner || !ownedApprovals) return undefined;
518
+ const sync = () => {
519
+ for (const id of ownedApprovalIdsRef.current)
520
+ toolApprovalResponsesRef.current.delete(id);
521
+ for (const [id, entry] of ownedApprovals)
522
+ toolApprovalResponsesRef.current.set(id, entry.response);
523
+ ownedApprovalIdsRef.current = new Set(ownedApprovals.keys());
524
+ hostApprovalIdsRef.current = new Set(
525
+ toolApprovalResponsesRef.current.keys(),
526
+ );
527
+ setToolApprovalResponses((prev) => {
528
+ const next = new Map(toolApprovalResponsesRef.current);
529
+ const unchanged =
530
+ prev.size === next.size &&
531
+ [...next].every(([id, response]) => prev.get(id) === response);
532
+ return unchanged ? prev : next;
533
+ });
534
+ };
535
+ const unsubscribe = subscribeToHostApprovals(approvalOwner, sync);
536
+ // The state was seeded during render, so a write landing between then and
537
+ // this subscription would otherwise never be seen.
538
+ sync();
539
+ return unsubscribe;
540
+ }, [approvalOwner, ownedApprovals]);
541
+
542
+ // A runtime kept mounted across a change of owner must not carry the
543
+ // previous chat's answers: a reused approval id would render as already
544
+ // answered and reject a genuine response.
545
+ const lastApprovalOwnerRef = useRef(approvalOwner);
546
+ if (lastApprovalOwnerRef.current !== approvalOwner) {
547
+ lastApprovalOwnerRef.current = approvalOwner;
548
+ hostApprovalIdsRef.current = new Set<string>(ownedApprovals?.keys());
549
+ toolApprovalResponsesRef.current = new Map(
550
+ toApprovalResponses(ownedApprovals),
551
+ );
552
+ ownedApprovalIdsRef.current = new Set(ownedApprovals?.keys());
553
+ setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
554
+ }
308
555
  const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
309
556
  new Map(),
310
557
  );
311
558
  const toolLastInputCacheRef = useRef<Map<string, ReadonlyJSONObject>>(
312
559
  new Map(),
313
560
  );
561
+ const toolArgsTextCacheRef = useRef<
562
+ WeakMap<ReadonlyJSONObject, Map<string, string>>
563
+ >(new WeakMap());
314
564
  const mcpAppMetadataCacheRef = useRef<Map<string, McpAppMetadata>>(new Map());
565
+ const toolArtifactsRef = useRef<Map<string, unknown>>(new Map());
566
+ const toolInteractionsRef = useRef<Map<string, Unstable_ToolInteractionLog>>(
567
+ new Map(),
568
+ );
315
569
  const lastRunConfigRef = useRef<RunConfig | undefined>(undefined);
570
+ const markToolArtifactsChanged = useCallback(() => {
571
+ setToolArtifactEpoch((epoch) => epoch + 1);
572
+ }, []);
573
+ const markToolInteractionsChanged = useCallback(() => {
574
+ setToolInteractionEpoch((epoch) => epoch + 1);
575
+ }, []);
316
576
 
317
577
  const hasExecutingTools = Object.values(toolStatuses).some(
318
578
  (s) => s?.type === "executing",
@@ -336,11 +596,44 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
336
596
  : NO_CANCELLED_MESSAGE_IDS;
337
597
  const supportsRichToolApprovalResponses =
338
598
  customOnRespondToToolApproval != null;
599
+ const supersededApprovalProjectionRef = useRef(
600
+ NO_SUPERSEDED_APPROVAL_PROJECTION,
601
+ );
602
+ const supersededApprovalProjection = useMemo(
603
+ () => {
604
+ const projection = getSupersededApprovalProjection(
605
+ chatHelpers.messages,
606
+ hostApprovalIdsRef.current,
607
+ joinStrategy,
608
+ isRunning,
609
+ );
610
+ const previous = supersededApprovalProjectionRef.current;
611
+ if (
612
+ projection.approvalIds.size === previous.approvalIds.size &&
613
+ projection.statusMessageIds.size === previous.statusMessageIds.size &&
614
+ [...projection.approvalIds].every((id) =>
615
+ previous.approvalIds.has(id),
616
+ ) &&
617
+ [...projection.statusMessageIds].every((id) =>
618
+ previous.statusMessageIds.has(id),
619
+ )
620
+ )
621
+ return previous;
622
+
623
+ supersededApprovalProjectionRef.current = projection;
624
+ return projection;
625
+ },
626
+ // oxlint-disable-next-line react/exhaustive-deps -- hostApprovalIdsRef changes alongside toolApprovalResponses, which invalidates the projection
627
+ [chatHelpers.messages, joinStrategy, isRunning, toolApprovalResponses],
628
+ );
339
629
 
340
630
  const toThreadMessages = useCallback(
341
631
  (sourceMessages: UI_MESSAGE[]) => {
342
632
  const metadata: AISDKMessageConverterMetadata = {
343
633
  supportsRichToolApprovalResponses,
634
+ toolArtifacts: toolArtifactsRef.current,
635
+ toolInteractions: toolInteractionsRef.current,
636
+ toolApprovalResponses: toolApprovalResponsesRef.current,
344
637
  };
345
638
  return AISDKMessageConverter.toThreadMessages(
346
639
  sourceMessages,
@@ -391,9 +684,15 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
391
684
  toolStatuses,
392
685
  messageTiming,
393
686
  toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
687
+ toolArgsTextCache: toolArgsTextCacheRef.current,
394
688
  toolLastInputCache: toolLastInputCacheRef.current,
395
689
  mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
690
+ toolArtifacts: toolArtifactsRef.current,
691
+ toolInteractions: toolInteractionsRef.current,
396
692
  supportsRichToolApprovalResponses,
693
+ cancelledToolApprovalIds: supersededApprovalProjection.approvalIds,
694
+ cancelledStatusMessageIds:
695
+ supersededApprovalProjection.statusMessageIds,
397
696
  ...(optimisticMessageId && { optimisticMessageId }),
398
697
  ...(chatHelpers.error && {
399
698
  error: toChatError(chatHelpers.error),
@@ -409,6 +708,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
409
708
  cancelledMessageIds,
410
709
  toolApprovalResponses,
411
710
  supportsRichToolApprovalResponses,
711
+ supersededApprovalProjection,
712
+ toolArtifactEpoch,
713
+ toolInteractionEpoch,
412
714
  ],
413
715
  ),
414
716
  });
@@ -434,7 +736,12 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
434
736
  },
435
737
  }));
436
738
 
437
- const { isLoading, deleteMessage: deleteHistoryMessage } = useExternalHistory(
739
+ const {
740
+ isLoading,
741
+ deleteMessage: deleteHistoryMessage,
742
+ persistToolInteractions,
743
+ persistToolApprovalResponses,
744
+ } = useExternalHistory(
438
745
  runtimeRef,
439
746
  adapters?.history ?? contextAdapters?.history,
440
747
  toThreadMessages,
@@ -445,6 +752,20 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
445
752
  (messages) => {
446
753
  chatHelpers.setMessages(messages);
447
754
  },
755
+ toolArtifactsRef.current,
756
+ markToolArtifactsChanged,
757
+ toolInteractionsRef.current,
758
+ markToolInteractionsChanged,
759
+ toolApprovalResponsesRef.current,
760
+ () => {
761
+ for (const [id, entry] of ownedApprovals ?? []) {
762
+ toolApprovalResponsesRef.current.set(id, entry.response);
763
+ }
764
+ hostApprovalIdsRef.current = new Set(
765
+ toolApprovalResponsesRef.current.keys(),
766
+ );
767
+ setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
768
+ },
448
769
  );
449
770
 
450
771
  const {
@@ -467,35 +788,43 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
467
788
  if (!cancelPendingToolCallsOnSend) return;
468
789
 
469
790
  // The runtime auto-aborts in-flight tool invocations when a new run
470
- // is dispatched (append() / startRun()). All we need to do here is
471
- // mark any tool without a result as cancelled in the UI message list.
472
-
473
- // Mark any tool without a result as cancelled (uses setMessages to avoid triggering sendAutomaticallyWhen)
791
+ // is dispatched (append() / startRun()), so this only has to mark the
792
+ // abandoned tools cancelled in the UI message list. Every non-terminal
793
+ // tool call qualifies wherever it sits: the run that produced it is over,
794
+ // and a staged `startRun: false` message can sit between it and the tail.
795
+ // Uses setMessages to avoid triggering sendAutomaticallyWhen.
474
796
  chatHelpers.setMessages((messages) => {
475
- const lastMessage = messages.at(-1);
476
- if (lastMessage?.role !== "assistant") return messages;
477
-
478
797
  let hasChanges = false;
479
- const parts = lastMessage.parts?.map((part) => {
480
- if (!isToolUIPart(part)) return part;
481
- if (
482
- part.state === "output-available" ||
483
- part.state === "output-error" ||
484
- part.state === "output-denied"
485
- )
486
- return part;
487
798
 
799
+ const next = messages.map((message) => {
800
+ if (message.role !== "assistant") return message;
801
+
802
+ let messageChanged = false;
803
+ const parts = message.parts?.map((part) => {
804
+ if (!isToolUIPart(part)) return part;
805
+ if (
806
+ part.state === "output-available" ||
807
+ part.state === "output-error" ||
808
+ part.state === "output-denied"
809
+ )
810
+ return part;
811
+
812
+ messageChanged = true;
813
+ const { approval: _approval, ...rest } = part;
814
+ return {
815
+ ...rest,
816
+ state: "output-error" as const,
817
+ errorText: "User cancelled tool call by sending a new message.",
818
+ };
819
+ });
820
+
821
+ if (!messageChanged) return message;
488
822
  hasChanges = true;
489
- const { approval: _approval, ...rest } = part;
490
- return {
491
- ...rest,
492
- state: "output-error" as const,
493
- errorText: "User cancelled tool call by sending a new message.",
494
- };
823
+ return { ...message, parts };
495
824
  });
496
825
 
497
826
  if (!hasChanges) return messages;
498
- return [...messages.slice(0, -1), { ...lastMessage, parts }];
827
+ return next;
499
828
  });
500
829
  };
501
830
 
@@ -519,12 +848,15 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
519
848
  ) => {
520
849
  const { approvalId } = response;
521
850
  const requested = chatHelpers.messages
522
- .flatMap((message) => message.parts)
523
- .filter(isToolUIPart)
851
+ .flatMap((message) =>
852
+ message.parts.flatMap((part) =>
853
+ isToolUIPart(part) ? [{ messageId: message.id, part }] : [],
854
+ ),
855
+ )
524
856
  .find(
525
- (part) =>
857
+ ({ part }) =>
526
858
  part.state === "approval-requested" &&
527
- part.approval.id === approvalId,
859
+ part.approval?.id === approvalId,
528
860
  );
529
861
  if (!requested || hostApprovalIdsRef.current.has(approvalId))
530
862
  throw new Error(
@@ -532,22 +864,47 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
532
864
  );
533
865
 
534
866
  // A host answer stays out of the useChat messages, where sendAutomaticallyWhen would forward it to the chat route.
867
+ // The owner can change while a response is in flight, and the id set is a
868
+ // ref that is reseeded when it does. Both writes are therefore scoped to
869
+ // the record this response started under, so a rollback never reaches a
870
+ // different chat's state.
871
+ const startedWith = ownedApprovals;
872
+ const startedOwner = approvalOwner;
873
+ const startedResponses = toolApprovalResponsesRef.current;
874
+ // Whether this response is currently applied, tracked here rather than
875
+ // read back from the id ref: that ref follows the chat on screen and is
876
+ // reseeded when the owner changes, so it cannot answer for this response.
877
+ let isApplied = false;
535
878
  const applyResponse = (applied: boolean) => {
879
+ isApplied = applied;
880
+ // The captured record is always corrected, so a rollback reaches the
881
+ // chat the response belongs to even after the owner moved on.
882
+ if (applied)
883
+ startedWith?.set(approvalId, {
884
+ response,
885
+ });
886
+ else startedWith?.delete(approvalId);
887
+
888
+ if (startedOwner) {
889
+ // Every runtime mounted over that owner re-reads the record, including
890
+ // one mounted after this response started.
891
+ notifyHostApprovals(startedOwner);
892
+ return;
893
+ }
894
+
895
+ if (lastApprovalOwnerRef.current !== startedOwner) return;
536
896
  if (applied) hostApprovalIdsRef.current.add(approvalId);
537
897
  else hostApprovalIdsRef.current.delete(approvalId);
538
- setToolApprovalResponses((prev) => {
539
- const responses = new Map(prev);
540
- if (applied) responses.set(approvalId, response);
541
- else responses.delete(approvalId);
542
- return responses;
543
- });
898
+ if (applied) toolApprovalResponsesRef.current.set(approvalId, response);
899
+ else toolApprovalResponsesRef.current.delete(approvalId);
900
+ setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
544
901
  };
545
902
 
546
903
  applyResponse(true);
547
904
  try {
548
905
  await onRespond(response, {
549
- toolCallId: requested.toolCallId,
550
- toolName: getToolName(requested),
906
+ toolCallId: requested.part.toolCallId,
907
+ toolName: getToolName(requested.part),
551
908
  respondViaAISDK: async () => {
552
909
  try {
553
910
  await respondViaAISDK(response);
@@ -557,9 +914,14 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
557
914
  },
558
915
  });
559
916
  } catch (error) {
560
- if (hostApprovalIdsRef.current.has(approvalId)) applyResponse(false);
917
+ if (isApplied) applyResponse(false);
561
918
  throw error;
562
919
  }
920
+ const remainsApplied =
921
+ startedWith?.has(approvalId) ?? startedResponses.has(approvalId);
922
+ if (isApplied && remainsApplied) {
923
+ await persistToolApprovalResponses(requested.messageId);
924
+ }
563
925
  };
564
926
 
565
927
  const hasSeededRepositoryRef = useRef(false);
@@ -569,6 +931,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
569
931
  messages.length === 0;
570
932
 
571
933
  const runtime = useExternalStoreRuntime({
934
+ unstable_persistsHistory: true,
572
935
  isRunning: providerIsRunning,
573
936
  ...(shouldFeedRepository
574
937
  ? { messageRepository: exportedMessageRepository }
@@ -714,6 +1077,36 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
714
1077
 
715
1078
  await deleteHistoryMessage(messageId);
716
1079
 
1080
+ let removedToolArtifact = false;
1081
+ let removedToolInteractions = false;
1082
+ let removedToolApprovalResponse = false;
1083
+ let removedHostApprovalId = false;
1084
+ for (const part of threadMessages[messageIndex]!.content) {
1085
+ if (part.type === "tool-call") {
1086
+ removedToolArtifact =
1087
+ toolArtifactsRef.current.delete(part.toolCallId) ||
1088
+ removedToolArtifact;
1089
+ removedToolInteractions =
1090
+ toolInteractionsRef.current.delete(part.toolCallId) ||
1091
+ removedToolInteractions;
1092
+ if (part.approval) {
1093
+ ownedApprovals?.delete(part.approval.id);
1094
+ removedToolApprovalResponse =
1095
+ toolApprovalResponsesRef.current.delete(part.approval.id) ||
1096
+ removedToolApprovalResponse;
1097
+ removedHostApprovalId =
1098
+ hostApprovalIdsRef.current.delete(part.approval.id) ||
1099
+ removedHostApprovalId;
1100
+ }
1101
+ }
1102
+ }
1103
+ if (removedToolArtifact) markToolArtifactsChanged();
1104
+ if (removedToolInteractions) markToolInteractionsChanged();
1105
+ if (removedToolApprovalResponse || removedHostApprovalId) {
1106
+ if (approvalOwner) notifyHostApprovals(approvalOwner);
1107
+ setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
1108
+ }
1109
+
717
1110
  const deleteIds = new Set(
718
1111
  getExternalStoreMessages<UI_MESSAGE>(threadMessages[messageIndex]!).map(
719
1112
  (message) => message.id,
@@ -731,12 +1124,74 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
731
1124
  await chatHelpers.regenerate({ metadata: config.runConfig });
732
1125
  },
733
1126
  onAddToolResult: ({
1127
+ messageId,
734
1128
  toolCallId,
735
1129
  toolName,
736
1130
  result,
737
1131
  isError,
1132
+ artifact,
738
1133
  modelContent,
739
1134
  }) => {
1135
+ if (artifact !== undefined) {
1136
+ toolArtifactsRef.current.set(toolCallId, artifact);
1137
+ markToolArtifactsChanged();
1138
+ }
1139
+
1140
+ const targetIndex = findRawToolMessageIndex(
1141
+ chatHelpers.messages,
1142
+ messageId,
1143
+ toolCallId,
1144
+ joinStrategy,
1145
+ );
1146
+
1147
+ const errorText =
1148
+ typeof result === "string" ? result : JSON.stringify(result);
1149
+ const output =
1150
+ !isError && modelContent !== undefined
1151
+ ? wrapModelContentEnvelope(result, modelContent)
1152
+ : result;
1153
+
1154
+ if (targetIndex >= 0 && targetIndex !== chatHelpers.messages.length - 1) {
1155
+ const target = chatHelpers.messages[targetIndex]!;
1156
+ const targetPart = target.parts.find(
1157
+ (part) => isToolUIPart(part) && part.toolCallId === toolCallId,
1158
+ ) as { state?: string; preliminary?: boolean } | undefined;
1159
+ // An earlier message's settled output may already have reached the model, as the error a cancelling send writes does.
1160
+ if (
1161
+ targetPart?.state === "output-error" ||
1162
+ targetPart?.state === "output-denied" ||
1163
+ (targetPart?.state === "output-available" && !targetPart.preliminary)
1164
+ )
1165
+ return Promise.resolve();
1166
+
1167
+ const targetMessageId = target.id;
1168
+ chatHelpers.setMessages((current) =>
1169
+ current.map((message) => {
1170
+ if (message.id !== targetMessageId) return message;
1171
+
1172
+ return {
1173
+ ...message,
1174
+ parts: message.parts.map((part) => {
1175
+ if (!isToolUIPart(part) || part.toolCallId !== toolCallId)
1176
+ return part;
1177
+
1178
+ const { preliminary: _preliminary, ...finalPart } =
1179
+ part as typeof part & { preliminary?: boolean };
1180
+ return {
1181
+ ...finalPart,
1182
+ state: isError
1183
+ ? ("output-error" as const)
1184
+ : ("output-available" as const),
1185
+ output: isError ? undefined : output,
1186
+ errorText: isError ? errorText : undefined,
1187
+ } as typeof part;
1188
+ }),
1189
+ };
1190
+ }),
1191
+ );
1192
+ return Promise.resolve();
1193
+ }
1194
+
740
1195
  const options = { metadata: lastRunConfigRef.current };
741
1196
  if (isError) {
742
1197
  return Promise.resolve(
@@ -744,29 +1199,47 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
744
1199
  state: "output-error",
745
1200
  tool: toolName ?? toolCallId,
746
1201
  toolCallId,
747
- errorText:
748
- typeof result === "string" ? result : JSON.stringify(result),
749
- options,
750
- }),
751
- );
752
- } else {
753
- const output =
754
- modelContent !== undefined
755
- ? wrapModelContentEnvelope(result, modelContent)
756
- : result;
757
- return Promise.resolve(
758
- chatHelpers.addToolOutput({
759
- tool: toolName,
760
- toolCallId,
761
- output,
1202
+ errorText,
762
1203
  options,
763
1204
  }),
764
1205
  );
765
1206
  }
1207
+
1208
+ return Promise.resolve(
1209
+ chatHelpers.addToolOutput({
1210
+ tool: toolName,
1211
+ toolCallId,
1212
+ output,
1213
+ options,
1214
+ }),
1215
+ );
1216
+ },
1217
+ onRespondToToolApproval: (response) => {
1218
+ if (supersededApprovalProjection.approvalIds.has(response.approvalId))
1219
+ return Promise.reject(
1220
+ new Error(
1221
+ `Tool approval ${response.approvalId} is not waiting for a response.`,
1222
+ ),
1223
+ );
1224
+ return customOnRespondToToolApproval
1225
+ ? respondViaHost(customOnRespondToToolApproval, response)
1226
+ : respondViaAISDK(response);
1227
+ },
1228
+ unstable_onRecordToolInteraction: ({
1229
+ messageId,
1230
+ toolCallId,
1231
+ interaction,
1232
+ }) => {
1233
+ toolInteractionsRef.current.set(
1234
+ toolCallId,
1235
+ appendToolInteraction(
1236
+ toolInteractionsRef.current.get(toolCallId),
1237
+ interaction,
1238
+ ),
1239
+ );
1240
+ markToolInteractionsChanged();
1241
+ return persistToolInteractions(messageId);
766
1242
  },
767
- onRespondToToolApproval: customOnRespondToToolApproval
768
- ? (response) => respondViaHost(customOnRespondToToolApproval, response)
769
- : respondViaAISDK,
770
1243
  ...pickExternalStoreSharedOptions(adapter),
771
1244
  ...(adapter.unstable_messageRepositoryInstance && {
772
1245
  unstable_messageRepositoryInstance: