@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.
Files changed (124) hide show
  1. package/dist/converters/convertMessage.d.ts +8 -6
  2. package/dist/converters/convertMessage.d.ts.map +1 -1
  3. package/dist/converters/convertMessage.js +106 -28
  4. package/dist/converters/convertMessage.js.map +1 -1
  5. package/dist/converters/toolApprovalAnswers.d.ts +3 -0
  6. package/dist/converters/toolApprovalAnswers.d.ts.map +1 -0
  7. package/dist/converters/toolApprovalAnswers.js +17 -0
  8. package/dist/converters/toolApprovalAnswers.js.map +1 -0
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.native.d.ts +1 -1
  13. package/dist/index.native.d.ts.map +1 -1
  14. package/dist/index.native.js +1 -1
  15. package/dist/model-context/injectInteractableContext.d.ts +3 -0
  16. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  17. package/dist/model-context/injectInteractableContext.js +3 -0
  18. package/dist/model-context/injectInteractableContext.js.map +1 -1
  19. package/dist/model-context/injectQuoteContext.d.ts +1 -0
  20. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  21. package/dist/model-context/injectQuoteContext.js +1 -0
  22. package/dist/model-context/injectQuoteContext.js.map +1 -1
  23. package/dist/runtime/AISDKChat.js +1 -1
  24. package/dist/runtime/AISDKThreads.d.ts +5 -0
  25. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  26. package/dist/runtime/AISDKThreads.js +37 -4
  27. package/dist/runtime/AISDKThreads.js.map +1 -1
  28. package/dist/runtime/DynamicChatTransport.d.ts +49 -0
  29. package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
  30. package/dist/runtime/DynamicChatTransport.js +147 -0
  31. package/dist/runtime/DynamicChatTransport.js.map +1 -0
  32. package/dist/runtime/getResumableAdapter.d.ts +5 -0
  33. package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
  34. package/dist/runtime/getResumableAdapter.js +12 -0
  35. package/dist/runtime/getResumableAdapter.js.map +1 -0
  36. package/dist/runtime/sdkIdentity.js +1 -1
  37. package/dist/runtime/toolHistoryCodec.d.ts +20 -0
  38. package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
  39. package/dist/runtime/toolHistoryCodec.js +107 -0
  40. package/dist/runtime/toolHistoryCodec.js.map +1 -0
  41. package/dist/runtime/useAISDKRuntime.d.ts +4 -1
  42. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  43. package/dist/runtime/useAISDKRuntime.js +154 -155
  44. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  45. package/dist/runtime/useChatRuntime.d.ts +14 -2
  46. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  47. package/dist/runtime/useChatRuntime.js +12 -3
  48. package/dist/runtime/useChatRuntime.js.map +1 -1
  49. package/dist/runtime/useChatThread.d.ts +3 -2
  50. package/dist/runtime/useChatThread.d.ts.map +1 -1
  51. package/dist/runtime/useChatThread.js +56 -36
  52. package/dist/runtime/useChatThread.js.map +1 -1
  53. package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
  54. package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
  55. package/dist/runtime/useDynamicChatTransport.js +64 -0
  56. package/dist/runtime/useDynamicChatTransport.js.map +1 -0
  57. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  58. package/dist/runtime/useExternalHistory.js +11 -105
  59. package/dist/runtime/useExternalHistory.js.map +1 -1
  60. package/dist/runtime/useResourceCleanup.js +1 -1
  61. package/dist/runtime/useStreamingTiming.js +2 -2
  62. package/dist/runtime/useStreamingTiming.js.map +1 -1
  63. package/dist/tools/generativeTools.d.ts +2 -1
  64. package/dist/tools/generativeTools.d.ts.map +1 -1
  65. package/dist/tools/generativeTools.js +17 -6
  66. package/dist/tools/generativeTools.js.map +1 -1
  67. package/dist/usage.js +1 -1
  68. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  69. package/dist/utils/sliceMessagesUntil.js +1 -2
  70. package/dist/utils/sliceMessagesUntil.js.map +1 -1
  71. package/package.json +12 -10
  72. package/src/converters/convertMessage.test.ts +350 -2
  73. package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
  74. package/src/converters/convertMessage.ts +154 -23
  75. package/src/converters/toCreateMessage.test.ts +27 -0
  76. package/src/converters/toolApprovalAnswers.ts +27 -0
  77. package/src/index.native.ts +1 -1
  78. package/src/index.ts +1 -1
  79. package/src/model-context/injectInteractableContext.ts +3 -0
  80. package/src/model-context/injectQuoteContext.ts +1 -0
  81. package/src/runtime/AISDKChat.integration.test.tsx +57 -2
  82. package/src/runtime/AISDKThreads.cloud.test.ts +3 -0
  83. package/src/runtime/AISDKThreads.test.ts +181 -0
  84. package/src/runtime/AISDKThreads.ts +31 -4
  85. package/src/runtime/DynamicChatTransport.test.ts +203 -0
  86. package/src/runtime/DynamicChatTransport.ts +273 -0
  87. package/src/runtime/__tests__/controlled-transport.ts +3 -0
  88. package/src/runtime/getResumableAdapter.ts +16 -0
  89. package/src/runtime/toolHistoryCodec.test.ts +161 -0
  90. package/src/runtime/toolHistoryCodec.ts +207 -0
  91. package/src/runtime/useAISDKRuntime.approval.test.tsx +12 -0
  92. package/src/runtime/useAISDKRuntime.reload.test.tsx +219 -0
  93. package/src/runtime/useAISDKRuntime.test.ts +538 -5
  94. package/src/runtime/useAISDKRuntime.ts +161 -51
  95. package/src/runtime/useChatRuntime.integration.test.tsx +319 -4
  96. package/src/runtime/useChatRuntime.local-storage.test.tsx +123 -0
  97. package/src/runtime/useChatRuntime.test.ts +107 -1
  98. package/src/runtime/useChatRuntime.ts +26 -4
  99. package/src/runtime/useChatThread.binding.test.tsx +143 -0
  100. package/src/runtime/useChatThread.ts +98 -81
  101. package/src/runtime/useDynamicChatTransport.ts +26 -0
  102. package/src/runtime/useExternalHistory.test.ts +205 -0
  103. package/src/runtime/useExternalHistory.ts +14 -206
  104. package/src/runtime/useStreamingTiming.ts +2 -2
  105. package/src/tools/generativeTools.test.ts +190 -2
  106. package/src/tools/generativeTools.ts +28 -8
  107. package/src/utils/sliceMessagesUntil.test.ts +2 -6
  108. package/src/utils/sliceMessagesUntil.ts +1 -5
  109. package/dist/converters/modelContentEnvelope.d.ts +0 -14
  110. package/dist/converters/modelContentEnvelope.d.ts.map +0 -1
  111. package/dist/converters/modelContentEnvelope.js +0 -22
  112. package/dist/converters/modelContentEnvelope.js.map +0 -1
  113. package/dist/converters/toolOutputConversion.d.ts +0 -26
  114. package/dist/converters/toolOutputConversion.d.ts.map +0 -1
  115. package/dist/converters/toolOutputConversion.js +0 -31
  116. package/dist/converters/toolOutputConversion.js.map +0 -1
  117. package/dist/tools/frontendTools.d.ts +0 -30
  118. package/dist/tools/frontendTools.d.ts.map +0 -1
  119. package/dist/tools/frontendTools.js +0 -33
  120. package/dist/tools/frontendTools.js.map +0 -1
  121. package/src/converters/modelContentEnvelope.ts +0 -41
  122. package/src/converters/toolOutputConversion.ts +0 -26
  123. package/src/tools/frontendTools.test.ts +0 -205
  124. 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 { act, render, screen, waitFor } from "@testing-library/react";
5
- import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
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 { Activity, StrictMode, useState, version, type ReactNode } from "react";
12
- import { describe, expect, it } from "vitest";
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 () => subscribers.delete(callback);
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 { useHostDestroySignal } from "@assistant-ui/store/internal";
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({ cloud, sdk: AI_SDK_SDK });
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,