@assistant-ui/ai-sdk 0.0.10 → 0.0.12
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/dist/converters/convertMessage.d.ts +8 -6
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +106 -28
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/toolApprovalAnswers.d.ts +3 -0
- package/dist/converters/toolApprovalAnswers.d.ts.map +1 -0
- package/dist/converters/toolApprovalAnswers.js +17 -0
- package/dist/converters/toolApprovalAnswers.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.native.d.ts +1 -1
- package/dist/index.native.d.ts.map +1 -1
- package/dist/index.native.js +1 -1
- package/dist/model-context/injectInteractableContext.d.ts +3 -0
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
- package/dist/model-context/injectInteractableContext.js +3 -0
- package/dist/model-context/injectInteractableContext.js.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +1 -0
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
- package/dist/model-context/injectQuoteContext.js +1 -0
- package/dist/model-context/injectQuoteContext.js.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKThreads.d.ts +5 -0
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +37 -4
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/DynamicChatTransport.d.ts +49 -0
- package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
- package/dist/runtime/DynamicChatTransport.js +147 -0
- package/dist/runtime/DynamicChatTransport.js.map +1 -0
- package/dist/runtime/getResumableAdapter.d.ts +5 -0
- package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
- package/dist/runtime/getResumableAdapter.js +12 -0
- package/dist/runtime/getResumableAdapter.js.map +1 -0
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/toolHistoryCodec.d.ts +20 -0
- package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
- package/dist/runtime/toolHistoryCodec.js +107 -0
- package/dist/runtime/toolHistoryCodec.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +4 -1
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +154 -155
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts +14 -2
- package/dist/runtime/useChatRuntime.d.ts.map +1 -1
- package/dist/runtime/useChatRuntime.js +12 -3
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useChatThread.d.ts +3 -2
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +56 -36
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
- package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
- package/dist/runtime/useDynamicChatTransport.js +64 -0
- package/dist/runtime/useDynamicChatTransport.js.map +1 -0
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +11 -105
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.js +1 -1
- package/dist/runtime/useStreamingTiming.js +2 -2
- package/dist/runtime/useStreamingTiming.js.map +1 -1
- package/dist/tools/generativeTools.d.ts +2 -1
- package/dist/tools/generativeTools.d.ts.map +1 -1
- package/dist/tools/generativeTools.js +17 -6
- package/dist/tools/generativeTools.js.map +1 -1
- package/dist/usage.js +1 -1
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/dist/utils/sliceMessagesUntil.js +1 -2
- package/dist/utils/sliceMessagesUntil.js.map +1 -1
- package/package.json +12 -10
- package/src/converters/convertMessage.test.ts +350 -2
- package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
- package/src/converters/convertMessage.ts +154 -23
- package/src/converters/toCreateMessage.test.ts +27 -0
- package/src/converters/toolApprovalAnswers.ts +27 -0
- package/src/index.native.ts +1 -1
- package/src/index.ts +1 -1
- package/src/model-context/injectInteractableContext.ts +3 -0
- package/src/model-context/injectQuoteContext.ts +1 -0
- package/src/runtime/AISDKChat.integration.test.tsx +57 -2
- package/src/runtime/AISDKThreads.cloud.test.ts +3 -0
- package/src/runtime/AISDKThreads.test.ts +181 -0
- package/src/runtime/AISDKThreads.ts +31 -4
- package/src/runtime/DynamicChatTransport.test.ts +203 -0
- package/src/runtime/DynamicChatTransport.ts +273 -0
- package/src/runtime/__tests__/controlled-transport.ts +3 -0
- package/src/runtime/getResumableAdapter.ts +16 -0
- package/src/runtime/toolHistoryCodec.test.ts +161 -0
- package/src/runtime/toolHistoryCodec.ts +207 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +12 -0
- package/src/runtime/useAISDKRuntime.reload.test.tsx +219 -0
- package/src/runtime/useAISDKRuntime.test.ts +538 -5
- package/src/runtime/useAISDKRuntime.ts +161 -51
- package/src/runtime/useChatRuntime.integration.test.tsx +319 -4
- package/src/runtime/useChatRuntime.local-storage.test.tsx +123 -0
- package/src/runtime/useChatRuntime.test.ts +107 -1
- package/src/runtime/useChatRuntime.ts +26 -4
- package/src/runtime/useChatThread.binding.test.tsx +143 -0
- package/src/runtime/useChatThread.ts +98 -81
- package/src/runtime/useDynamicChatTransport.ts +26 -0
- package/src/runtime/useExternalHistory.test.ts +205 -0
- package/src/runtime/useExternalHistory.ts +14 -206
- package/src/runtime/useStreamingTiming.ts +2 -2
- package/src/tools/generativeTools.test.ts +190 -2
- package/src/tools/generativeTools.ts +28 -8
- package/src/utils/sliceMessagesUntil.test.ts +2 -6
- package/src/utils/sliceMessagesUntil.ts +1 -5
- package/dist/converters/modelContentEnvelope.d.ts +0 -14
- package/dist/converters/modelContentEnvelope.d.ts.map +0 -1
- package/dist/converters/modelContentEnvelope.js +0 -22
- package/dist/converters/modelContentEnvelope.js.map +0 -1
- package/dist/converters/toolOutputConversion.d.ts +0 -26
- package/dist/converters/toolOutputConversion.d.ts.map +0 -1
- package/dist/converters/toolOutputConversion.js +0 -31
- package/dist/converters/toolOutputConversion.js.map +0 -1
- package/dist/tools/frontendTools.d.ts +0 -30
- package/dist/tools/frontendTools.d.ts.map +0 -1
- package/dist/tools/frontendTools.js +0 -33
- package/dist/tools/frontendTools.js.map +0 -1
- package/src/converters/modelContentEnvelope.ts +0 -41
- package/src/converters/toolOutputConversion.ts +0 -26
- package/src/tools/frontendTools.test.ts +0 -205
- package/src/tools/frontendTools.ts +0 -83
|
@@ -1,15 +1,37 @@
|
|
|
1
|
+
import { createRoot } from "react-dom/client";
|
|
2
|
+
import { useTapHost } from "@assistant-ui/tap";
|
|
3
|
+
import { DynamicChatTransport } from "./DynamicChatTransport";
|
|
4
|
+
import { useChatThread } from "./useChatThread";
|
|
1
5
|
// @vitest-environment jsdom
|
|
2
6
|
|
|
3
7
|
import { getEventListeners } from "node:events";
|
|
4
|
-
import {
|
|
5
|
-
|
|
8
|
+
import {
|
|
9
|
+
act,
|
|
10
|
+
render,
|
|
11
|
+
renderHook,
|
|
12
|
+
screen,
|
|
13
|
+
waitFor,
|
|
14
|
+
} from "@testing-library/react";
|
|
15
|
+
import {
|
|
16
|
+
AssistantRuntimeProvider,
|
|
17
|
+
RuntimeAdapterProvider,
|
|
18
|
+
} from "@assistant-ui/core/react";
|
|
6
19
|
import { AuiConfig, AuiProvider, useAuiState } from "@assistant-ui/store";
|
|
7
20
|
import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
|
|
8
21
|
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
9
22
|
import { AISDKChat } from "./AISDKChat";
|
|
10
23
|
import type { ChatTransport, UIMessage } from "ai";
|
|
11
|
-
import {
|
|
12
|
-
|
|
24
|
+
import {
|
|
25
|
+
Activity,
|
|
26
|
+
StrictMode,
|
|
27
|
+
Suspense,
|
|
28
|
+
useEffect,
|
|
29
|
+
useLayoutEffect,
|
|
30
|
+
useState,
|
|
31
|
+
version,
|
|
32
|
+
type ReactNode,
|
|
33
|
+
} from "react";
|
|
34
|
+
import { describe, expect, it, vi } from "vitest";
|
|
13
35
|
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
14
36
|
import {
|
|
15
37
|
createCancellableTransport,
|
|
@@ -322,3 +344,296 @@ describe("useThreadTokenUsage through useChatRuntime", () => {
|
|
|
322
344
|
});
|
|
323
345
|
});
|
|
324
346
|
});
|
|
347
|
+
|
|
348
|
+
describe("replacement transports", () => {
|
|
349
|
+
it.skipIf(onReact18)(
|
|
350
|
+
"does not loop when an inline transport recreates resumable storage",
|
|
351
|
+
async () => {
|
|
352
|
+
const fetch = vi.fn(
|
|
353
|
+
async () =>
|
|
354
|
+
new Response(
|
|
355
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
356
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
357
|
+
),
|
|
358
|
+
);
|
|
359
|
+
let renders = 0;
|
|
360
|
+
let sent = false;
|
|
361
|
+
const SendOnLayout = ({ runtime }: { runtime: AssistantRuntime }) => {
|
|
362
|
+
useLayoutEffect(() => {
|
|
363
|
+
if (sent) return;
|
|
364
|
+
sent = true;
|
|
365
|
+
void runtime.thread.append("hello");
|
|
366
|
+
}, [runtime]);
|
|
367
|
+
return null;
|
|
368
|
+
};
|
|
369
|
+
const NestedChat = () => {
|
|
370
|
+
renders += 1;
|
|
371
|
+
const runtime = useChatRuntime({
|
|
372
|
+
transport: new AssistantChatTransport({
|
|
373
|
+
fetch,
|
|
374
|
+
resumable: {
|
|
375
|
+
storage: {
|
|
376
|
+
getStreamId: () => null,
|
|
377
|
+
setStreamId: vi.fn(),
|
|
378
|
+
clear: vi.fn(),
|
|
379
|
+
},
|
|
380
|
+
resumeApi: "/api/chat/resume",
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
return <SendOnLayout runtime={runtime} />;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
render(
|
|
388
|
+
<AuiProvider
|
|
389
|
+
config={AuiConfig({
|
|
390
|
+
threads: AISDKChat({ transport: new AssistantChatTransport() }),
|
|
391
|
+
})}
|
|
392
|
+
>
|
|
393
|
+
<NestedChat />
|
|
394
|
+
</AuiProvider>,
|
|
395
|
+
);
|
|
396
|
+
await waitFor(() => expect(fetch).toHaveBeenCalledOnce());
|
|
397
|
+
|
|
398
|
+
expect(renders).toBeLessThan(10);
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
it("routes sends through a replacement transport", async () => {
|
|
403
|
+
const createEmptyStream = () =>
|
|
404
|
+
new ReadableStream({ start: (controller) => controller.close() });
|
|
405
|
+
const sendA = vi.fn(async () => createEmptyStream());
|
|
406
|
+
const sendB = vi.fn(async () => createEmptyStream());
|
|
407
|
+
const transportA: ChatTransport<UIMessage> = {
|
|
408
|
+
sendMessages: sendA,
|
|
409
|
+
reconnectToStream: vi.fn(),
|
|
410
|
+
};
|
|
411
|
+
const transportB: ChatTransport<UIMessage> = {
|
|
412
|
+
sendMessages: sendB,
|
|
413
|
+
reconnectToStream: vi.fn(),
|
|
414
|
+
};
|
|
415
|
+
const SendOnLayout = ({ runtime }: { runtime: AssistantRuntime }) => {
|
|
416
|
+
useLayoutEffect(() => {
|
|
417
|
+
void runtime.thread.append({
|
|
418
|
+
role: "user",
|
|
419
|
+
content: [{ type: "text", text: "hello" }],
|
|
420
|
+
});
|
|
421
|
+
}, [runtime]);
|
|
422
|
+
return null;
|
|
423
|
+
};
|
|
424
|
+
const App = ({
|
|
425
|
+
transport,
|
|
426
|
+
send = false,
|
|
427
|
+
}: {
|
|
428
|
+
transport: ChatTransport<UIMessage>;
|
|
429
|
+
send?: boolean;
|
|
430
|
+
}) => {
|
|
431
|
+
const runtime = useChatRuntime({ transport });
|
|
432
|
+
return (
|
|
433
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
434
|
+
{send && <SendOnLayout runtime={runtime} />}
|
|
435
|
+
</AssistantRuntimeProvider>
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const view = render(<App transport={transportA} />);
|
|
440
|
+
view.rerender(<App transport={transportB} send />);
|
|
441
|
+
|
|
442
|
+
await waitFor(() => expect(sendB).toHaveBeenCalledOnce());
|
|
443
|
+
expect(sendA).not.toHaveBeenCalled();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("routes sends triggered during the initial layout commit", async () => {
|
|
447
|
+
const send = vi.fn(
|
|
448
|
+
async () =>
|
|
449
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
450
|
+
);
|
|
451
|
+
const transport: ChatTransport<UIMessage> = {
|
|
452
|
+
sendMessages: send,
|
|
453
|
+
reconnectToStream: vi.fn(),
|
|
454
|
+
};
|
|
455
|
+
let resolveLayout!: () => void;
|
|
456
|
+
const layoutCommitted = new Promise<void>((resolve) => {
|
|
457
|
+
resolveLayout = resolve;
|
|
458
|
+
});
|
|
459
|
+
const SendOnLayout = ({ runtime }: { runtime: AssistantRuntime }) => {
|
|
460
|
+
useLayoutEffect(() => {
|
|
461
|
+
runtime.thread.append({
|
|
462
|
+
role: "user",
|
|
463
|
+
content: [{ type: "text", text: "hello" }],
|
|
464
|
+
});
|
|
465
|
+
resolveLayout();
|
|
466
|
+
}, [runtime]);
|
|
467
|
+
return null;
|
|
468
|
+
};
|
|
469
|
+
const App = () => {
|
|
470
|
+
const runtime = useChatRuntime({ transport });
|
|
471
|
+
return (
|
|
472
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
473
|
+
<SendOnLayout runtime={runtime} />
|
|
474
|
+
</AssistantRuntimeProvider>
|
|
475
|
+
);
|
|
476
|
+
};
|
|
477
|
+
const actEnvironment = globalThis as typeof globalThis & {
|
|
478
|
+
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
|
479
|
+
};
|
|
480
|
+
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
|
481
|
+
const container = document.createElement("div");
|
|
482
|
+
const root = createRoot(container);
|
|
483
|
+
|
|
484
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = false;
|
|
485
|
+
document.body.append(container);
|
|
486
|
+
try {
|
|
487
|
+
root.render(<App />);
|
|
488
|
+
await layoutCommitted;
|
|
489
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
490
|
+
|
|
491
|
+
expect(send).toHaveBeenCalledOnce();
|
|
492
|
+
} finally {
|
|
493
|
+
root.unmount();
|
|
494
|
+
container.remove();
|
|
495
|
+
if (previousActEnvironment === undefined) {
|
|
496
|
+
delete actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
|
497
|
+
} else {
|
|
498
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("keeps AssistantChatTransport wired through StrictMode effect replay", () => {
|
|
504
|
+
const sourceTransport = new AssistantChatTransport<UIMessage>({
|
|
505
|
+
api: "/api/chat",
|
|
506
|
+
});
|
|
507
|
+
const transport = new DynamicChatTransport(sourceTransport);
|
|
508
|
+
const wiredDuringEffectSetup: boolean[] = [];
|
|
509
|
+
const Probe = ({ effects }: { effects: () => void }) => {
|
|
510
|
+
useEffect(effects);
|
|
511
|
+
useEffect(() => {
|
|
512
|
+
try {
|
|
513
|
+
wiredDuringEffectSetup.push(
|
|
514
|
+
transport.getCurrentTransport("strict-mode-thread") !==
|
|
515
|
+
sourceTransport,
|
|
516
|
+
);
|
|
517
|
+
} catch {
|
|
518
|
+
wiredDuringEffectSetup.push(false);
|
|
519
|
+
}
|
|
520
|
+
}, []);
|
|
521
|
+
return null;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const App = () => {
|
|
525
|
+
const { effects } = useTapHost(function ChatThreadResource() {
|
|
526
|
+
return useChatThread(
|
|
527
|
+
{ transport },
|
|
528
|
+
{
|
|
529
|
+
id: "strict-mode-thread",
|
|
530
|
+
isMainThread: true,
|
|
531
|
+
getThreadListItem: () => undefined,
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
});
|
|
535
|
+
return <Probe effects={effects} />;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
render(
|
|
539
|
+
<StrictMode>
|
|
540
|
+
<App />
|
|
541
|
+
</StrictMode>,
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
expect(wiredDuringEffectSetup).toEqual([true, true]);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("does not publish transport contexts from suspended renders", () => {
|
|
548
|
+
const sourceTransport = new AssistantChatTransport<UIMessage>({
|
|
549
|
+
api: "/api/chat",
|
|
550
|
+
});
|
|
551
|
+
const transport = new DynamicChatTransport(sourceTransport);
|
|
552
|
+
const pending = new Promise<never>(() => {});
|
|
553
|
+
const SuspendedThread = () => {
|
|
554
|
+
useTapHost(function ChatThreadResource() {
|
|
555
|
+
return useChatThread(
|
|
556
|
+
{ transport },
|
|
557
|
+
{
|
|
558
|
+
id: "suspended-thread",
|
|
559
|
+
isMainThread: true,
|
|
560
|
+
getThreadListItem: () => undefined,
|
|
561
|
+
},
|
|
562
|
+
);
|
|
563
|
+
});
|
|
564
|
+
throw pending;
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
render(
|
|
568
|
+
<Suspense fallback={null}>
|
|
569
|
+
<SuspendedThread />
|
|
570
|
+
</Suspense>,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
expect(transport.getCurrentTransport("suspended-thread")).toBe(
|
|
574
|
+
sourceTransport,
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it.skipIf(onReact18)(
|
|
579
|
+
"routes runtime sends through a wired clone with the latest thread item",
|
|
580
|
+
async () => {
|
|
581
|
+
const bodies: Array<{ id: string; system: string }> = [];
|
|
582
|
+
const sourceTransport = new AssistantChatTransport<UIMessage>({
|
|
583
|
+
fetch: vi.fn(async (_input, init) => {
|
|
584
|
+
bodies.push(JSON.parse(String(init?.body)));
|
|
585
|
+
return new Response(
|
|
586
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
587
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
588
|
+
);
|
|
589
|
+
}),
|
|
590
|
+
});
|
|
591
|
+
const transport = new DynamicChatTransport(sourceTransport);
|
|
592
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
593
|
+
<RuntimeAdapterProvider
|
|
594
|
+
adapters={{
|
|
595
|
+
modelContext: {
|
|
596
|
+
getModelContext: () => ({ system: "system prompt" }),
|
|
597
|
+
},
|
|
598
|
+
}}
|
|
599
|
+
>
|
|
600
|
+
{children}
|
|
601
|
+
</RuntimeAdapterProvider>
|
|
602
|
+
);
|
|
603
|
+
const { result, rerender } = renderHook(
|
|
604
|
+
({ remoteId }: { remoteId: string }) =>
|
|
605
|
+
useChatThread(
|
|
606
|
+
{ transport },
|
|
607
|
+
{
|
|
608
|
+
id: "stable-thread",
|
|
609
|
+
isMainThread: true,
|
|
610
|
+
getThreadListItem: () => ({
|
|
611
|
+
initialize: async () => ({
|
|
612
|
+
remoteId,
|
|
613
|
+
externalId: undefined,
|
|
614
|
+
}),
|
|
615
|
+
}),
|
|
616
|
+
},
|
|
617
|
+
),
|
|
618
|
+
{ initialProps: { remoteId: "remote-a" }, wrapper },
|
|
619
|
+
);
|
|
620
|
+
const send = async () => {
|
|
621
|
+
await act(async () => {
|
|
622
|
+
await result.current.thread.append({
|
|
623
|
+
role: "user",
|
|
624
|
+
content: [{ type: "text", text: "hello" }],
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
await send();
|
|
630
|
+
rerender({ remoteId: "remote-b" });
|
|
631
|
+
await send();
|
|
632
|
+
|
|
633
|
+
expect(bodies).toEqual([
|
|
634
|
+
expect.objectContaining({ id: "remote-a", system: "system prompt" }),
|
|
635
|
+
expect.objectContaining({ id: "remote-b", system: "system prompt" }),
|
|
636
|
+
]);
|
|
637
|
+
},
|
|
638
|
+
);
|
|
639
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import {
|
|
5
|
+
AssistantRuntimeProvider,
|
|
6
|
+
createLocalStorageAdapter,
|
|
7
|
+
useRemoteThreadListRuntime,
|
|
8
|
+
type AsyncStorageLike,
|
|
9
|
+
} from "@assistant-ui/core/react";
|
|
10
|
+
import type { ChatTransport, UIMessage } from "ai";
|
|
11
|
+
import { useState, type ReactNode } from "react";
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
createControlledTransport,
|
|
15
|
+
createStreamHarness,
|
|
16
|
+
} from "./__tests__/controlled-transport";
|
|
17
|
+
import { useChatRuntime } from "./useChatRuntime";
|
|
18
|
+
|
|
19
|
+
const createStorage = (): AsyncStorageLike => {
|
|
20
|
+
const values = new Map<string, string>();
|
|
21
|
+
return {
|
|
22
|
+
getItem: async (key) => values.get(key) ?? null,
|
|
23
|
+
setItem: async (key, value) => {
|
|
24
|
+
values.set(key, value);
|
|
25
|
+
},
|
|
26
|
+
removeItem: async (key) => {
|
|
27
|
+
values.delete(key);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const LocalStorageApp = ({
|
|
33
|
+
storage,
|
|
34
|
+
transport,
|
|
35
|
+
threadId,
|
|
36
|
+
probe,
|
|
37
|
+
}: {
|
|
38
|
+
storage: AsyncStorageLike;
|
|
39
|
+
transport: ChatTransport<UIMessage>;
|
|
40
|
+
threadId?: string;
|
|
41
|
+
probe: ReactNode;
|
|
42
|
+
}) => {
|
|
43
|
+
const [adapter] = useState(() => createLocalStorageAdapter({ storage }));
|
|
44
|
+
const runtime = useRemoteThreadListRuntime({
|
|
45
|
+
runtimeHook: function RuntimeHook() {
|
|
46
|
+
return useChatRuntime({ transport });
|
|
47
|
+
},
|
|
48
|
+
adapter,
|
|
49
|
+
...(threadId !== undefined ? { threadId } : undefined),
|
|
50
|
+
});
|
|
51
|
+
return (
|
|
52
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
53
|
+
{probe}
|
|
54
|
+
</AssistantRuntimeProvider>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const threadText = (
|
|
59
|
+
harness: ReturnType<typeof createStreamHarness>,
|
|
60
|
+
): string[] =>
|
|
61
|
+
harness
|
|
62
|
+
.client()
|
|
63
|
+
.thread.getState()
|
|
64
|
+
.messages.map((message) =>
|
|
65
|
+
message.content
|
|
66
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
67
|
+
.join(""),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
describe("useChatRuntime with createLocalStorageAdapter", () => {
|
|
71
|
+
it("persists AI SDK messages and restores them after a remount", async () => {
|
|
72
|
+
const storage = createStorage();
|
|
73
|
+
const { transport, emit, close } = createControlledTransport();
|
|
74
|
+
const harness = createStreamHarness();
|
|
75
|
+
|
|
76
|
+
const first = render(
|
|
77
|
+
<LocalStorageApp
|
|
78
|
+
storage={storage}
|
|
79
|
+
transport={transport}
|
|
80
|
+
probe={<harness.Probe />}
|
|
81
|
+
/>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
await act(async () => harness.send());
|
|
85
|
+
await waitFor(() => expect(harness.isRunning()).toBe(true));
|
|
86
|
+
await act(async () => {
|
|
87
|
+
emit(
|
|
88
|
+
{ type: "start" },
|
|
89
|
+
{ type: "text-start", id: "t1" },
|
|
90
|
+
{ type: "text-delta", id: "t1", delta: "stored answer" },
|
|
91
|
+
{ type: "text-end", id: "t1" },
|
|
92
|
+
{ type: "finish" },
|
|
93
|
+
);
|
|
94
|
+
close();
|
|
95
|
+
});
|
|
96
|
+
await waitFor(() => expect(harness.isRunning()).toBe(false));
|
|
97
|
+
|
|
98
|
+
const remoteId = harness.client().threadListItem.getState().remoteId;
|
|
99
|
+
expect(remoteId).toBeDefined();
|
|
100
|
+
await waitFor(async () =>
|
|
101
|
+
expect(
|
|
102
|
+
await storage.getItem(
|
|
103
|
+
`@assistant-ui:formatted-messages:${JSON.stringify([remoteId, "ai-sdk/v6"])}`,
|
|
104
|
+
),
|
|
105
|
+
).toContain("stored answer"),
|
|
106
|
+
);
|
|
107
|
+
first.unmount();
|
|
108
|
+
|
|
109
|
+
const restored = createStreamHarness();
|
|
110
|
+
render(
|
|
111
|
+
<LocalStorageApp
|
|
112
|
+
storage={storage}
|
|
113
|
+
transport={createControlledTransport().transport}
|
|
114
|
+
threadId={remoteId!}
|
|
115
|
+
probe={<restored.Probe />}
|
|
116
|
+
/>,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
await waitFor(() =>
|
|
120
|
+
expect(threadText(restored)).toEqual(["keep streaming", "stored answer"]),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { ChatTransport, UIMessage } from "ai";
|
|
1
2
|
// @vitest-environment jsdom
|
|
2
3
|
|
|
3
4
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
5
|
import { version } from "react";
|
|
5
6
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import type { AssistantCloud } from "assistant-cloud";
|
|
6
8
|
|
|
7
9
|
const mocks = vi.hoisted(() => {
|
|
8
10
|
const state = {
|
|
@@ -17,7 +19,9 @@ const mocks = vi.hoisted(() => {
|
|
|
17
19
|
getModelContext: () => ({}),
|
|
18
20
|
subscribe: (callback: () => void) => {
|
|
19
21
|
subscribers.add(callback);
|
|
20
|
-
return () =>
|
|
22
|
+
return () => {
|
|
23
|
+
subscribers.delete(callback);
|
|
24
|
+
};
|
|
21
25
|
},
|
|
22
26
|
},
|
|
23
27
|
threads: {
|
|
@@ -82,6 +86,8 @@ import {
|
|
|
82
86
|
createResumableSessionStorage,
|
|
83
87
|
RESUMABLE_STREAM_ID_HEADER,
|
|
84
88
|
} from "../transport/resumable";
|
|
89
|
+
import { getResumedStreamIds } from "./DynamicChatTransport";
|
|
90
|
+
import { AI_SDK_SDK } from "./sdkIdentity";
|
|
85
91
|
import { useChatRuntime } from "./useChatRuntime";
|
|
86
92
|
|
|
87
93
|
const onReact18 = version.startsWith("18.");
|
|
@@ -97,6 +103,7 @@ const sendMessagesOptions = {
|
|
|
97
103
|
// Fails on React 18: TypeError: ReactRuntime.use is not a function. Shipped React 18 incompatibility.
|
|
98
104
|
describe.skipIf(onReact18)("useChatRuntime", () => {
|
|
99
105
|
beforeEach(() => {
|
|
106
|
+
mocks.useAISDKRuntime.mockImplementation(() => mocks.runtime);
|
|
100
107
|
mocks.state.isLoadingHistory = false;
|
|
101
108
|
mocks.state.threadId = "thread-id";
|
|
102
109
|
mocks.state.mainThreadId = "thread-id";
|
|
@@ -104,6 +111,63 @@ describe.skipIf(onReact18)("useChatRuntime", () => {
|
|
|
104
111
|
window.sessionStorage.clear();
|
|
105
112
|
});
|
|
106
113
|
|
|
114
|
+
it("refreshes AssistantChatTransport wiring when the runtime changes", async () => {
|
|
115
|
+
const bodies: Array<{ system: string }> = [];
|
|
116
|
+
const transport = new AssistantChatTransport({
|
|
117
|
+
fetch: vi.fn(async (_input, init) => {
|
|
118
|
+
bodies.push(JSON.parse(String(init?.body)));
|
|
119
|
+
return new Response(
|
|
120
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
121
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
122
|
+
);
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
const createRuntime = (system: string) => ({
|
|
126
|
+
thread: {
|
|
127
|
+
getState: () => ({ isLoading: false }),
|
|
128
|
+
getModelContext: () => ({ system }),
|
|
129
|
+
subscribe: () => () => {},
|
|
130
|
+
},
|
|
131
|
+
threads: { mainItem: undefined },
|
|
132
|
+
});
|
|
133
|
+
let currentRuntime = createRuntime("system-a");
|
|
134
|
+
mocks.useAISDKRuntime.mockImplementation(() => currentRuntime);
|
|
135
|
+
mocks.useChat.mockReturnValue({
|
|
136
|
+
resumeStream: vi.fn(),
|
|
137
|
+
status: "ready",
|
|
138
|
+
});
|
|
139
|
+
const { rerender } = renderHook(() => useChatRuntime({ transport }));
|
|
140
|
+
const dynamicTransport = mocks.useChat.mock.lastCall?.[0].chat
|
|
141
|
+
.transport as ChatTransport<UIMessage>;
|
|
142
|
+
|
|
143
|
+
await dynamicTransport.sendMessages(sendMessagesOptions as never);
|
|
144
|
+
currentRuntime = createRuntime("system-b");
|
|
145
|
+
rerender();
|
|
146
|
+
await dynamicTransport.sendMessages(sendMessagesOptions as never);
|
|
147
|
+
|
|
148
|
+
expect(bodies).toEqual([
|
|
149
|
+
expect.objectContaining({ system: "system-a" }),
|
|
150
|
+
expect.objectContaining({ system: "system-b" }),
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("forwards the Cloud scope to the thread-list adapter", () => {
|
|
155
|
+
const cloud = {} as AssistantCloud;
|
|
156
|
+
mocks.useCloudThreadListAdapter.mockClear();
|
|
157
|
+
mocks.useChat.mockReturnValue({
|
|
158
|
+
resumeStream: vi.fn(),
|
|
159
|
+
status: "ready",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
renderHook(() => useChatRuntime({ cloud, scopeId: "workspace-1" }));
|
|
163
|
+
|
|
164
|
+
expect(mocks.useCloudThreadListAdapter).toHaveBeenCalledWith({
|
|
165
|
+
cloud,
|
|
166
|
+
scopeId: "workspace-1",
|
|
167
|
+
sdk: AI_SDK_SDK,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
107
171
|
it("forwards a callback through a ref, so a later render's callback fires instead of the mounted one", () => {
|
|
108
172
|
mocks.useChat.mockReturnValue({
|
|
109
173
|
resumeStream: vi.fn(),
|
|
@@ -455,6 +519,48 @@ describe.skipIf(onReact18)("useChatRuntime", () => {
|
|
|
455
519
|
await waitFor(() => expect(resumeStream).toHaveBeenCalledTimes(1));
|
|
456
520
|
});
|
|
457
521
|
|
|
522
|
+
it("does not resume an unchanged stream id already handled by replacement storage", () => {
|
|
523
|
+
mocks.state.isLoadingHistory = true;
|
|
524
|
+
const resumeStream = vi.fn().mockResolvedValue(undefined);
|
|
525
|
+
mocks.useChat.mockReturnValue({
|
|
526
|
+
resumeStream,
|
|
527
|
+
status: "ready",
|
|
528
|
+
});
|
|
529
|
+
const storageA = {
|
|
530
|
+
getStreamId: (): string | null => "stream-1",
|
|
531
|
+
setStreamId: vi.fn(),
|
|
532
|
+
clear: vi.fn(),
|
|
533
|
+
};
|
|
534
|
+
const storageB = {
|
|
535
|
+
getStreamId: (): string | null => "stream-1",
|
|
536
|
+
setStreamId: vi.fn(),
|
|
537
|
+
clear: vi.fn(),
|
|
538
|
+
};
|
|
539
|
+
getResumedStreamIds(storageB).add("stream-1");
|
|
540
|
+
const transportA = {
|
|
541
|
+
getResumableAdapter: () => ({
|
|
542
|
+
storage: storageA,
|
|
543
|
+
resumeApi: "/api/chat/resume",
|
|
544
|
+
}),
|
|
545
|
+
};
|
|
546
|
+
const transportB = {
|
|
547
|
+
getResumableAdapter: () => ({
|
|
548
|
+
storage: storageB,
|
|
549
|
+
resumeApi: "/api/chat/resume",
|
|
550
|
+
}),
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const { rerender } = renderHook(
|
|
554
|
+
({ transport }) => useChatRuntime({ transport: transport as never }),
|
|
555
|
+
{ initialProps: { transport: transportA } },
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
mocks.state.isLoadingHistory = false;
|
|
559
|
+
rerender({ transport: transportB });
|
|
560
|
+
|
|
561
|
+
expect(resumeStream).not.toHaveBeenCalled();
|
|
562
|
+
});
|
|
563
|
+
|
|
458
564
|
it("does not clear a newer stream id when an older resume fails", async () => {
|
|
459
565
|
let streamId: string | null = "stream-1";
|
|
460
566
|
const storageSubscribers = new Set<() => void>();
|
|
@@ -8,13 +8,28 @@ import {
|
|
|
8
8
|
useRemoteThreadListRuntime,
|
|
9
9
|
} from "@assistant-ui/core/react";
|
|
10
10
|
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
11
|
-
import {
|
|
11
|
+
import type { ChatTransport } from "ai";
|
|
12
|
+
import { useMemo } from "react";
|
|
13
|
+
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
12
14
|
import { useChatThread, type ChatThreadOptions } from "./useChatThread";
|
|
15
|
+
import { useDynamicChatTransport } from "./useDynamicChatTransport";
|
|
16
|
+
import { useHostDestroySignal } from "@assistant-ui/store/internal";
|
|
13
17
|
import { AI_SDK_SDK } from "./sdkIdentity";
|
|
14
18
|
|
|
15
19
|
export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
16
|
-
ChatThreadOptions<UI_MESSAGE> & {
|
|
20
|
+
Omit<ChatThreadOptions<UI_MESSAGE>, "transport"> & {
|
|
21
|
+
/**
|
|
22
|
+
* The transport threads send through. `AssistantChatTransport` instances
|
|
23
|
+
* are cloned per thread through `__internal_clone()` so their assistant-ui
|
|
24
|
+
* wiring remains isolated. Other transport instances are shared as-is.
|
|
25
|
+
*/
|
|
26
|
+
transport?: ChatTransport<UI_MESSAGE> | undefined;
|
|
17
27
|
cloud?: AssistantCloud | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Stable identity for the account or workspace owning Cloud runtime state.
|
|
30
|
+
* Provide it from the first render and change it when that scope changes.
|
|
31
|
+
*/
|
|
32
|
+
scopeId?: string | undefined;
|
|
18
33
|
onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
|
|
19
34
|
};
|
|
20
35
|
|
|
@@ -39,14 +54,21 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
39
54
|
|
|
40
55
|
export const useChatRuntime = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
41
56
|
cloud,
|
|
57
|
+
scopeId,
|
|
42
58
|
onThreadIdChange,
|
|
43
59
|
...options
|
|
44
60
|
}: UseChatRuntimeOptions<UI_MESSAGE> = {}): AssistantRuntime => {
|
|
45
61
|
const hostDestroySignal = useHostDestroySignal();
|
|
46
|
-
const cloudAdapter = useCloudThreadListAdapter({
|
|
62
|
+
const cloudAdapter = useCloudThreadListAdapter({
|
|
63
|
+
cloud,
|
|
64
|
+
scopeId,
|
|
65
|
+
sdk: AI_SDK_SDK,
|
|
66
|
+
});
|
|
67
|
+
const fallback = useMemo(() => new AssistantChatTransport<UI_MESSAGE>(), []);
|
|
68
|
+
const transport = useDynamicChatTransport(options.transport ?? fallback);
|
|
47
69
|
return useRemoteThreadListRuntime({
|
|
48
70
|
runtimeHook: function RuntimeHook() {
|
|
49
|
-
return useChatThreadRuntime(options, hostDestroySignal);
|
|
71
|
+
return useChatThreadRuntime({ ...options, transport }, hostDestroySignal);
|
|
50
72
|
},
|
|
51
73
|
adapter: cloudAdapter,
|
|
52
74
|
allowNesting: true,
|