@assistant-ui/ai-sdk 0.0.7 → 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.
Files changed (115) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1 -1
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -2
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -3
  6. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/vercelAttachmentAdapter.js +3 -3
  8. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -1
  9. package/dist/aiSDKExtras.d.ts +4 -6
  10. package/dist/aiSDKExtras.d.ts.map +1 -1
  11. package/dist/converters/convertMessage.d.ts +34 -24
  12. package/dist/converters/convertMessage.d.ts.map +1 -1
  13. package/dist/converters/convertMessage.js +56 -12
  14. package/dist/converters/convertMessage.js.map +1 -1
  15. package/dist/converters/modelContentEnvelope.d.ts +6 -7
  16. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  17. package/dist/converters/toCreateMessage.d.ts +2 -4
  18. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  19. package/dist/converters/toolOutputConversion.d.ts +20 -22
  20. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  21. package/dist/hooks.d.ts +2 -4
  22. package/dist/hooks.d.ts.map +1 -1
  23. package/dist/index.d.ts +17 -14
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.native.d.ts +15 -12
  26. package/dist/index.native.d.ts.map +1 -0
  27. package/dist/model-context/injectInteractableContext.d.ts +2 -4
  28. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  29. package/dist/model-context/injectQuoteContext.d.ts +1 -3
  30. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  31. package/dist/runtime/AISDKChat.d.ts +2 -4
  32. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  33. package/dist/runtime/AISDKChat.js +1 -1
  34. package/dist/runtime/AISDKChat.js.map +1 -1
  35. package/dist/runtime/AISDKThreads.d.ts +29 -31
  36. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  37. package/dist/runtime/AISDKThreads.js +22 -23
  38. package/dist/runtime/AISDKThreads.js.map +1 -1
  39. package/dist/runtime/sdkIdentity.d.ts +1 -3
  40. package/dist/runtime/sdkIdentity.d.ts.map +1 -1
  41. package/dist/runtime/sdkIdentity.js +1 -1
  42. package/dist/runtime/useAISDKRuntime.d.ts +76 -70
  43. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  44. package/dist/runtime/useAISDKRuntime.js +311 -77
  45. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  46. package/dist/runtime/useChatRuntime.d.ts +6 -8
  47. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  48. package/dist/runtime/useChatRuntime.js +14 -10
  49. package/dist/runtime/useChatRuntime.js.map +1 -1
  50. package/dist/runtime/useChatThread.d.ts +94 -89
  51. package/dist/runtime/useChatThread.d.ts.map +1 -1
  52. package/dist/runtime/useChatThread.js +44 -15
  53. package/dist/runtime/useChatThread.js.map +1 -1
  54. package/dist/runtime/useExternalHistory.d.ts +7 -7
  55. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  56. package/dist/runtime/useExternalHistory.js +231 -60
  57. package/dist/runtime/useExternalHistory.js.map +1 -1
  58. package/dist/runtime/useResourceCleanup.d.ts +1 -3
  59. package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
  60. package/dist/runtime/useResourceCleanup.js +38 -14
  61. package/dist/runtime/useResourceCleanup.js.map +1 -1
  62. package/dist/runtime/useStreamingTiming.d.ts +3 -5
  63. package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
  64. package/dist/runtime/useStreamingTiming.js +1 -1
  65. package/dist/tools/frontendTools.d.ts +21 -23
  66. package/dist/tools/frontendTools.d.ts.map +1 -1
  67. package/dist/tools/generativeTools.d.ts +24 -26
  68. package/dist/tools/generativeTools.d.ts.map +1 -1
  69. package/dist/tools/mcp-stdio.node.d.ts +2 -2
  70. package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
  71. package/dist/tools/mcp-stdio.unsupported.d.ts +2 -4
  72. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  73. package/dist/transport/AssistantChatTransport.d.ts +14 -16
  74. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  75. package/dist/transport/AssistantChatTransport.js +9 -2
  76. package/dist/transport/AssistantChatTransport.js.map +1 -1
  77. package/dist/transport/resumable.d.ts +25 -27
  78. package/dist/transport/resumable.d.ts.map +1 -1
  79. package/dist/usage.d.ts +8 -10
  80. package/dist/usage.d.ts.map +1 -1
  81. package/dist/usage.js.map +1 -1
  82. package/dist/utils/getVercelAIMessages.d.ts +2 -4
  83. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  84. package/dist/utils/sliceMessagesUntil.d.ts +1 -3
  85. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  86. package/package.json +13 -9
  87. package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
  88. package/src/adapters/vercelAttachmentAdapter.ts +2 -2
  89. package/src/converters/convertMessage.test.ts +541 -3
  90. package/src/converters/convertMessage.ts +123 -15
  91. package/src/index.native.ts +1 -1
  92. package/src/index.ts +1 -1
  93. package/src/runtime/AISDKChat.ts +0 -4
  94. package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
  95. package/src/runtime/AISDKThreads.test.ts +105 -46
  96. package/src/runtime/AISDKThreads.ts +1 -1
  97. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1639 -19
  98. package/src/runtime/useAISDKRuntime.approval.test.tsx +252 -9
  99. package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +182 -0
  100. package/src/runtime/useAISDKRuntime.test.ts +275 -5
  101. package/src/runtime/useAISDKRuntime.ts +533 -60
  102. package/src/runtime/useAISDKRuntime.voice.test.tsx +50 -1
  103. package/src/runtime/useChatRuntime.fast-refresh.test.tsx +94 -0
  104. package/src/runtime/useChatRuntime.integration.test.tsx +143 -65
  105. package/src/runtime/useChatRuntime.test.ts +11 -7
  106. package/src/runtime/useChatRuntime.ts +6 -2
  107. package/src/runtime/useChatThread.test.ts +166 -2
  108. package/src/runtime/useChatThread.transport.test.tsx +152 -0
  109. package/src/runtime/useChatThread.ts +89 -22
  110. package/src/runtime/useExternalHistory.test.ts +943 -3
  111. package/src/runtime/useExternalHistory.ts +378 -15
  112. package/src/runtime/useResourceCleanup.ts +41 -15
  113. package/src/transport/AssistantChatTransport.test.ts +164 -0
  114. package/src/transport/AssistantChatTransport.ts +22 -2
  115. package/src/usage.ts +1 -1
