@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.
- package/LICENSE +1 -1
- package/README.md +1 -1
- package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -2
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -3
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
- package/dist/adapters/vercelAttachmentAdapter.js +3 -3
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -1
- package/dist/aiSDKExtras.d.ts +4 -6
- package/dist/aiSDKExtras.d.ts.map +1 -1
- package/dist/converters/convertMessage.d.ts +34 -24
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +56 -12
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/modelContentEnvelope.d.ts +6 -7
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
- package/dist/converters/toCreateMessage.d.ts +2 -4
- package/dist/converters/toCreateMessage.d.ts.map +1 -1
- package/dist/converters/toolOutputConversion.d.ts +20 -22
- package/dist/converters/toolOutputConversion.d.ts.map +1 -1
- package/dist/hooks.d.ts +2 -4
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +17 -14
- package/dist/index.d.ts.map +1 -0
- package/dist/index.native.d.ts +15 -12
- package/dist/index.native.d.ts.map +1 -0
- package/dist/model-context/injectInteractableContext.d.ts +2 -4
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +1 -3
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.d.ts +2 -4
- package/dist/runtime/AISDKChat.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKChat.js.map +1 -1
- package/dist/runtime/AISDKThreads.d.ts +29 -31
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +22 -23
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/sdkIdentity.d.ts +1 -3
- package/dist/runtime/sdkIdentity.d.ts.map +1 -1
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +76 -70
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +311 -77
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts +6 -8
- package/dist/runtime/useChatRuntime.d.ts.map +1 -1
- package/dist/runtime/useChatRuntime.js +14 -10
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useChatThread.d.ts +94 -89
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +44 -15
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts +7 -7
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +231 -60
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.d.ts +1 -3
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
- package/dist/runtime/useResourceCleanup.js +38 -14
- package/dist/runtime/useResourceCleanup.js.map +1 -1
- package/dist/runtime/useStreamingTiming.d.ts +3 -5
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
- package/dist/runtime/useStreamingTiming.js +1 -1
- package/dist/tools/frontendTools.d.ts +21 -23
- package/dist/tools/frontendTools.d.ts.map +1 -1
- package/dist/tools/generativeTools.d.ts +24 -26
- package/dist/tools/generativeTools.d.ts.map +1 -1
- package/dist/tools/mcp-stdio.node.d.ts +2 -2
- package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +2 -4
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.d.ts +14 -16
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.js +9 -2
- package/dist/transport/AssistantChatTransport.js.map +1 -1
- package/dist/transport/resumable.d.ts +25 -27
- package/dist/transport/resumable.d.ts.map +1 -1
- package/dist/usage.d.ts +8 -10
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js.map +1 -1
- package/dist/utils/getVercelAIMessages.d.ts +2 -4
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
- package/dist/utils/sliceMessagesUntil.d.ts +1 -3
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/package.json +13 -9
- package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
- package/src/adapters/vercelAttachmentAdapter.ts +2 -2
- package/src/converters/convertMessage.test.ts +541 -3
- package/src/converters/convertMessage.ts +123 -15
- package/src/index.native.ts +1 -1
- package/src/index.ts +1 -1
- package/src/runtime/AISDKChat.ts +0 -4
- package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
- package/src/runtime/AISDKThreads.test.ts +105 -46
- package/src/runtime/AISDKThreads.ts +1 -1
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1639 -19
- package/src/runtime/useAISDKRuntime.approval.test.tsx +252 -9
- package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +182 -0
- package/src/runtime/useAISDKRuntime.test.ts +275 -5
- package/src/runtime/useAISDKRuntime.ts +533 -60
- package/src/runtime/useAISDKRuntime.voice.test.tsx +50 -1
- package/src/runtime/useChatRuntime.fast-refresh.test.tsx +94 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +143 -65
- package/src/runtime/useChatRuntime.test.ts +11 -7
- package/src/runtime/useChatRuntime.ts +6 -2
- package/src/runtime/useChatThread.test.ts +166 -2
- package/src/runtime/useChatThread.transport.test.tsx +152 -0
- package/src/runtime/useChatThread.ts +89 -22
- package/src/runtime/useExternalHistory.test.ts +943 -3
- package/src/runtime/useExternalHistory.ts +378 -15
- package/src/runtime/useResourceCleanup.ts +41 -15
- package/src/transport/AssistantChatTransport.test.ts +164 -0
- package/src/transport/AssistantChatTransport.ts +22 -2
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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(
|
|
247
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
]);
|