@assistant-ui/ai-sdk 0.0.11 → 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 (119) 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 +48 -22
  4. package/dist/converters/convertMessage.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/index.native.d.ts +1 -1
  9. package/dist/index.native.d.ts.map +1 -1
  10. package/dist/index.native.js +1 -1
  11. package/dist/model-context/injectInteractableContext.d.ts +3 -0
  12. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  13. package/dist/model-context/injectInteractableContext.js +3 -0
  14. package/dist/model-context/injectInteractableContext.js.map +1 -1
  15. package/dist/model-context/injectQuoteContext.d.ts +1 -0
  16. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  17. package/dist/model-context/injectQuoteContext.js +1 -0
  18. package/dist/model-context/injectQuoteContext.js.map +1 -1
  19. package/dist/runtime/AISDKChat.js +1 -1
  20. package/dist/runtime/AISDKThreads.d.ts +5 -0
  21. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  22. package/dist/runtime/AISDKThreads.js +37 -4
  23. package/dist/runtime/AISDKThreads.js.map +1 -1
  24. package/dist/runtime/DynamicChatTransport.d.ts +49 -0
  25. package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
  26. package/dist/runtime/DynamicChatTransport.js +147 -0
  27. package/dist/runtime/DynamicChatTransport.js.map +1 -0
  28. package/dist/runtime/getResumableAdapter.d.ts +5 -0
  29. package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
  30. package/dist/runtime/getResumableAdapter.js +12 -0
  31. package/dist/runtime/getResumableAdapter.js.map +1 -0
  32. package/dist/runtime/sdkIdentity.js +1 -1
  33. package/dist/runtime/toolHistoryCodec.d.ts +20 -0
  34. package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
  35. package/dist/runtime/toolHistoryCodec.js +107 -0
  36. package/dist/runtime/toolHistoryCodec.js.map +1 -0
  37. package/dist/runtime/useAISDKRuntime.d.ts +4 -1
  38. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  39. package/dist/runtime/useAISDKRuntime.js +154 -155
  40. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  41. package/dist/runtime/useChatRuntime.d.ts +14 -2
  42. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  43. package/dist/runtime/useChatRuntime.js +12 -3
  44. package/dist/runtime/useChatRuntime.js.map +1 -1
  45. package/dist/runtime/useChatThread.d.ts +3 -2
  46. package/dist/runtime/useChatThread.d.ts.map +1 -1
  47. package/dist/runtime/useChatThread.js +56 -36
  48. package/dist/runtime/useChatThread.js.map +1 -1
  49. package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
  50. package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
  51. package/dist/runtime/useDynamicChatTransport.js +64 -0
  52. package/dist/runtime/useDynamicChatTransport.js.map +1 -0
  53. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  54. package/dist/runtime/useExternalHistory.js +11 -109
  55. package/dist/runtime/useExternalHistory.js.map +1 -1
  56. package/dist/runtime/useResourceCleanup.js +1 -1
  57. package/dist/runtime/useStreamingTiming.js +2 -2
  58. package/dist/runtime/useStreamingTiming.js.map +1 -1
  59. package/dist/tools/generativeTools.d.ts +2 -1
  60. package/dist/tools/generativeTools.d.ts.map +1 -1
  61. package/dist/tools/generativeTools.js +5 -5
  62. package/dist/tools/generativeTools.js.map +1 -1
  63. package/dist/usage.js +1 -1
  64. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  65. package/dist/utils/sliceMessagesUntil.js +1 -2
  66. package/dist/utils/sliceMessagesUntil.js.map +1 -1
  67. package/package.json +11 -10
  68. package/src/converters/convertMessage.test.ts +120 -2
  69. package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
  70. package/src/converters/convertMessage.ts +60 -19
  71. package/src/converters/toCreateMessage.test.ts +27 -0
  72. package/src/index.native.ts +1 -1
  73. package/src/index.ts +1 -1
  74. package/src/model-context/injectInteractableContext.ts +3 -0
  75. package/src/model-context/injectQuoteContext.ts +1 -0
  76. package/src/runtime/AISDKChat.integration.test.tsx +57 -2
  77. package/src/runtime/AISDKThreads.cloud.test.ts +3 -0
  78. package/src/runtime/AISDKThreads.test.ts +181 -0
  79. package/src/runtime/AISDKThreads.ts +31 -4
  80. package/src/runtime/DynamicChatTransport.test.ts +203 -0
  81. package/src/runtime/DynamicChatTransport.ts +273 -0
  82. package/src/runtime/__tests__/controlled-transport.ts +3 -0
  83. package/src/runtime/getResumableAdapter.ts +16 -0
  84. package/src/runtime/toolHistoryCodec.test.ts +161 -0
  85. package/src/runtime/toolHistoryCodec.ts +207 -0
  86. package/src/runtime/useAISDKRuntime.approval.test.tsx +12 -0
  87. package/src/runtime/useAISDKRuntime.reload.test.tsx +219 -0
  88. package/src/runtime/useAISDKRuntime.test.ts +538 -5
  89. package/src/runtime/useAISDKRuntime.ts +161 -51
  90. package/src/runtime/useChatRuntime.integration.test.tsx +319 -4
  91. package/src/runtime/useChatRuntime.local-storage.test.tsx +123 -0
  92. package/src/runtime/useChatRuntime.test.ts +107 -1
  93. package/src/runtime/useChatRuntime.ts +26 -4
  94. package/src/runtime/useChatThread.binding.test.tsx +143 -0
  95. package/src/runtime/useChatThread.ts +98 -81
  96. package/src/runtime/useDynamicChatTransport.ts +26 -0
  97. package/src/runtime/useExternalHistory.test.ts +111 -0
  98. package/src/runtime/useExternalHistory.ts +14 -210
  99. package/src/runtime/useStreamingTiming.ts +2 -2
  100. package/src/tools/generativeTools.test.ts +1 -1
  101. package/src/tools/generativeTools.ts +5 -6
  102. package/src/utils/sliceMessagesUntil.test.ts +2 -6
  103. package/src/utils/sliceMessagesUntil.ts +1 -5
  104. package/dist/converters/modelContentEnvelope.d.ts +0 -14
  105. package/dist/converters/modelContentEnvelope.d.ts.map +0 -1
  106. package/dist/converters/modelContentEnvelope.js +0 -22
  107. package/dist/converters/modelContentEnvelope.js.map +0 -1
  108. package/dist/converters/toolOutputConversion.d.ts +0 -26
  109. package/dist/converters/toolOutputConversion.d.ts.map +0 -1
  110. package/dist/converters/toolOutputConversion.js +0 -31
  111. package/dist/converters/toolOutputConversion.js.map +0 -1
  112. package/dist/tools/frontendTools.d.ts +0 -30
  113. package/dist/tools/frontendTools.d.ts.map +0 -1
  114. package/dist/tools/frontendTools.js +0 -33
  115. package/dist/tools/frontendTools.js.map +0 -1
  116. package/src/converters/modelContentEnvelope.ts +0 -41
  117. package/src/converters/toolOutputConversion.ts +0 -26
  118. package/src/tools/frontendTools.test.ts +0 -205
  119. package/src/tools/frontendTools.ts +0 -83
