@assistant-ui/ai-sdk 0.0.8 → 0.0.10

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 (51) hide show
  1. package/LICENSE +1 -1
  2. package/dist/converters/convertMessage.d.ts +2 -0
  3. package/dist/converters/convertMessage.d.ts.map +1 -1
  4. package/dist/converters/convertMessage.js +15 -4
  5. package/dist/converters/convertMessage.js.map +1 -1
  6. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  7. package/dist/converters/toCreateMessage.js +2 -1
  8. package/dist/converters/toCreateMessage.js.map +1 -1
  9. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  10. package/dist/runtime/AISDKChat.js.map +1 -1
  11. package/dist/runtime/AISDKThreads.js.map +1 -1
  12. package/dist/runtime/sdkIdentity.js +1 -1
  13. package/dist/runtime/useAISDKRuntime.d.ts +8 -1
  14. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  15. package/dist/runtime/useAISDKRuntime.js +212 -34
  16. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  17. package/dist/runtime/useChatRuntime.js +1 -1
  18. package/dist/runtime/useChatRuntime.js.map +1 -1
  19. package/dist/runtime/useChatThread.d.ts.map +1 -1
  20. package/dist/runtime/useChatThread.js +40 -10
  21. package/dist/runtime/useChatThread.js.map +1 -1
  22. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  23. package/dist/runtime/useExternalHistory.js +2 -1
  24. package/dist/runtime/useExternalHistory.js.map +1 -1
  25. package/dist/runtime/useResourceCleanup.js.map +1 -1
  26. package/dist/runtime/useStreamingTiming.js.map +1 -1
  27. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  28. package/dist/transport/AssistantChatTransport.js +9 -2
  29. package/dist/transport/AssistantChatTransport.js.map +1 -1
  30. package/dist/transport/resumable.js.map +1 -1
  31. package/dist/usage.js.map +1 -1
  32. package/package.json +15 -12
  33. package/src/converters/convertMessage.test.ts +99 -0
  34. package/src/converters/convertMessage.ts +27 -2
  35. package/src/converters/toCreateMessage.test.ts +13 -0
  36. package/src/converters/toCreateMessage.ts +1 -0
  37. package/src/runtime/AISDKChat.ts +0 -4
  38. package/src/runtime/AISDKThreads.test.ts +26 -0
  39. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1588 -19
  40. package/src/runtime/useAISDKRuntime.approval.test.tsx +27 -0
  41. package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +182 -0
  42. package/src/runtime/useAISDKRuntime.ts +397 -28
  43. package/src/runtime/useChatRuntime.fast-refresh.test.tsx +94 -0
  44. package/src/runtime/useChatRuntime.integration.test.tsx +134 -112
  45. package/src/runtime/useChatRuntime.test.ts +7 -7
  46. package/src/runtime/useChatThread.test.ts +166 -2
  47. package/src/runtime/useChatThread.transport.test.tsx +5 -2
  48. package/src/runtime/useChatThread.ts +56 -17
  49. package/src/runtime/useExternalHistory.ts +12 -1
  50. package/src/transport/AssistantChatTransport.test.ts +164 -0
  51. package/src/transport/AssistantChatTransport.ts +22 -2
@@ -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,
@@ -114,6 +115,13 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
114
115
  | undefined;
115
116
  toCreateMessage?: CustomToCreateMessageFunction;
116
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;
117
125
  /**
118
126
  * Whether to automatically cancel pending interactive tool calls when the user sends a new message.
119
127
  *
@@ -141,7 +149,7 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
141
149
  /**
142
150
  * Answers tool approval requests through a host-owned channel instead of the AI SDK's `addToolApprovalResponse`.
143
151
  *
144
- * 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, and a second response to the same request rejects; without one it lasts as long as this runtime, 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.
145
153
  *
146
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.
147
155
  */
@@ -250,7 +258,7 @@ const useGeneratedSuggestions = (
250
258
  })();
251
259
  }, [hasAdapter, isRunning]);
252
260
 
