@assistant-ui/ai-sdk 0.0.8 → 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.
- package/LICENSE +1 -1
- package/dist/converters/convertMessage.d.ts +2 -0
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +14 -4
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/runtime/AISDKChat.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.js.map +1 -1
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +8 -1
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +212 -34
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.js +1 -1
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +40 -10
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +2 -1
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.js +9 -2
- package/dist/transport/AssistantChatTransport.js.map +1 -1
- package/package.json +10 -7
- package/src/converters/convertMessage.test.ts +63 -0
- package/src/converters/convertMessage.ts +24 -2
- package/src/runtime/AISDKChat.ts +0 -4
- package/src/runtime/AISDKThreads.test.ts +26 -0
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1588 -19
- package/src/runtime/useAISDKRuntime.approval.test.tsx +27 -0
- package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +182 -0
- package/src/runtime/useAISDKRuntime.ts +397 -28
- package/src/runtime/useChatRuntime.fast-refresh.test.tsx +94 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +134 -112
- package/src/runtime/useChatRuntime.test.ts +7 -7
- package/src/runtime/useChatThread.test.ts +166 -2
- package/src/runtime/useChatThread.transport.test.tsx +5 -2
- package/src/runtime/useChatThread.ts +56 -17
- package/src/runtime/useExternalHistory.ts +12 -1
- package/src/transport/AssistantChatTransport.test.ts +164 -0
- 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
|
|
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
|
-
|
|
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
|
-
>(
|
|
504
|
+
>(() => toApprovalResponses(ownedApprovals));
|
|
309
505
|
const [toolArtifactEpoch, setToolArtifactEpoch] = useState(0);
|
|
310
506
|
const [toolInteractionEpoch, setToolInteractionEpoch] = useState(0);
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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 (
|
|
917
|
+
if (isApplied) applyResponse(false);
|
|
613
918
|
throw error;
|
|
614
919
|
}
|
|
615
|
-
|
|
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
|
+
});
|