@@ -0,0 +1,143 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { Suspense, version } from "react";
4
+ import { render } from "@testing-library/react";
5
+ import type { AssistantRuntime } from "@assistant-ui/core";
6
+ import type { ChatTransport, UIMessage } from "ai";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { AssistantChatTransport } from "../transport/AssistantChatTransport";
9
+ import { DynamicChatTransport } from "./DynamicChatTransport";
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ system: "",
13
+ useAISDKRuntime: vi.fn(),
14
+ useChat: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("@ai-sdk/react", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("@ai-sdk/react")>()),
19
+ Chat: class MockChat {
20
+ constructor(config: unknown) {
21
+ Object.assign(this, config);
22
+ }
23
+ },
24
+ useChat: mocks.useChat,
25
+ }));
26
+
27
+ vi.mock("./useAISDKRuntime", async (importOriginal) => ({
28
+ ...(await importOriginal<typeof import("./useAISDKRuntime")>()),
29
+ useAISDKRuntime: mocks.useAISDKRuntime,
30
+ }));
31
+
32
+ import { useChatThread } from "./useChatThread";
33
+
34
+ const onReact18 = version.startsWith("18.");
35
+
36
+ const createRuntime = (system: string) =>
37
+ ({
38
+ thread: {
39
+ getModelContext: () => ({ system }),
40
+ getState: () => ({ isLoading: false }),
41
+ subscribe: () => () => {},
42
+ },
43
+ }) as unknown as AssistantRuntime;
44
+
45
+ describe("useChatThread transport binding", () => {
46
+ beforeEach(() => {
47
+ mocks.system = "";
48
+ mocks.useAISDKRuntime.mockReset();
49
+ mocks.useChat.mockReset();
50
+ mocks.useAISDKRuntime.mockImplementation(() => createRuntime(mocks.system));
51
+ mocks.useChat.mockImplementation(({ chat }) => ({
52
+ ...chat,
53
+ messages: [],
54
+ status: "ready",
55
+ error: undefined,
56
+ stop: vi.fn(async () => {}),
57
+ }));
58
+ });
59
+
60
+ it.skipIf(onReact18)(
61
+ "keeps fallback wiring on committed values after an abandoned render",
62
+ async () => {
63
+ const bodies: Array<{ id: string; system: string }> = [];
64
+ const sourceTransport = new AssistantChatTransport<UIMessage>({
65
+ fetch: vi.fn(async (_input, init) => {
66
+ bodies.push(JSON.parse(String(init?.body)));
67
+ return new Response(
68
+ new ReadableStream({ start: (controller) => controller.close() }),
69
+ { headers: { "content-type": "text/event-stream" } },
70
+ );
71
+ }),
72
+ });
73
+ const transport = new DynamicChatTransport(sourceTransport);
74
+ const pending = new Promise<never>(() => {});
75
+ let committedProxy: ChatTransport<UIMessage> | undefined;
76
+
77
+ const App = ({
78
+ system,
79
+ remoteId,
80
+ suspend = false,
81
+ }: {
82
+ system: string;
83
+ remoteId: string;
84
+ suspend?: boolean;
85
+ }) => {
86
+ mocks.system = system;
87
+ useChatThread(
88
+ { transport },
89
+ {
90
+ id: "thread-id",
91
+ isMainThread: true,
92
+ getThreadListItem: () => ({
93
+ initialize: async () => ({
94
+ remoteId,
95
+ externalId: undefined,
96
+ }),
97
+ }),
98
+ },
99
+ );
100
+ const proxy = mocks.useChat.mock.lastCall?.[0].chat.transport as
101
+ | ChatTransport<UIMessage>
102
+ | undefined;
103
+ if (!suspend) committedProxy = proxy;
104
+ if (suspend) throw pending;
105
+ return null;
106
+ };
107
+
108
+ const view = render(
109
+ <Suspense fallback={null}>
110
+ <App system="committed-system" remoteId="committed-remote" />
111
+ </Suspense>,
112
+ );
113
+
114
+ transport.registerThread("thread-id", {});
115
+ view.rerender(
116
+ <Suspense fallback={null}>
117
+ <App system="discarded-system" remoteId="discarded-remote" suspend />
118
+ </Suspense>,
119
+ );
120
+
121
+ await committedProxy!.sendMessages({
122
+ trigger: "submit-message",
123
+ chatId: "thread-id",
124
+ messageId: undefined,
125
+ messages: [
126
+ {
127
+ id: "message-id",
128
+ role: "user",
129
+ parts: [{ type: "text", text: "hello" }],
130
+ },
131
+ ],
132
+ abortSignal: undefined,
133
+ });
134
+
135
+ expect(bodies).toEqual([
136
+ expect.objectContaining({
137
+ id: "committed-remote",
138
+ system: "committed-system",
139
+ }),
140
+ ]);
141
+ },
142
+ );
143
+ });
@@ -12,24 +12,28 @@ import {
12
12
  type AISDKRuntimeAdapter,
13
13
  type CustomToCreateMessageFunction,
14
14
  } from "./useAISDKRuntime";