@@ -3,6 +3,7 @@
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { resource, useResource, flushTapSync } from "@assistant-ui/tap";
5
5
  import { useState } from "react";
6
+ import type { ChatTransport, UIMessage } from "ai";
6
7
  import {
7
8
  RuntimeAdapter,
8
9
  runtimeAdapterTransformScopes,
@@ -12,14 +13,21 @@ import {
12
13
  AuiConfig,
13
14
  createAssistantClient,
14
15
  } from "@assistant-ui/store/client";
15
- import { useChatThread, type ChatThreadEnvironment } from "./useChatThread";
16
+ import {
17
+ createChat,
18
+ useChatThread,
19
+ type ChatThreadEnvironment,
20
+ } from "./useChatThread";
21
+ import { AssistantChatTransport } from "../transport/AssistantChatTransport";
22
+ import { createResumableSessionStorage } from "../transport/resumable";
16
23
  import {
17
24
  createCancellableTransport,
18
25
  nextTask,
19
26
  } from "./__tests__/controlled-transport";
20
27
 
21
28
  const createHost = (
22
- env: Pick<ChatThreadEnvironment, "stopOnClientDestroy">,
29
+ env: Pick<ChatThreadEnvironment, "stopOnClientDestroy" | "chat">,
30
+ onRuntime?: (runtime: ReturnType<typeof useChatThread>) => void,
23
31
  ) => {
24
32
  const useHost = (options: Parameters<typeof useChatThread>[0]) => {
25
33
  const [threadListItem] = useState(() => ({
@@ -31,6 +39,7 @@ const createHost = (
31
39
  getThreadListItem: () => threadListItem,
32
40
  ...env,
33
41
  });
42
+ onRuntime?.(runtime);
34
43
  return useResource(RuntimeAdapter(runtime));
35
44
  };
36
45
  attachTransformScopes(useHost, runtimeAdapterTransformScopes);
@@ -64,6 +73,161 @@ const streamThenDestroy = async (
64
73
  };
65
74
 
66
75
  describe("useChatThread", () => {
76
+ it.each([
77
+ { adapters: undefined, threadId: "main" },
78
+ {
79
+ adapters: { threadList: { threadId: "caller-id" } },
80
+ threadId: "caller-id",
81
+ },
82
+ ])("uses $threadId as the runtime thread id", ({ adapters, threadId }) => {
83
+ let runtime: ReturnType<typeof useChatThread> | undefined;
84
+ const Host = createHost({}, (value) => {
85
+ runtime = value;
86
+ });
87
+ const handle = createAssistantClient(
88
+ AuiConfig({ threads: Host({ adapters }) }),
89
+ );
90
+ try {
91
+ handle.subscribe(() => {});
92
+ expect(runtime?.thread.getState().threadId).toBe(threadId);
93
+ } finally {
94
+ handle.destroy();
95
+ }
96
+ });
97
+
98
+ it.each(["send", "resume"])(
99
+ "ignores errors from a subsequent %s when a resume finishes",
100
+ async (nextRequest) => {
101
+ const storage = createResumableSessionStorage({
102
+ key: `resume-${nextRequest}-race`,
103
+ });
104
+ storage.setStreamId("stream-1", "main");
105
+ const error = new Error(`${nextRequest} offline`);
106
+ const onError = vi.fn();
107
+ const onResumeError = vi.fn();
108
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
109
+ const transport = {
110
+ getResumableAdapter: () => ({ storage }),
111
+ reconnectToStream: vi
112
+ .fn<ChatTransport<UIMessage>["reconnectToStream"]>()
113
+ .mockRejectedValue(error)
114
+ .mockImplementationOnce(
115
+ async () =>
116
+ new ReadableStream({
117
+ start(controller) {
118
+ controller.enqueue({ type: "start", messageId: "resumed" });
119
+ controller.close();
120
+ },
121
+ }),
122
+ ),
123
+ sendMessages: vi.fn(async () => {
124
+ throw error;
125
+ }),
126
+ } satisfies ChatTransport<UIMessage> & {
127
+ getResumableAdapter: () => { storage: typeof storage };
128
+ };
129
+ let sent = false;
130
+ const callbacks = {
131
+ onError,
132
+ onFinish: () => {
133
+ if (!sent) {
134
+ sent = true;
135
+ if (nextRequest === "resume") {
136
+ void chat.resumeStream();
137
+ } else {
138
+ void chat.sendMessage({
139
+ role: "user",
140
+ parts: [{ type: "text", text: "next" }],
141
+ });
142
+ }
143
+ }
144
+ },
145
+ };
146
+ const chat = createChat(
147
+ {
148
+ id: "main",
149
+ transport,
150
+ },
151
+ { current: callbacks },
152
+ );
153
+ const Host = createHost({ chat });
154
+ const handle = createAssistantClient(
155
+ AuiConfig({ threads: Host({ transport, onResumeError }) }),
156
+ );
157
+ handle.subscribe(() => {});
158
+ try {
159
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error));
160
+ await nextTask();
161
+ expect(onResumeError).not.toHaveBeenCalled();
162
+ expect(storage.getStreamId("main")).toBe("stream-1");
163
+ } finally {
164
+ handle.destroy();
165
+ storage.clear();
166
+ warn.mockRestore();
167
+ }
168
+ },
169
+ );
170
+
171
+ it.each([false, true])(
172
+ "reports SDK reconnect failures and preserves replacement checkpoints: %s",
173
+ async (replaceCheckpoint) => {
174
+ const storage = createResumableSessionStorage({
175
+ key: `automatic-resume-error-${replaceCheckpoint}`,
176
+ });
177
+ storage.setStreamId("failed-stream", "main");
178
+ const error = new Error("resume offline");
179
+ const onError = vi.fn();
180
+ const onResumeError = vi.fn(() => {
181
+ if (replaceCheckpoint) storage.setStreamId("replacement", "main");
182
+ });
183
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
184
+ let finishReplacement: (() => void) | undefined;
185
+ const fetch = vi
186
+ .fn<typeof globalThis.fetch>()
187
+ .mockRejectedValueOnce(error)
188
+ .mockImplementation(
189
+ () =>
190
+ new Promise<Response>((resolve) => {
191
+ finishReplacement = () =>
192
+ resolve(new Response(null, { status: 204 }));
193
+ }),
194
+ );
195
+ const Host = createHost({});
196
+ const handle = createAssistantClient(
197
+ AuiConfig({
198
+ threads: Host({
199
+ transport: new AssistantChatTransport({
200
+ fetch,
201
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
202
+ }),
203
+ onError,
204
+ onResumeError,
205
+ }),
206
+ }),
207
+ );
208
+ handle.subscribe(() => {});
209
+ try {
210
+ await vi.waitFor(() => {
211
+ expect(onError).toHaveBeenCalledWith(error);
212
+ expect(onResumeError).toHaveBeenCalledOnce();
213
+ });
214
+ expect(onResumeError).toHaveBeenCalledWith(error);
215
+ expect(warn).toHaveBeenCalledWith(
216
+ "[assistant-ui] resumable: resume failed",
217
+ error,
218
+ );
219
+ expect(storage.getStreamId("main")).toBe(
220
+ replaceCheckpoint ? "replacement" : null,
221
+ );
222
+ } finally {
223
+ handle.destroy();
224
+ finishReplacement?.();
225
+ storage.clear();
226
+ warn.mockRestore();
227
+ }
228
+ },
229
+ );
230
+
67
231
  it("stops an in-flight chat on client destroy when stopOnClientDestroy is omitted", async () => {
68
232
  expect(await streamThenDestroy({})).toBe(1);
69
233
  });
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { useEffect, version } from "react";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { Chat } from "@ai-sdk/react";
7
+ import {
8
+ AssistantChatTransport,
9
+ type InitializableThreadListItem,
10
+ } from "../transport/AssistantChatTransport";
11
+ import { useChatThread } from "./useChatThread";
12
+
13
+ const onReact18 = version.startsWith("18.");
14
+
15
+ const itemFor = (remoteId: string) => ({
16
+ initialize: async () => ({ remoteId, externalId: undefined }),
17
+ });
18
+
19
+ const finishedStream = () =>
20
+ new Response(
21
+ new ReadableStream<Uint8Array>({
22
+ start(controller) {
23
+ controller.enqueue(
24
+ new TextEncoder().encode(
25
+ 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
26
+ ),
27
+ );
28
+ controller.close();
29
+ },
30
+ }),
31
+ { headers: { "content-type": "text/event-stream" } },
32
+ );
33
+
34
+ // Fails on React 18: TypeError: ReactRuntime.use is not a function. Shipped React 18 incompatibility.
35
+ describe.skipIf(onReact18)("useChatThread shared transport isolation", () => {
36
+ it("gives each thread its own clone wired to its own thread-list item", async () => {
37
+ const transport = new AssistantChatTransport({ api: "/api/chat" });
38
+ const setRuntime = vi.spyOn(transport, "setRuntime");
39
+ const setGetItem = vi.spyOn(transport, "__internal_setGetThreadListItem");
40
+ const clones: AssistantChatTransport<never>[] = [];
41
+ const getters: (() => InitializableThreadListItem | undefined)[] = [];
42
+ const realClone = transport.__internal_clone.bind(transport);
43
+ vi.spyOn(transport, "__internal_clone").mockImplementation(() => {
44
+ const clone = realClone();
45
+ const index = clones.push(clone as AssistantChatTransport<never>) - 1;
46
+ vi.spyOn(clone, "__internal_setGetThreadListItem").mockImplementation(
47
+ (getter) => {
48
+ getters[index] = getter;
49
+ },
50
+ );
51
+ return clone;
52
+ });
53
+
54
+ renderHook(() =>
55
+ useChatThread(
56
+ { transport },
57
+ {
58
+ id: "thread-a",
59
+ isMainThread: true,
60
+ getThreadListItem: () => itemFor("remote-a"),
61
+ },
62
+ ),
63
+ );
64
+ renderHook(() =>
65
+ useChatThread(
66
+ { transport },
67
+ {
68
+ id: "thread-b",
69
+ isMainThread: false,
70
+ getThreadListItem: () => itemFor("remote-b"),
71
+ },
72
+ ),
73
+ );
74
+
75
+ expect(setRuntime).not.toHaveBeenCalled();
76
+ expect(setGetItem).not.toHaveBeenCalled();
77
+ expect(clones).toHaveLength(2);
78
+ expect(clones[0]).not.toBe(clones[1]);
79
+
80
+ const remoteIdOf = async (
81
+ getter?: () => InitializableThreadListItem | undefined,
82
+ ) => (await getter?.()?.initialize())?.remoteId;
83
+ expect(await remoteIdOf(getters[0])).toBe("remote-a");
84
+ expect(await remoteIdOf(getters[1])).toBe("remote-b");
85
+ });
86
+
87
+ it("sends each thread's request with that thread's remoteId and model context", async () => {
88
+ const sent: { id: unknown; system: unknown }[] = [];
89
+ const transport = new AssistantChatTransport({
90
+ api: "/api/chat",
91
+ fetch: async (_input, init) => {
92
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
93
+ sent.push({ id: body["id"], system: body["system"] });
94
+ return finishedStream();
95
+ },
96
+ });
97
+
98
+ const mount = (id: string, remoteId: string, system: string) =>
99
+ renderHook(() => {
100
+ const runtime = useChatThread(
101
+ { transport },
102
+ {
103
+ id,
104
+ isMainThread: id === "thread-a",
105
+ getThreadListItem: () => itemFor(remoteId),
106
+ },
107
+ );
108
+ useEffect(
109
+ () =>
110
+ runtime.registerModelContextProvider({
111
+ getModelContext: () => ({ system }),
112
+ }),
113
+ [runtime],
114
+ );
115
+ return runtime;
116
+ });
117
+
118
+ const threadA = mount("thread-a", "remote-a", "system-a");
119
+ const threadB = mount("thread-b", "remote-b", "system-b");
120
+
121
+ threadA.rerender();
122
+
123
+ await act(async () => {
124
+ threadB.result.current.thread.append("hello");
125
+ });
126
+
127
+ await waitFor(() => expect(sent).toHaveLength(1));
128
+ expect(sent[0]).toEqual({ id: "remote-b", system: "system-b" });
129
+ });
130
+
131
+ it("uses the supplied instance when the caller owns the chat", () => {
132
+ const transport = new AssistantChatTransport({ api: "/api/chat" });
133
+ const clone = vi.spyOn(transport, "__internal_clone");
134
+ const chat = new Chat({ id: "thread-a", transport });
135
+
136
+ renderHook(() =>
137
+ useChatThread(
138
+ { transport },
139
+ {
140
+ id: "thread-a",
141
+ isMainThread: true,
142
+ getThreadListItem: () => itemFor("remote-a"),
143
+ chat,
144
+ },
145
+ ),
146
+ );
147
+
148
+ // A caller-owned chat is already bound to its transport, so cloning again
149
+ // would wire an instance the chat never sends through.
150
+ expect(clone).not.toHaveBeenCalled();
151
+ });
152
+ });
@@ -57,6 +57,12 @@ export type ChatThreadEnvironment<UI_MESSAGE extends UIMessage = UIMessage> = {
57
57
  isMainThread: boolean;
58
58
  getThreadListItem: () => InitializableThreadListItem | undefined;
59
59
  stopOnClientDestroy?: boolean;
60
+ /**
61
+ * Aborts when the React component hosting the runtime is deleted. A nested
62
+ * runtime resolves the destroy signal of the provider above it, which
63
+ * outlives the nested component, so this stops the chat on its own unmount.
64
+ */
65
+ hostDestroySignal?: AbortSignal | undefined;
60
66
  /**
61
67
  * An externally owned chat instance. State lives on the instance, so it
62
68
  * survives the hosting resource unmounting; construction options are read
@@ -175,6 +181,8 @@ type ChatCallbacks<UI_MESSAGE extends UIMessage> = Pick<
175
181
  "onToolCall" | "onData" | "onFinish" | "onError" | "sendAutomaticallyWhen"
176
182
  >;
177
183
 
184
+ const requestsByChat = new WeakMap<object, symbol>();
185
+
178
186
  /**
179
187
  * Constructs a `Chat` whose callbacks read the latest options through
180
188
  * `callbacksRef`, the forwarding `useChat` applies only to a chat it
@@ -183,9 +191,22 @@ type ChatCallbacks<UI_MESSAGE extends UIMessage> = Pick<
183
191
  export const createChat = <UI_MESSAGE extends UIMessage>(
184
192
  init: ChatInit<UI_MESSAGE>,
185
193
  callbacksRef: { readonly current: ChatCallbacks<UI_MESSAGE> | undefined },
186
- ): Chat<UI_MESSAGE> =>
187
- new Chat<UI_MESSAGE>({
194
+ ): Chat<UI_MESSAGE> => {
195
+ const transport = init.transport;
196
+ const chat = new Chat<UI_MESSAGE>({
188
197
  ...init,
198
+ ...(transport && {
199
+ transport: {
200
+ sendMessages: (options) => {
201
+ requestsByChat.set(chat, Symbol());
202
+ return transport.sendMessages(options);
203
+ },
204
+ reconnectToStream: (options) => {
205
+ requestsByChat.set(chat, Symbol());
206
+ return transport.reconnectToStream(options);
207
+ },
208
+ },
209
+ }),
189
210
  onToolCall: (arg) => callbacksRef.current?.onToolCall?.(arg),
190
211
  onData: (arg) => callbacksRef.current?.onData?.(arg),
191
212
  onFinish: (arg) => callbacksRef.current?.onFinish?.(arg),
@@ -193,6 +214,8 @@ export const createChat = <UI_MESSAGE extends UIMessage>(
193
214
  sendAutomaticallyWhen: (arg) =>
194
215
  callbacksRef.current?.sendAutomaticallyWhen?.(arg) ?? false,
195
216
  });
217
+ return chat;
218
+ };
196
219
 
197
220
  export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
198
221
  options: ChatThreadOptions<UI_MESSAGE> | undefined,
@@ -218,12 +241,25 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
218
241
  isMainThread,
219
242
  getThreadListItem,
220
243
  stopOnClientDestroy = true,
244
+ hostDestroySignal,
221
245
  chat: externalChat,
222
246
  messageRepositoryInstance,
223
247
  } = env;
224
248
 
225
- const defaultTransport = useMemo(() => new AssistantChatTransport(), []);
226
- const sourceTransport = transportOptions ?? defaultTransport;
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(
254
+ () =>
255
+ transportOptions === undefined
256
+ ? new AssistantChatTransport()
257
+ : externalChat === undefined &&
258
+ transportOptions instanceof AssistantChatTransport
259
+ ? transportOptions.__internal_clone()
260
+ : transportOptions,
261
+ [transportOptions, externalChat],
262
+ );
227
263
  const transport = useDynamicChatTransport(sourceTransport);
228
264
 
229
265
  const latestChatOptionsRef = useRef(chatOptions);
@@ -243,12 +279,19 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
243
279
  ...(throttle !== undefined && { throttle }),
244
280
  });
245
281
 
246
- useResourceCleanup(stopOnClientDestroy, () => {
247
- void chat.stop().catch(() => {});
248
- });
282
+ useResourceCleanup(
283
+ stopOnClientDestroy,
284
+ () => {
285
+ void chat.stop().catch(() => {});
286
+ },
287
+ hostDestroySignal,
288
+ );
249
289
 
250
290
  const runtime = useAISDKRuntime(chat, {
251
- adapters,
291
+ adapters: {
292
+ ...adapters,
293
+ threadList: { threadId: id, ...adapters?.threadList },
294
+ },
252
295
  ...pickExternalStoreSharedOptions(options ?? {}),
253
296
  ...(toCreateMessage && { toCreateMessage }),
254
297
  ...(onResume && { onResume }),
@@ -259,9 +302,19 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
259
302
  ...(messageRepositoryInstance && {
260
303
  unstable_messageRepositoryInstance: messageRepositoryInstance,
261
304
  }),
305
+ // The chat outlives this runtime when a host mounts only the visible
306
+ // thread, so a host approval answer is kept with it. This is the Chat
307
+ // instance, not the useChat helpers, which are re-minted every render and
308
+ // would be a dead WeakMap key by the next one.
309
+ unstable_hostApprovalOwner: externalChat ?? ownedChat,
262
310
  ...(unstable_onBranchChange && { unstable_onBranchChange }),
263
311
  });
264
312
 
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.
265
318
  if (sourceTransport instanceof AssistantChatTransport) {
266
319
  sourceTransport.setRuntime(runtime);
267
320
  sourceTransport.__internal_setGetThreadListItem(getThreadListItem);
@@ -322,27 +375,41 @@ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
322
375
  }
323
376
  if (isLoadingHistory) return;
324
377
  resumedStreamIds.add(pendingStreamId);
325
- chat.resumeStream().catch((err: unknown) => {
326
- console.warn("[assistant-ui] resumable: resume failed", err);
327
- try {
328
- onResumeErrorRef.current?.(err);
329
- } catch (callbackError) {
330
- console.error(
331
- "[assistant-ui] resumable: onResumeError callback failed",
332
- callbackError,
333
- );
334
- } finally {
335
- if (resumableStorage?.getStreamId(id) === pendingStreamId) {
336
- resumableStorage.clear(id);
378
+ const activeChat = externalChat ?? ownedChat;
379
+ activeChat.clearError();
380
+ const pending = chat.resumeStream();
381
+ const request = requestsByChat.get(activeChat);
382
+ pending
383
+ .then(() => {
384
+ // Chat.error is shared with sends and resumes that can start before
385
+ // this promise settles, including inside the caller's onFinish.
386
+ if (requestsByChat.get(activeChat) === request && activeChat.error) {
387
+ throw activeChat.error;
388
+ }
389
+ })
390
+ .catch((err: unknown) => {
391
+ console.warn("[assistant-ui] resumable: resume failed", err);
392
+ try {
393
+ onResumeErrorRef.current?.(err);
394
+ } catch (callbackError) {
395
+ console.error(
396
+ "[assistant-ui] resumable: onResumeError callback failed",
397
+ callbackError,
398
+ );
399
+ } finally {
400
+ if (resumableStorage?.getStreamId(id) === pendingStreamId) {
401
+ resumableStorage.clear(id);
402
+ }
337
403
  }
338
- }
339
- });
404
+ });
340
405
  }, [
341
406
  chat,
407
+ externalChat,
342
408
  id,
343
409
  isChatRunning,
344
410
  isLoadingHistory,
345
411
  pendingStreamId,
412
+ ownedChat,
346
413
  resumableStorage,
347
414
  resumedStreamIds,
348
415
  ]);