@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
@@ -8,9 +8,14 @@ import type {
8
8
  MessageFormatItem,
9
9
  MessageFormatRepository,
10
10
  ExportedMessageRepository,
11
+ RespondToToolApprovalOptions,
12
+ Unstable_ToolInteractionLog,
11
13
  } from "@assistant-ui/core";
12
14
  import { getExternalStoreMessages } from "@assistant-ui/core";
13
- import { MessageRepository } from "@assistant-ui/core/internal";
15
+ import {
16
+ MessageRepository,
17
+ readToolInteractionLog,
18
+ } from "@assistant-ui/core/internal";
14
19
  import { useAui } from "@assistant-ui/store";
15
20
  import {
16
21
  useRef,
@@ -50,6 +55,211 @@ const isAwaitingToolApproval = (message: ThreadMessage) =>
50
55
  message.status?.type === "requires-action" &&
51
56
  message.status.reason === "tool-calls";
52
57
 
58
+ const isTerminalMessage = (message: ThreadMessage) =>
59
+ message.status === undefined ||
60
+ message.status.type === "complete" ||
61
+ message.status.type === "incomplete";
62
+
63
+ const TOOL_ARTIFACTS_METADATA_KEY = "__aui_toolArtifacts";
64
+ const TOOL_INTERACTIONS_METADATA_KEY = "__aui_toolInteractions";
65
+ const TOOL_APPROVAL_RESPONSES_METADATA_KEY = "__aui_toolApprovalResponses";
66
+
67
+ type StoredToolApprovalResponse = Omit<
68
+ RespondToToolApprovalOptions,
69
+ "approvalId"
70
+ >;
71
+
72
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
73
+ value !== null && typeof value === "object" && !Array.isArray(value);
74
+
75
+ const collectToolArtifacts = (
76
+ message: ThreadMessage,
77
+ toolArtifacts: ReadonlyMap<string, unknown> | undefined,
78
+ ) => {
79
+ if (!toolArtifacts) return undefined;
80
+ const entries = message.content.flatMap((part) => {
81
+ if (part.type !== "tool-call") return [];
82
+ const artifact = toolArtifacts.get(part.toolCallId);
83
+ return artifact === undefined ? [] : [[part.toolCallId, artifact] as const];
84
+ });
85
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
86
+ };
87
+
88
+ const collectToolInteractions = (
89
+ message: ThreadMessage,
90
+ toolInteractions:
91
+ | ReadonlyMap<string, Unstable_ToolInteractionLog>
92
+ | undefined,
93
+ ) => {
94
+ if (!toolInteractions) return undefined;
95
+ const entries = message.content.flatMap((part) => {
96
+ if (part.type !== "tool-call") return [];
97
+ const interactions = toolInteractions.get(part.toolCallId);
98
+ return interactions === undefined
99
+ ? []
100
+ : [[part.toolCallId, interactions] as const];
101
+ });
102
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
103
+ };
104
+
105
+ const collectToolApprovalResponses = (
106
+ message: ThreadMessage,
107
+ toolApprovalResponses:
108
+ | ReadonlyMap<string, RespondToToolApprovalOptions>
109
+ | undefined,
110
+ ) => {
111
+ if (!toolApprovalResponses) return undefined;
112
+ const entries = message.content.flatMap((part) => {
113
+ if (part.type !== "tool-call" || !part.approval) return [];
114
+ const response = toolApprovalResponses.get(part.approval.id);
115
+ if (!response) return [];
116
+ return [
117
+ [
118
+ part.approval.id,
119
+ {
120
+ approved: response.approved,
121
+ ...(response.optionId != null && { optionId: response.optionId }),
122
+ ...(response.text != null && { text: response.text }),
123
+ ...(response.reason != null && { reason: response.reason }),
124
+ },
125
+ ] as const,
126
+ ];
127
+ });
128
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
129
+ };
130
+
131
+ const addToolData = <TMessage>(
132
+ message: TMessage,
133
+ toolArtifacts: Record<string, unknown> | undefined,
134
+ toolInteractions: Record<string, Unstable_ToolInteractionLog> | undefined,
135
+ toolApprovalResponses: Record<string, StoredToolApprovalResponse> | undefined,
136
+ ): TMessage => {
137
+ if (
138
+ (!toolArtifacts && !toolInteractions && !toolApprovalResponses) ||
139
+ !isRecord(message) ||
140
+ !Array.isArray(message.parts)
141
+ )
142
+ return message;
143
+ const toolCallIds = message.parts.flatMap((part) => {
144
+ if (!isRecord(part) || typeof part.toolCallId !== "string") return [];
145
+ return [part.toolCallId];
146
+ });
147
+ const artifacts = toolArtifacts
148
+ ? Object.fromEntries(
149
+ toolCallIds.flatMap((toolCallId) =>
150
+ Object.hasOwn(toolArtifacts, toolCallId)
151
+ ? [[toolCallId, toolArtifacts[toolCallId]] as const]
152
+ : [],
153
+ ),
154
+ )
155
+ : undefined;
156
+ const interactions = toolInteractions
157
+ ? Object.fromEntries(
158
+ toolCallIds.flatMap((toolCallId) =>
159
+ Object.hasOwn(toolInteractions, toolCallId)
160
+ ? [[toolCallId, toolInteractions[toolCallId]] as const]
161
+ : [],
162
+ ),
163
+ )
164
+ : undefined;
165
+ const approvalIds = message.parts.flatMap((part) => {
166
+ if (!isRecord(part) || !isRecord(part.approval)) return [];
167
+ const approvalId = part.approval.id;
168
+ return typeof approvalId === "string" ? [approvalId] : [];
169
+ });
170
+ const approvalResponses = toolApprovalResponses
171
+ ? Object.fromEntries(
172
+ approvalIds.flatMap((approvalId) =>
173
+ Object.hasOwn(toolApprovalResponses, approvalId)
174
+ ? [[approvalId, toolApprovalResponses[approvalId]] as const]
175
+ : [],
176
+ ),
177
+ )
178
+ : undefined;
179
+ const hasArtifacts = !!artifacts && Object.keys(artifacts).length > 0;
180
+ const hasInteractions =
181
+ !!interactions && Object.keys(interactions).length > 0;
182
+ const hasApprovalResponses =
183
+ !!approvalResponses && Object.keys(approvalResponses).length > 0;
184
+ if (!hasArtifacts && !hasInteractions && !hasApprovalResponses)
185
+ return message;
186
+ const metadata = isRecord(message.metadata) ? message.metadata : {};
187
+ return {
188
+ ...message,
189
+ metadata: {
190
+ ...metadata,
191
+ ...(hasArtifacts && { [TOOL_ARTIFACTS_METADATA_KEY]: artifacts }),
192
+ ...(hasInteractions && {
193
+ [TOOL_INTERACTIONS_METADATA_KEY]: interactions,
194
+ }),
195
+ ...(hasApprovalResponses && {
196
+ [TOOL_APPROVAL_RESPONSES_METADATA_KEY]: approvalResponses,
197
+ }),
198
+ },
199
+ } as TMessage;
200
+ };
201
+
202
+ const restoreToolData = <TMessage>(
203
+ message: TMessage,
204
+ toolArtifacts: Map<string, unknown> | undefined,
205
+ toolInteractions: Map<string, Unstable_ToolInteractionLog> | undefined,
206
+ toolApprovalResponses: Map<string, RespondToToolApprovalOptions> | undefined,
207
+ ): TMessage => {
208
+ if (!isRecord(message) || !isRecord(message.metadata)) return message;
209
+ const metadata = message.metadata;
210
+ const hasArtifacts = Object.hasOwn(metadata, TOOL_ARTIFACTS_METADATA_KEY);
211
+ const hasInteractions = Object.hasOwn(
212
+ metadata,
213
+ TOOL_INTERACTIONS_METADATA_KEY,
214
+ );
215
+ const hasApprovalResponses = Object.hasOwn(
216
+ metadata,
217
+ TOOL_APPROVAL_RESPONSES_METADATA_KEY,
218
+ );
219
+ if (!hasArtifacts && !hasInteractions && !hasApprovalResponses)
220
+ return message;
221
+ const artifacts = metadata[TOOL_ARTIFACTS_METADATA_KEY];
222
+ if (toolArtifacts && isRecord(artifacts)) {
223
+ for (const [toolCallId, artifact] of Object.entries(artifacts)) {
224
+ toolArtifacts.set(toolCallId, artifact);
225
+ }
226
+ }
227
+ const interactions = metadata[TOOL_INTERACTIONS_METADATA_KEY];
228
+ if (toolInteractions && isRecord(interactions)) {
229
+ for (const [toolCallId, value] of Object.entries(interactions)) {
230
+ const log = readToolInteractionLog(value);
231
+ if (log) toolInteractions.set(toolCallId, log);
232
+ }
233
+ }
234
+ const approvalResponses = metadata[TOOL_APPROVAL_RESPONSES_METADATA_KEY];
235
+ if (toolApprovalResponses && isRecord(approvalResponses)) {
236
+ for (const [approvalId, value] of Object.entries(approvalResponses)) {
237
+ if (!isRecord(value) || typeof value.approved !== "boolean") continue;
238
+ toolApprovalResponses.set(approvalId, {
239
+ approvalId,
240
+ approved: value.approved,
241
+ ...(typeof value.optionId === "string" && {
242
+ optionId: value.optionId,
243
+ }),
244
+ ...(typeof value.text === "string" && { text: value.text }),
245
+ ...(typeof value.reason === "string" && { reason: value.reason }),
246
+ });
247
+ }
248
+ }
249
+ const {
250
+ [TOOL_ARTIFACTS_METADATA_KEY]: _,
251
+ [TOOL_INTERACTIONS_METADATA_KEY]: __,
252
+ [TOOL_APPROVAL_RESPONSES_METADATA_KEY]: ___,
253
+ ...restMetadata
254
+ } = metadata;
255
+ const { metadata: _metadata, ...restMessage } = message;
256
+ return (
257
+ Object.keys(restMetadata).length === 0
258
+ ? restMessage
259
+ : { ...restMessage, metadata: restMetadata }
260
+ ) as TMessage;
261
+ };
262
+
53
263
  const encodeContent = <TMessage>(
54
264
  storageFormatAdapter: MessageFormatAdapter<TMessage, any>,
55
265
  item: MessageFormatItem<TMessage>,
@@ -61,6 +271,12 @@ export const useExternalHistory = <TMessage>(
61
271
  toThreadMessages: (messages: TMessage[]) => ThreadMessage[],
62
272
  storageFormatAdapter: MessageFormatAdapter<TMessage, any>,
63
273
  onSetMessages: (messages: TMessage[]) => void,
274
+ toolArtifacts?: Map<string, unknown>,
275
+ onToolArtifactsRestored?: () => void,
276
+ toolInteractions?: Map<string, Unstable_ToolInteractionLog>,
277
+ onToolInteractionsRestored?: () => void,
278
+ toolApprovalResponses?: Map<string, RespondToToolApprovalOptions>,
279
+ onToolApprovalResponsesRestored?: () => void,
64
280
  ) => {
65
281
  const loadedRef = useRef(false);
66
282
  const [itemEpoch, setItemEpoch] = useState(0);
@@ -103,17 +319,37 @@ export const useExternalHistory = <TMessage>(
103
319
  const loadHistory = async () => {
104
320
  try {
105
321
  const repo = await formatAdapter.load();
106
- if (repo && repo.messages.length > 0) {
107
- for (const m of repo.messages) {
322
+ toolArtifacts?.clear();
323
+ toolInteractions?.clear();
324
+ toolApprovalResponses?.clear();
325
+ const restoredMessages =
326
+ repo?.messages.map((item) => ({
327
+ ...item,
328
+ message: restoreToolData(
329
+ item.message,
330
+ toolArtifacts,
331
+ toolInteractions,
332
+ toolApprovalResponses,
333
+ ),
334
+ })) ?? [];
335
+ onToolArtifactsRestored?.();
336
+ onToolInteractionsRestored?.();
337
+ onToolApprovalResponsesRestored?.();
338
+ if (repo && restoredMessages.length > 0) {
339
+ const restoredRepo = { ...repo, messages: restoredMessages };
340
+ for (const [index, m] of repo.messages.entries()) {
108
341
  persistedInnerMessages.current.set(
109
342
  storageFormatAdapter.getId(m.message),
110
343
  {
111
- source: m.message,
344
+ source: restoredMessages[index]!.message,
112
345
  content: encodeContent(storageFormatAdapter, m),
113
346
  },
114
347
  );
115
348
  }
116
- const converted = toExportedMessageRepository(toThreadMessages, repo);
349
+ const converted = toExportedMessageRepository(
350
+ toThreadMessages,
351
+ restoredRepo,
352
+ );
117
353
  runtimeRef.current.thread.import(converted);
118
354
 
119
355
  const tempRepo = new MessageRepository();
@@ -172,6 +408,12 @@ export const useExternalHistory = <TMessage>(
172
408
  aui,
173
409
  itemEpoch,
174
410
  storageFormatAdapter,
411
+ toolArtifacts,
412
+ onToolArtifactsRestored,
413
+ toolInteractions,
414
+ onToolInteractionsRestored,
415
+ toolApprovalResponses,
416
+ onToolApprovalResponsesRestored,
175
417
  ]);
176
418
 
177
419
  const runStartRef = useRef<number | null>(null);
@@ -181,10 +423,96 @@ export const useExternalHistory = <TMessage>(
181
423
  const wasRunningRef = useRef(false);
182
424
  const toolCallCountRef = useRef(0);
183
425
 
426
+ const persistToolInteractions = useCallback(
427
+ (messageId: string) => {
428
+ const persistence = persistInFlightRef.current.then(async () => {
429
+ if (!formatAdapter?.update) return;
430
+ const messages = runtimeRef.current.thread.getState().messages;
431
+ const message = messages.find(
432
+ (item) =>
433
+ item.id === messageId ||
434
+ getExternalStoreMessages<TMessage>(item).some(
435
+ (innerMessage) =>
436
+ storageFormatAdapter.getId(innerMessage) === messageId,
437
+ ),
438
+ );
439
+ if (!message) return;
440
+
441
+ const previousMessages = messages
442
+ .slice(0, messages.indexOf(message))
443
+ .flatMap(getExternalStoreMessages<TMessage>);
444
+ let parentId = previousMessages.at(-1)
445
+ ? storageFormatAdapter.getId(previousMessages.at(-1)!)
446
+ : null;
447
+ const storedToolArtifacts = collectToolArtifacts(
448
+ message,
449
+ toolArtifacts,
450
+ );
451
+ const storedToolInteractions = collectToolInteractions(
452
+ message,
453
+ toolInteractions,
454
+ );
455
+ const storedToolApprovalResponses = collectToolApprovalResponses(
456
+ message,
457
+ toolApprovalResponses,
458
+ );
459
+
460
+ for (const innerMessage of getExternalStoreMessages<TMessage>(
461
+ message,
462
+ )) {
463
+ const item = {
464
+ parentId,
465
+ message: addToolData(
466
+ innerMessage,
467
+ storedToolArtifacts,
468
+ storedToolInteractions,
469
+ storedToolApprovalResponses,
470
+ ),
471
+ };
472
+ const innerId = storageFormatAdapter.getId(item.message);
473
+ const persisted = persistedInnerMessages.current.get(innerId);
474
+ if (persisted) {
475
+ const content = encodeContent(storageFormatAdapter, item);
476
+ if (content === persisted.content) {
477
+ persisted.source = item.message;
478
+ } else {
479
+ await formatAdapter.update(item, innerId);
480
+ persistedInnerMessages.current.set(innerId, {
481
+ source: item.message,
482
+ content,
483
+ });
484
+ }
485
+ }
486
+ parentId = innerId;
487
+ }
488
+ });
489
+ persistInFlightRef.current = persistence.catch(() => {});
490
+ return persistence.catch((error) => {
491
+ console.error("Failed to persist tool data:", error);
492
+ });
493
+ },
494
+ [
495
+ formatAdapter,
496
+ runtimeRef,
497
+ storageFormatAdapter,
498
+ toolArtifacts,
499
+ toolInteractions,
500
+ toolApprovalResponses,
501
+ ],
502
+ );
503
+
184
504
  useEffect(() => {
185
505
  if (!formatAdapter) return;
186
506
  const adapter = formatAdapter;
187
507
 
508
+ const idleScheduledInnerIds = new Set<string>();
509
+ const unpersistedInnerIds = (message: ThreadMessage) =>
510
+ isTerminalMessage(message)
511
+ ? getExternalStoreMessages<TMessage>(message)
512
+ .map((innerMessage) => storageFormatAdapter.getId(innerMessage))
513
+ .filter((innerId) => !persistedInnerMessages.current.has(innerId))
514
+ : [];
515
+
188
516
  const unsubscribe = runtimeRef.current.thread.subscribe(() => {
189
517
  const threadState = runtimeRef.current.thread.getState();
190
518
  const { isRunning } = threadState;
@@ -220,12 +548,16 @@ export const useExternalHistory = <TMessage>(
220
548
  return;
221
549
  }
222
550
 
223
- // Only act on the true→false transition
224
- if (!wasRunning) return;
225
-
226
- // Record step boundary offset (synchronous for accuracy)
227
- if (runStartRef.current != null) {
228
- stepBoundariesRef.current.push(Date.now() - runStartRef.current);
551
+ if (wasRunning) {
552
+ // Record step boundary offset (synchronous for accuracy)
553
+ if (runStartRef.current != null) {
554
+ stepBoundariesRef.current.push(Date.now() - runStartRef.current);
555
+ }
556
+ } else {
557
+ const pending = threadState.messages.flatMap(unpersistedInnerIds);
558
+ if (pending.every((innerId) => idleScheduledInnerIds.has(innerId)))
559
+ return;
560
+ for (const innerId of pending) idleScheduledInnerIds.add(innerId);
229
561
  }
230
562
 
231
563
  // Debounce: wait one macrotask so agentic step flickers are absorbed
@@ -301,22 +633,33 @@ export const useExternalHistory = <TMessage>(
301
633
  const getLastInnerId = (msgs: TMessage[]): string | null =>
302
634
  msgs.length > 0 ? storageFormatAdapter.getId(msgs.at(-1)!) : null;
303
635
 
304
- const toBatchItems = (msgs: TMessage[]) =>
636
+ const toBatchItems = (
637
+ msgs: TMessage[],
638
+ toolArtifacts: Record<string, unknown> | undefined,
639
+ toolInteractions:
640
+ | Record<string, Unstable_ToolInteractionLog>
641
+ | undefined,
642
+ toolApprovalResponses:
643
+ | Record<string, StoredToolApprovalResponse>
644
+ | undefined,
645
+ ) =>
305
646
  msgs.map((msg, idx) => ({
306
647
  parentId:
307
648
  idx === 0
308
649
  ? lastInnerMessageId
309
650
  : storageFormatAdapter.getId(msgs[idx - 1]!),
310
- message: msg,
651
+ message: addToolData(
652
+ msg,
653
+ toolArtifacts,
654
+ toolInteractions,
655
+ toolApprovalResponses,
656
+ ),
311
657
  }));
312
658
 
313
659
  for (const message of messages) {
314
660
  const innerMessages = getExternalStoreMessages<TMessage>(message);
315
661
 
316
- const isTerminal =
317
- message.status === undefined ||
318
- message.status.type === "complete" ||
319
- message.status.type === "incomplete";
662
+ const isTerminal = isTerminalMessage(message);
320
663
  const isAwaitingToolCalls = isAwaitingToolApproval(message);
321
664
  // A paused message's later content can only reach storage via update, so it is persisted early only when the adapter supports update.
322
665
  const isReady =
@@ -334,7 +677,12 @@ export const useExternalHistory = <TMessage>(
334
677
  deferredTelemetryIds.current.add(message.id);
335
678
  }
336
679
 
337
- const batchItems = toBatchItems(innerMessages);
680
+ const batchItems = toBatchItems(
681
+ innerMessages,
682
+ collectToolArtifacts(message, toolArtifacts),
683
+ collectToolInteractions(message, toolInteractions),
684
+ collectToolApprovalResponses(message, toolApprovalResponses),
685
+ );
338
686
  for (const item of batchItems) {
339
687
  const innerId = storageFormatAdapter.getId(item.message);
340
688
  const persisted = persistedInnerMessages.current.get(innerId);
@@ -371,10 +719,16 @@ export const useExternalHistory = <TMessage>(
371
719
 
372
720
  if (deferredTelemetryIds.current.has(message.id) && isTerminal) {
373
721
  deferredTelemetryIds.current.delete(message.id);
374
- adapter.reportTelemetry?.(batchItems, {
375
- ...telemetryOptions,
376
- message,
377
- });
722
+ adapter.reportTelemetry?.(
723
+ batchItems.map((item, index) => ({
724
+ ...item,
725
+ message: innerMessages[index]!,
726
+ })),
727
+ {
728
+ ...telemetryOptions,
729
+ message,
730
+ },
731
+ );
378
732
  }
379
733
  }
380
734
  })
@@ -391,7 +745,14 @@ export const useExternalHistory = <TMessage>(
391
745
  persistSettled(false);
392
746
  }
393
747
  };
394
- }, [formatAdapter, storageFormatAdapter, runtimeRef]);
748
+ }, [
749
+ formatAdapter,
750
+ storageFormatAdapter,
751
+ runtimeRef,
752
+ toolArtifacts,
753
+ toolInteractions,
754
+ toolApprovalResponses,
755
+ ]);
395
756
 
396
757
  const deleteMessage = useCallback(
397
758
  async (messageId: string) => {
@@ -434,5 +795,10 @@ export const useExternalHistory = <TMessage>(
434
795
  [formatAdapter, runtimeRef, storageFormatAdapter],
435
796
  );
436
797
 
437
- return { isLoading, deleteMessage };
798
+ return {
799
+ isLoading,
800
+ deleteMessage,
801
+ persistToolInteractions,
802
+ persistToolApprovalResponses: persistToolInteractions,
803
+ };
438
804
  };
@@ -1,11 +1,19 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
3
3
 
4
- export const useResourceCleanup = (enabled: boolean, cleanup: () => void) => {
4
+ export const useResourceCleanup = (
5
+ enabled: boolean,
6
+ cleanup: () => void,
7
+ hostDestroySignal?: AbortSignal,
8
+ ) => {
5
9
  const destroySignal = useAssistantClientDestroySignal();
6
10
  const cleanupRef = useRef(cleanup);
7
11
  const enabledRef = useRef(enabled);
8
- const registeredSignalRef = useRef<AbortSignal | undefined>(undefined);
12
+ const registrationRef = useRef<{
13
+ destroySignal: AbortSignal | undefined;
14
+ hostDestroySignal: AbortSignal | undefined;
15
+ controller: AbortController;
16
+ } | null>(null);
9
17
 
10
18
  useEffect(() => {
11
19
  cleanupRef.current = cleanup;
@@ -13,20 +21,38 @@ export const useResourceCleanup = (enabled: boolean, cleanup: () => void) => {
13
21
  });
14
22
 
15
23
  useEffect(() => {
16
- if (!enabled || !destroySignal) return undefined;
17
- if (registeredSignalRef.current === destroySignal) return undefined;
24
+ if (!enabled || (!destroySignal && !hostDestroySignal)) return undefined;
25
+ const current = registrationRef.current;
26
+ if (
27
+ current !== null &&
28
+ current.destroySignal === destroySignal &&
29
+ current.hostDestroySignal === hostDestroySignal
30
+ ) {
31
+ return undefined;
32
+ }
33
+ current?.controller.abort();
18
34
 
19
- registeredSignalRef.current = destroySignal;
20
- destroySignal.addEventListener(
21
- "abort",
22
- () => {
23
- if (enabledRef.current) cleanupRef.current();
24
- },
25
- { once: true },
26
- );
35
+ const registration = new AbortController();
36
+ registrationRef.current = {
37
+ destroySignal,
38
+ hostDestroySignal,
39
+ controller: registration,
40
+ };
41
+ const run = () => {
42
+ if (registration.signal.aborted) return;
43
+ registration.abort();
44
+ if (enabledRef.current) cleanupRef.current();
45
+ };
46
+ if (destroySignal?.aborted || hostDestroySignal?.aborted) {
47
+ run();
48
+ return undefined;
49
+ }
50
+ const options = { once: true, signal: registration.signal };
51
+ destroySignal?.addEventListener("abort", run, options);
52
+ hostDestroySignal?.addEventListener("abort", run, options);
27
53
 
28
- // The listener must survive standalone soft unmounts so a later permanent
29
- // client destroy still cleans up the retained resource state.
54
+ // The listeners must survive standalone soft unmounts so a later
55
+ // permanent destroy still cleans up the retained resource state.
30
56
  return undefined;
31
- }, [destroySignal, enabled]);
57
+ }, [destroySignal, enabled, hostDestroySignal]);
32
58
  };
@@ -623,8 +623,14 @@ describe("AISDKToolkit", () => {
623
623
  toolCallId: "call-docs-search",
624
624
  messages: [],
625
625
  };
626
+ const execute = toolSet.docs_search?.execute as
627
+ | ((
628
+ args: { query: string },
629
+ options: typeof executeOptions,
630
+ ) => Promise<string>)
631
+ | undefined;
626
632
  await expect(
627
- toolSet.docs_search?.execute?.({ query: "assistant-ui" }, executeOptions),
633
+ execute?.({ query: "assistant-ui" }, executeOptions),
628
634
  ).resolves.toBe("docs result");
629
635
  expect(docsExecute).toHaveBeenCalledWith(
630
636
  { query: "assistant-ui" },
package/src/usage.ts CHANGED
@@ -1,4 +1,4 @@
1
- /// <reference types="@assistant-ui/core/react" />
1
+ /// <reference types="@assistant-ui/core/react" preserve="true" />
2
2
  import { useMemo } from "react";
3
3
  import { useAuiState } from "@assistant-ui/store";
4
4