15
- import type { ChatInit, ChatTransport } from "ai";
15
+ import type { ChatInit } from "ai";
16
16
  import {
17
17
  AssistantChatTransport,
18
18
  type InitializableThreadListItem,
19
19
  } from "../transport/AssistantChatTransport";
20
- import type {
21
- AssistantChatResumableOptions,
22
- ResumableClientStorage,
23
- } from "../transport/resumable";
24
20
  import {
25
21
  useCallback,
26
22
  useEffect,
23
+ useEffectEvent,
24
+ useInsertionEffect,
27
25
  useMemo,
28
26
  useRef,
29
27
  useState,
30
28
  useSyncExternalStore,
31
29
  } from "react";
32
30
  import { useResourceCleanup } from "./useResourceCleanup";
31
+ import {
32
+ DynamicChatTransport,
33
+ getResumedStreamIds,
34
+ } from "./DynamicChatTransport";
35
+ import { getResumableAdapter } from "./getResumableAdapter";
36
+ import { useDynamicChatTransport } from "./useDynamicChatTransport";
33
37
 
34
38
  export type ChatThreadOptions<UI_MESSAGE extends UIMessage = UIMessage> =
35
39
  ChatInit<UI_MESSAGE> &
@@ -49,6 +53,7 @@ export type ChatThreadOptions<UI_MESSAGE extends UIMessage = UIMessage> =
49
53
  onResumeError?: ((error: unknown) => void) | undefined;