253
- useEffect(() => {
261
+ useReplaySafeEffect(() => {
254
262
  return () => {
255
263
  controllerRef.current?.abort();
256
264
  };
@@ -261,11 +269,188 @@ const useGeneratedSuggestions = (
261
269
 
262
270
  const NO_CANCELLED_MESSAGE_IDS: ReadonlySet<string> = new Set();
263
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
+
264
277
  const NO_TOOL_APPROVAL_RESPONSES: ReadonlyMap<
265
278
  string,
266
279
  RespondToToolApprovalOptions
267
280
  > = new Map();
268
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
+
269
454
  const toChatError = (error: Error): AssistantError => {
270
455
  const code = (error as { code?: unknown }).code;
271
456
  return {
@@ -303,15 +488,70 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
303
488
  chatId: string;
304
489
  ids: ReadonlySet<string>;
305
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;
306
502
  const [toolApprovalResponses, setToolApprovalResponses] = useState<
307
503
  ReadonlyMap<string, RespondToToolApprovalOptions>
308
- >(NO_TOOL_APPROVAL_RESPONSES);
504
+ >(() => toApprovalResponses(ownedApprovals));
309
505
  const [toolArtifactEpoch, setToolArtifactEpoch] = useState(0);
310
506
  const [toolInteractionEpoch, setToolInteractionEpoch] = useState(0);
311
- const hostApprovalIdsRef = useRef(new Set<string>());
312
- const toolApprovalResponsesRef = useRef<
313
- Map<string, RespondToToolApprovalOptions>
314
- >(new Map());
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
+ }
315
555
  const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
316
556
  new Map(),
317
557
  );
@@ -356,6 +596,36 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
356
596
  : NO_CANCELLED_MESSAGE_IDS;
357
597
  const supportsRichToolApprovalResponses =
358
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
+ );
359
629
 
360
630
  const toThreadMessages = useCallback(
361
631
  (sourceMessages: UI_MESSAGE[]) => {
@@ -420,6 +690,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
420
690
  toolArtifacts: toolArtifactsRef.current,
421
691
  toolInteractions: toolInteractionsRef.current,
422
692
  supportsRichToolApprovalResponses,
693
+ cancelledToolApprovalIds: supersededApprovalProjection.approvalIds,
694
+ cancelledStatusMessageIds:
695
+ supersededApprovalProjection.statusMessageIds,
423
696
  ...(optimisticMessageId && { optimisticMessageId }),
424
697
  ...(chatHelpers.error && {
425
698
  error: toChatError(chatHelpers.error),
@@ -435,6 +708,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
435
708
  cancelledMessageIds,
436
709
  toolApprovalResponses,
437
710
  supportsRichToolApprovalResponses,
711
+ supersededApprovalProjection,
438
712
  toolArtifactEpoch,
439
713
  toolInteractionEpoch,
440
714
  ],
@@ -484,6 +758,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
484
758
  markToolInteractionsChanged,
485
759
  toolApprovalResponsesRef.current,
486
760
  () => {
761
+ for (const [id, entry] of ownedApprovals ?? []) {
762
+ toolApprovalResponsesRef.current.set(id, entry.response);
763
+ }
487
764
  hostApprovalIdsRef.current = new Set(
488
765
  toolApprovalResponsesRef.current.keys(),
489
766
  );
@@ -579,7 +856,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
579
856
  .find(
580
857
  ({ part }) =>
581
858
  part.state === "approval-requested" &&
582
- part.approval.id === approvalId,
859
+ part.approval?.id === approvalId,
583
860
  );
584
861
  if (!requested || hostApprovalIdsRef.current.has(approvalId))
585
862
  throw new Error(
@@ -587,7 +864,35 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
587
864
  );
588
865
 
589
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;
590
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;
591
896
  if (applied) hostApprovalIdsRef.current.add(approvalId);
592
897
  else hostApprovalIdsRef.current.delete(approvalId);
593
898
  if (applied) toolApprovalResponsesRef.current.set(approvalId, response);
@@ -609,10 +914,12 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
609
914
  },
610
915
  });
611
916
  } catch (error) {
612
- if (hostApprovalIdsRef.current.has(approvalId)) applyResponse(false);
917
+ if (isApplied) applyResponse(false);
613
918
  throw error;
614
919
  }
615
- if (hostApprovalIdsRef.current.has(approvalId)) {
920
+ const remainsApplied =
921
+ startedWith?.has(approvalId) ?? startedResponses.has(approvalId);
922
+ if (isApplied && remainsApplied) {
616
923
  await persistToolApprovalResponses(requested.messageId);
617
924
  }
618
925
  };
@@ -783,6 +1090,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
783
1090
  toolInteractionsRef.current.delete(part.toolCallId) ||
784
1091
  removedToolInteractions;
785
1092
  if (part.approval) {
1093
+ ownedApprovals?.delete(part.approval.id);
786
1094
  removedToolApprovalResponse =
787
1095
  toolApprovalResponsesRef.current.delete(part.approval.id) ||
788
1096
  removedToolApprovalResponse;
@@ -795,6 +1103,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
795
1103
  if (removedToolArtifact) markToolArtifactsChanged();
796
1104
  if (removedToolInteractions) markToolInteractionsChanged();
797
1105
  if (removedToolApprovalResponse || removedHostApprovalId) {
1106
+ if (approvalOwner) notifyHostApprovals(approvalOwner);
798
1107
  setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
799
1108
  }
800
1109
 
@@ -815,6 +1124,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
815
1124
  await chatHelpers.regenerate({ metadata: config.runConfig });
816
1125
  },
817
1126
  onAddToolResult: ({
1127
+ messageId,
818
1128
  toolCallId,
819
1129
  toolName,
820
1130
  result,
@@ -826,6 +1136,62 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
826
1136
  toolArtifactsRef.current.set(toolCallId, artifact);
827
1137
  markToolArtifactsChanged();
828
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
+
829
1195
  const options = { metadata: lastRunConfigRef.current };
830
1196
  if (isError) {
831
1197
  return Promise.resolve(
@@ -833,29 +1199,32 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
833
1199
  state: "output-error",
834
1200
  tool: toolName ?? toolCallId,
835
1201
  toolCallId,
836
- errorText:
837
- typeof result === "string" ? result : JSON.stringify(result),
838
- options,
839
- }),
840
- );
841
- } else {
842
- const output =
843
- modelContent !== undefined
844
- ? wrapModelContentEnvelope(result, modelContent)
845
- : result;
846
- return Promise.resolve(
847
- chatHelpers.addToolOutput({
848
- tool: toolName,
849
- toolCallId,
850
- output,
1202
+ errorText,
851
1203
  options,
852
1204
  }),
853
1205
  );
854
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);
855
1227
  },
856
- onRespondToToolApproval: customOnRespondToToolApproval
857
- ? (response) => respondViaHost(customOnRespondToToolApproval, response)
858
- : respondViaAISDK,
859
1228
  unstable_onRecordToolInteraction: ({
860
1229
  messageId,
861
1230
  toolCallId,
@@ -0,0 +1,94 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, type ReactNode } from "react";
4
+ import { afterAll, afterEach, expect, it, vi } from "vitest";
5
+
6
+ type Family = { current: unknown };
7
+ type RendererInternals = {
8
+ setRefreshHandler: (resolve: (type: unknown) => Family | undefined) => void;
9
+ scheduleRefresh: (
10
+ root: unknown,
11
+ update: { staleFamilies: Set<Family>; updatedFamilies: Set<Family> },
12
+ ) => void;
13
+ };
14
+
15
+ // Fast Refresh drives React through the renderer internals handed to the DevTools hook, so the hook has to exist before react-dom loads.
16
+ let renderer: RendererInternals | undefined;
17
+ const fiberRoots = new Set<unknown>();
18
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
19
+ vi.stubGlobal("__REACT_DEVTOOLS_GLOBAL_HOOK__", {
20
+ supportsFiber: true,
21
+ inject: (internals: RendererInternals) => {
22
+ renderer = internals;
23
+ return 1;
24
+ },
25
+ onScheduleFiberRoot: () => {},
26
+ onCommitFiberRoot: (_id: number, root: unknown) => fiberRoots.add(root),
27
+ onCommitFiberUnmount: () => {},
28
+ });
29
+ const { cleanup, render, waitFor } = await import("@testing-library/react");
30
+ const { AssistantRuntimeProvider } = await import("@assistant-ui/core/react");
31
+ const { useChatRuntime } = await import("./useChatRuntime");
32
+ const { createCancellableTransport, createStreamHarness } =
33
+ await import("./__tests__/controlled-transport");
34
+
35
+ afterEach(cleanup);
36
+ afterAll(() => vi.unstubAllGlobals());
37
+
38
+ it("keeps a running thread across a Fast Refresh of its host and aborts it on unmount", async () => {
39
+ const { transport, getCancelCount } = createCancellableTransport();
40
+ const { Probe, send, isRunning, client } = createStreamHarness();
41
+ const runtimes: unknown[] = [];
42
+ let rendered: string | undefined;
43
+ const createHost =
44
+ (name: string) =>
45
+ ({ children }: { children: ReactNode }) => {
46
+ rendered = name;
47
+ const runtime = useChatRuntime({ transport });
48
+ runtimes.push(runtime);
49
+ return (
50
+ <AssistantRuntimeProvider runtime={runtime}>
51
+ {children}
52
+ </AssistantRuntimeProvider>
53
+ );
54
+ };
55
+ const Before = createHost("before");
56
+ const After = createHost("after");
57
+
58
+ const view = render(
59
+ <Before>
60
+ <Probe />
61
+ </Before>,
62
+ );
63
+ await act(async () => send());
64
+ await waitFor(() => expect(isRunning()).toBe(true));
65
+ const runtime = runtimes.at(-1);
66
+ const threadId = client().threads.getState().mainThreadId;
67
+ const messages = client().thread.getState().messages;
68
+ const error = vi.spyOn(console, "error");
69
+
70
+ const family: Family = { current: After };
71
+ renderer!.setRefreshHandler((type) =>
72
+ type === Before || type === After ? family : undefined,
73
+ );
74
+ await act(async () => {
75
+ for (const fiberRoot of fiberRoots) {
76
+ renderer!.scheduleRefresh(fiberRoot, {
77
+ staleFamilies: new Set(),
78
+ updatedFamilies: new Set([family]),
79
+ });
80
+ }
81
+ });
82
+ await act(async () => {});
83
+
84
+ expect(rendered).toBe("after");
85
+ expect(runtimes.at(-1)).toBe(runtime);
86
+ expect(isRunning()).toBe(true);
87
+ expect(getCancelCount()).toBe(0);
88
+ expect(client().threads.getState().mainThreadId).toBe(threadId);
89
+ expect(client().thread.getState().messages).toEqual(messages);
90
+ expect(error).not.toHaveBeenCalled();
91
+
92
+ view.unmount();
93
+ await waitFor(() => expect(getCancelCount()).toBe(1));
94
+ });