50
54
  joinStrategy?: AISDKRuntimeAdapter["joinStrategy"];
51
55
  messageRepository?: AISDKRuntimeAdapter<UI_MESSAGE>["messageRepository"];
56
+ /** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */
52
57
  unstable_onBranchChange?: AISDKRuntimeAdapter["unstable_onBranchChange"];
53
58
  };
54
59
 
@@ -77,58 +82,13 @@ export type ChatThreadEnvironment<UI_MESSAGE extends UIMessage = UIMessage> = {
77
82
  messageRepositoryInstance?: MessageRepository | undefined;
78
83
  };
79
84
 
80
- const useDynamicChatTransport = <UI_MESSAGE extends UIMessage = UIMessage>(
81
- transport: ChatTransport<UI_MESSAGE>,
82
- ): ChatTransport<UI_MESSAGE> => {
83
- const transportRef = useRef<ChatTransport<UI_MESSAGE>>(transport);
84
- useEffect(() => {
85
- transportRef.current = transport;
86
- });
87
- const dynamicTransport = useMemo(
88
- () =>
89
- new Proxy(transportRef.current, {
90
- get(_, prop) {
91
- const res =
92
- transportRef.current[prop as keyof ChatTransport<UI_MESSAGE>];
93
- return typeof res === "function"
94
- ? res.bind(transportRef.current)
95
- : res;
96
- },
97
- }),
98
- [],
99
- );
100
- return dynamicTransport;
101
- };
102
-
103
- const getResumableAdapter = <UI_MESSAGE extends UIMessage>(
104
- transport: ChatTransport<UI_MESSAGE>,
105
- ): AssistantChatResumableOptions | undefined => {
106
- if (transport instanceof AssistantChatTransport) {
107
- return transport.getResumableAdapter();
108
- }
109
- const candidate = (transport as { getResumableAdapter?: () => unknown })
110
- .getResumableAdapter;
111
- if (typeof candidate !== "function") return undefined;
112
- return candidate.call(transport) as AssistantChatResumableOptions | undefined;
85
+ type ChatThreadTransportBinding = {
86
+ runtime: AssistantRuntime;
87
+ getThreadListItem: () => InitializableThreadListItem | undefined;
113
88
  };
114
89
 
115
90
  const getNoPendingStreamId = () => null;
116
91
 
117
- const resumedStreamIdsByStorage = new WeakMap<
118
- ResumableClientStorage,
119
- Set<string>
120
- >();
121
-
122
- const getResumedStreamIds = (storage: ResumableClientStorage | undefined) => {
123
- if (!storage) return new Set<string>();
124
- let resumedStreamIds = resumedStreamIdsByStorage.get(storage);
125
- if (!resumedStreamIds) {
126
- resumedStreamIds = new Set();
127
- resumedStreamIdsByStorage.set(storage, resumedStreamIds);
128
- }
129
- return resumedStreamIds;
130
- };
131
-
132
92
  /**
133
93
  * Splits the combined options into the assistant-ui side and the `ChatInit`
134
94
  * remainder the AI SDK consumes, so external `Chat` construction forwards the
@@ -246,21 +206,47 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
246
206
  messageRepositoryInstance,
247
207
  } = env;
248
208
 
249
- // Wiring below is per thread and mutated on the instance, so a transport
250
- // shared across simultaneously mounted threads is last-writer-wins. A
251
- // caller-owned chat is already bound to its own clone, so cloning again
252
- // here would wire a copy the chat never sends through.
253
- const sourceTransport = useMemo(
209
+ const defaultTransport = useMemo(() => new AssistantChatTransport(), []);
210
+ const configuredTransport = transportOptions ?? defaultTransport;
211
+ const transport = useDynamicChatTransport(
212
+ configuredTransport,
213
+ externalChat === undefined,
214
+ );
215
+ const transportContextOwner = useMemo(() => ({}), []);
216
+ const getThreadListItemRef = useRef(getThreadListItem);
217
+ useInsertionEffect(() => {
218
+ getThreadListItemRef.current = getThreadListItem;
219
+ }, [getThreadListItem]);
220
+ const getCurrentThreadListItem = useCallback(
221
+ () => getThreadListItemRef.current(),
222
+ [],
223
+ );
224
+ const resumableStorage = useMemo(
254
225
  () =>
255
- transportOptions === undefined
256
- ? new AssistantChatTransport()
257
- : externalChat === undefined &&
258
- transportOptions instanceof AssistantChatTransport
259
- ? transportOptions.__internal_clone()
260
- : transportOptions,
261
- [transportOptions, externalChat],
226
+ transport instanceof DynamicChatTransport
227
+ ? transport.getCurrentResumableStorage()
228
+ : getResumableAdapter(transport)?.storage,
229
+ [transport],
230
+ );
231
+
232
+ const transportBindingRef = useRef<ChatThreadTransportBinding | null>(null);
233
+ // This is null when useMemo runs, so the proxy pins its mount render's binding. The fallback
234
+ // serves only pre-commit sends; after insertion effects, transportBindingRef is authoritative.
235
+ let initialTransportBinding: ChatThreadTransportBinding | null = null;
236
+ const chatTransport = useMemo(
237
+ () =>
238
+ transport instanceof DynamicChatTransport
239
+ ? transport.createThreadProxy(transportContextOwner, () => {
240
+ const binding =
241
+ transportBindingRef.current ?? initialTransportBinding;
242
+ if (!binding) {
243
+ throw new Error("Chat transport used before runtime setup");
244
+ }
245
+ return binding;
246
+ })
247
+ : transport,
248
+ [initialTransportBinding, transport, transportContextOwner],
262
249
  );
263
- const transport = useDynamicChatTransport(sourceTransport);
264
250
 
265
251
  const latestChatOptionsRef = useRef(chatOptions);
266
252
  useEffect(() => {
@@ -271,7 +257,10 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
271
257
  const [ownedChat] = useState(
272
258
  () =>
273
259
  externalChat ??
274
- createChat({ ...chatOptions, id, transport }, latestChatOptionsRef),
260
+ createChat(
261
+ { ...chatOptions, id, transport: chatTransport },
262
+ latestChatOptionsRef,
263
+ ),
275
264
  );
276
265
 
277
266
  const chat = useChat({
@@ -309,15 +298,42 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
309
298
  unstable_hostApprovalOwner: externalChat ?? ownedChat,
310
299
  ...(unstable_onBranchChange && { unstable_onBranchChange }),
311
300
  });
301
+ initialTransportBinding = {
302
+ runtime,
303
+ getThreadListItem: getCurrentThreadListItem,
304
+ };
305
+ useInsertionEffect(() => {
306
+ transportBindingRef.current = {
307
+ runtime,
308
+ getThreadListItem: getCurrentThreadListItem,
309
+ };
310
+ }, [runtime, getCurrentThreadListItem]);
311
+
312
+ const registerTransportContext = useEffectEvent(
313
+ (dynamicTransport: DynamicChatTransport<UI_MESSAGE>, chatId: string) => {
314
+ dynamicTransport.setThreadContext(
315
+ chatId,
316
+ transportContextOwner,
317
+ runtime,
318
+ getCurrentThreadListItem,
319
+ );
320
+ return () =>
321
+ dynamicTransport.unregisterThread(chatId, transportContextOwner);
322
+ },
323
+ );
324
+ useInsertionEffect(() => {
325
+ if (!(transport instanceof DynamicChatTransport)) return undefined;
326
+ return registerTransportContext(transport, id);
327
+ }, [id, runtime, transport, transportContextOwner]);
312
328
 
313
- // Wire in render, not an effect: a send from a descendant's mount effect
314
- // runs before this hook's effect would (effects fire child-first), and must
315
- // see a wired transport. The clone is per thread, so a discarded render's
316
- // wiring is discarded with it and the committed render re-wires the same
317
- // instance.
318
- if (sourceTransport instanceof AssistantChatTransport) {
319
- sourceTransport.setRuntime(runtime);
320
- sourceTransport.__internal_setGetThreadListItem(getThreadListItem);
329
+ if (
330
+ !(transport instanceof DynamicChatTransport) &&
331
+ configuredTransport instanceof AssistantChatTransport
332
+ ) {
333
+ configuredTransport.setRuntime(runtime);
334
+ configuredTransport.__internal_setGetThreadListItem(
335
+ getCurrentThreadListItem,
336
+ );
321
337
  }
322
338
 
323
339
  const subscribeToRuntime = useCallback(
@@ -334,10 +350,6 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
334
350
  getHistoryLoadingSnapshot,
335
351
  );
336
352
 
337
- const resumableStorage = useMemo(
338
- () => getResumableAdapter(sourceTransport)?.storage,
339
- [sourceTransport],
340
- );
341
353
  const subscribeToResumableStorage = useCallback(
342
354
  (callback: () => void) =>
343
355
  isMainThread
@@ -357,7 +369,7 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
357
369
  const isChatRunning =
358
370
  chat.status === "submitted" || chat.status === "streaming";
359
371
 
360
- const resumedStreamIds = useMemo(
372
+ const staticResumedStreamIds = useMemo(
361
373
  () => getResumedStreamIds(resumableStorage),
362
374
  [resumableStorage],
363
375
  );
@@ -366,6 +378,10 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
366
378
  onResumeErrorRef.current = onResumeError;
367
379
  });
368
380
  useEffect(() => {
381
+ const resumedStreamIds =
382
+ transport instanceof DynamicChatTransport
383
+ ? transport.getResumedStreamIds()
384
+ : staticResumedStreamIds;
369
385
  if (!pendingStreamId || resumedStreamIds.has(pendingStreamId)) {
370
386
  return;
371
387
  }
@@ -411,7 +427,8 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
411
427
  pendingStreamId,
412
428
  ownedChat,
413
429
  resumableStorage,
414
- resumedStreamIds,
430
+ staticResumedStreamIds,
431
+ transport,
415
432
  ]);
416
433
 
417
434
  return runtime;
@@ -0,0 +1,26 @@
1
+ import type { UIMessage } from "@ai-sdk/react";
2
+ import type { ChatTransport } from "ai";
3
+ import { useEffect, useInsertionEffect, useState } from "react";
4
+ import { DynamicChatTransport } from "./DynamicChatTransport";
5
+
6
+ export const useDynamicChatTransport = <UI_MESSAGE extends UIMessage>(
7
+ transport: ChatTransport<UI_MESSAGE>,
8
+ enabled = true,
9
+ ): ChatTransport<UI_MESSAGE> => {
10
+ const [dynamicTransport] = useState(() =>
11
+ transport instanceof DynamicChatTransport
12
+ ? transport
13
+ : new DynamicChatTransport(transport),
14
+ );
15
+
16
+ useInsertionEffect(() => {
17
+ if (!enabled || dynamicTransport === transport) return;
18
+ dynamicTransport.setTransport(transport);
19
+ }, [dynamicTransport, enabled, transport]);
20
+ useEffect(() => {
21
+ if (!enabled || dynamicTransport === transport) return;
22
+ dynamicTransport.flushTransportChange();
23
+ }, [dynamicTransport, enabled, transport]);
24
+
25
+ return enabled ? dynamicTransport : transport;
26
+ };
@@ -206,6 +206,53 @@ describe("useExternalHistory withFormat contract", () => {
206
206
  await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
207
207
  });
208
208
 
209
+ it("re-enters loading when remoteId arrives before history settles", async () => {
210
+ mocks.hasThreadListItem = true;
211
+ let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
212
+ const load = vi.fn(
213
+ () =>
214
+ new Promise<MessageFormatRepository<unknown>>((resolve) => {
215
+ resolveLoad = resolve;
216
+ }),
217
+ );
218
+ const adapter: ThreadHistoryAdapter = {
219
+ load: vi.fn(),
220
+ append: vi.fn(),
221
+ withFormat: vi.fn().mockReturnValue({
222
+ load,
223
+ append: vi.fn().mockResolvedValue(undefined),
224
+ }),
225
+ };
226
+
227
+ const { result } = renderHook(() =>
228
+ useExternalHistory(
229
+ runtimeRef,
230
+ adapter,
231
+ toThreadMessages,
232
+ storageFormat,
233
+ onSetMessages,
234
+ ),
235
+ );
236
+
237
+ await act(async () => {});
238
+ expect(result.current.isLoading).toBe(false);
239
+ expect(load).not.toHaveBeenCalled();
240
+
241
+ mocks.remoteId = "remote-thread";
242
+ await act(async () => {
243
+ for (const listener of mocks.listeners) listener();
244
+ });
245
+
246
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
247
+ expect(result.current.isLoading).toBe(true);
248
+
249
+ await act(async () => {
250
+ resolveLoad({ headId: null, messages: [] });
251
+ });
252
+
253
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
254
+ });
255
+
209
256
  it("does not load history when remoteId appears during an active run", async () => {
210
257
  mocks.hasThreadListItem = true;
211
258
  const load = vi.fn().mockResolvedValue({ headId: null, messages: [] });
@@ -288,6 +335,70 @@ describe("useExternalHistory withFormat contract", () => {
288
335
 
289
336
  await waitFor(() => expect(result.current.isLoading).toBe(false));
290
337
  });
338
+
339
+ it("does not replace live messages when history resolves after a send starts", async () => {
340
+ mocks.hasThreadListItem = true;
341
+ mocks.remoteId = "remote-thread";
342
+ let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
343
+ const load = vi.fn(
344
+ () =>
345
+ new Promise<MessageFormatRepository<unknown>>((resolve) => {
346
+ resolveLoad = resolve;
347
+ }),
348
+ );
349
+ const threadState = {
350
+ isRunning: false,
351
+ messages: [] as ThreadMessage[],
352
+ };
353
+ const threadImport = vi.fn();
354
+ const setMessages = vi.fn();
355
+ const thread = {
356
+ subscribe: () => () => {},
357
+ getState: () => threadState,
358
+ import: threadImport,
359
+ export: () => ({ headId: null, messages: [] }),
360
+ } as unknown as AssistantRuntime["thread"];
361
+ const historyRuntimeRef = {
362
+ current: { thread } as AssistantRuntime,
363
+ };
364
+ const adapter: ThreadHistoryAdapter = {
365
+ load: vi.fn(),
366
+ append: vi.fn(),
367
+ withFormat: vi.fn().mockReturnValue({
368
+ load,
369
+ append: vi.fn().mockResolvedValue(undefined),
370
+ }),
371
+ };
372
+
373
+ const { result } = renderHook(() =>
374
+ useExternalHistory(
375
+ historyRuntimeRef,
376
+ adapter,
377
+ (messages) => messages.map((message) => message as ThreadMessage),
378
+ storageFormat,
379
+ setMessages,
380
+ ),
381
+ );
382
+
383
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
384
+ const liveMessages = [
385
+ { id: "live-user" },
386
+ { id: "live-assistant" },
387
+ ] as ThreadMessage[];
388
+ await act(async () => {
389
+ threadState.isRunning = true;
390
+ threadState.messages = liveMessages;
391
+ resolveLoad({
392
+ headId: "stored",
393
+ messages: [{ parentId: null, message: { id: "stored" } }],
394
+ });
395
+ });
396
+
397
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
398
+ expect(threadState.messages).toBe(liveMessages);
399
+ expect(threadImport).not.toHaveBeenCalled();
400
+ expect(setMessages).not.toHaveBeenCalled();
401
+ });
291
402
  });
292
403
 
293
404
  describe("toExportedMessageRepository", () => {