@assistant-ui/ai-sdk 0.0.1
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 +21 -0
- package/README.md +32 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/aiSDKFormatAdapter.js +26 -0
- package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.js +37 -0
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
- package/dist/aiSDKExtras.d.ts +11 -0
- package/dist/aiSDKExtras.d.ts.map +1 -0
- package/dist/aiSDKExtras.js +7 -0
- package/dist/aiSDKExtras.js.map +1 -0
- package/dist/converters/convertMessage.d.ts +28 -0
- package/dist/converters/convertMessage.d.ts.map +1 -0
- package/dist/converters/convertMessage.js +249 -0
- package/dist/converters/convertMessage.js.map +1 -0
- package/dist/converters/modelContentEnvelope.d.ts +16 -0
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
- package/dist/converters/modelContentEnvelope.js +22 -0
- package/dist/converters/modelContentEnvelope.js.map +1 -0
- package/dist/converters/toCreateMessage.d.ts +7 -0
- package/dist/converters/toCreateMessage.d.ts.map +1 -0
- package/dist/converters/toCreateMessage.js +58 -0
- package/dist/converters/toCreateMessage.js.map +1 -0
- package/dist/converters/toolOutputConversion.d.ts +29 -0
- package/dist/converters/toolOutputConversion.d.ts.map +1 -0
- package/dist/converters/toolOutputConversion.js +31 -0
- package/dist/converters/toolOutputConversion.js.map +1 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +20 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/index.native.d.ts +12 -0
- package/dist/index.native.js +11 -0
- package/dist/model-context/injectInteractableContext.d.ts +37 -0
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
- package/dist/model-context/injectInteractableContext.js +52 -0
- package/dist/model-context/injectInteractableContext.js.map +1 -0
- package/dist/model-context/injectQuoteContext.d.ts +27 -0
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
- package/dist/model-context/injectQuoteContext.js +51 -0
- package/dist/model-context/injectQuoteContext.js.map +1 -0
- package/dist/runtime/AISDKChat.d.ts +16 -0
- package/dist/runtime/AISDKChat.d.ts.map +1 -0
- package/dist/runtime/AISDKChat.js +69 -0
- package/dist/runtime/AISDKChat.js.map +1 -0
- package/dist/runtime/AISDKThreads.d.ts +45 -0
- package/dist/runtime/AISDKThreads.d.ts.map +1 -0
- package/dist/runtime/AISDKThreads.js +203 -0
- package/dist/runtime/AISDKThreads.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +49 -0
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
- package/dist/runtime/useAISDKRuntime.js +266 -0
- package/dist/runtime/useAISDKRuntime.js.map +1 -0
- package/dist/runtime/useChatRuntime.d.ts +13 -0
- package/dist/runtime/useChatRuntime.d.ts.map +1 -0
- package/dist/runtime/useChatRuntime.js +52 -0
- package/dist/runtime/useChatRuntime.js.map +1 -0
- package/dist/runtime/useChatThread.d.ts +77 -0
- package/dist/runtime/useChatThread.d.ts.map +1 -0
- package/dist/runtime/useChatThread.js +134 -0
- package/dist/runtime/useChatThread.js.map +1 -0
- package/dist/runtime/useExternalHistory.d.ts +11 -0
- package/dist/runtime/useExternalHistory.d.ts.map +1 -0
- package/dist/runtime/useExternalHistory.js +278 -0
- package/dist/runtime/useExternalHistory.js.map +1 -0
- package/dist/runtime/useResourceCleanup.d.ts +5 -0
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
- package/dist/runtime/useResourceCleanup.js +47 -0
- package/dist/runtime/useResourceCleanup.js.map +1 -0
- package/dist/runtime/useStreamingTiming.d.ts +15 -0
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
- package/dist/runtime/useStreamingTiming.js +38 -0
- package/dist/runtime/useStreamingTiming.js.map +1 -0
- package/dist/tools/frontendTools.d.ts +33 -0
- package/dist/tools/frontendTools.d.ts.map +1 -0
- package/dist/tools/frontendTools.js +33 -0
- package/dist/tools/frontendTools.js.map +1 -0
- package/dist/tools/generativeTools.d.ts +73 -0
- package/dist/tools/generativeTools.d.ts.map +1 -0
- package/dist/tools/generativeTools.js +245 -0
- package/dist/tools/generativeTools.js.map +1 -0
- package/dist/tools/mcp-stdio.node.d.ts +2 -0
- package/dist/tools/mcp-stdio.node.js +2 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.js +11 -0
- package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
- package/dist/transport/AssistantChatTransport.d.ts +23 -0
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
- package/dist/transport/AssistantChatTransport.js +134 -0
- package/dist/transport/AssistantChatTransport.js.map +1 -0
- package/dist/transport/resumable.d.ts +38 -0
- package/dist/transport/resumable.d.ts.map +1 -0
- package/dist/transport/resumable.js +113 -0
- package/dist/transport/resumable.js.map +1 -0
- package/dist/usage.d.ts +19 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +127 -0
- package/dist/usage.js.map +1 -0
- package/dist/utils/getVercelAIMessages.d.ts +7 -0
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
- package/dist/utils/getVercelAIMessages.js +9 -0
- package/dist/utils/getVercelAIMessages.js.map +1 -0
- package/dist/utils/sliceMessagesUntil.d.ts +6 -0
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
- package/dist/utils/sliceMessagesUntil.js +12 -0
- package/dist/utils/sliceMessagesUntil.js.map +1 -0
- package/package.json +100 -0
- package/src/__tests__/standalone.e2e.ts +59 -0
- package/src/adapters/aiSDKFormatAdapter.ts +41 -0
- package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
- package/src/adapters/vercelAttachmentAdapter.ts +36 -0
- package/src/aiSDKExtras.ts +11 -0
- package/src/converters/convertMessage.test.ts +1130 -0
- package/src/converters/convertMessage.ts +457 -0
- package/src/converters/modelContentEnvelope.ts +41 -0
- package/src/converters/toCreateMessage.test.ts +596 -0
- package/src/converters/toCreateMessage.ts +99 -0
- package/src/converters/toolOutputConversion.ts +26 -0
- package/src/hooks.ts +23 -0
- package/src/index.native.ts +21 -0
- package/src/index.ts +29 -0
- package/src/model-context/injectInteractableContext.test.ts +167 -0
- package/src/model-context/injectInteractableContext.ts +61 -0
- package/src/model-context/injectQuoteContext.test.ts +33 -0
- package/src/model-context/injectQuoteContext.ts +62 -0
- package/src/runtime/AISDKChat.integration.test.tsx +47 -0
- package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
- package/src/runtime/AISDKChat.test.ts +193 -0
- package/src/runtime/AISDKChat.ts +47 -0
- package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
- package/src/runtime/AISDKThreads.test.ts +548 -0
- package/src/runtime/AISDKThreads.ts +223 -0
- package/src/runtime/__tests__/controlled-transport.ts +43 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
- package/src/runtime/useAISDKRuntime.test.ts +1021 -0
- package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
- package/src/runtime/useAISDKRuntime.ts +524 -0
- package/src/runtime/useChatOnTap.test.ts +58 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
- package/src/runtime/useChatRuntime.test.ts +522 -0
- package/src/runtime/useChatRuntime.ts +49 -0
- package/src/runtime/useChatThread.ts +286 -0
- package/src/runtime/useExternalHistory.test.ts +1289 -0
- package/src/runtime/useExternalHistory.ts +435 -0
- package/src/runtime/useResourceCleanup.ts +32 -0
- package/src/runtime/useStreamingTiming.ts +68 -0
- package/src/tools/frontendTools.test.ts +205 -0
- package/src/tools/frontendTools.ts +83 -0
- package/src/tools/generativeTools.test.ts +785 -0
- package/src/tools/generativeTools.ts +470 -0
- package/src/tools/mcp-stdio.node.ts +1 -0
- package/src/tools/mcp-stdio.unsupported.ts +12 -0
- package/src/transport/AssistantChatTransport.test.ts +300 -0
- package/src/transport/AssistantChatTransport.ts +204 -0
- package/src/transport/resumable.test.ts +262 -0
- package/src/transport/resumable.ts +161 -0
- package/src/usage.test.ts +186 -0
- package/src/usage.ts +174 -0
- package/src/utils/getVercelAIMessages.ts +11 -0
- package/src/utils/sliceMessagesUntil.test.ts +55 -0
- package/src/utils/sliceMessagesUntil.ts +20 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
|
|
5
|
+
import { useAuiState } from "@assistant-ui/store";
|
|
6
|
+
import type { UIMessage } from "ai";
|
|
7
|
+
import { StrictMode, useState } from "react";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
10
|
+
import { useChatRuntime } from "./useChatRuntime";
|
|
11
|
+
|
|
12
|
+
const messages: UIMessage[] = [
|
|
13
|
+
{
|
|
14
|
+
id: "initial-user-message",
|
|
15
|
+
role: "user",
|
|
16
|
+
parts: [{ type: "text", text: "Hello from the server" }],
|
|
17
|
+
},
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const MessageProbe = () => {
|
|
21
|
+
const count = useAuiState((state) => state.thread.messages.length);
|
|
22
|
+
const text = useAuiState(
|
|
23
|
+
(state) =>
|
|
24
|
+
state.thread.messages[0]?.parts
|
|
25
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
26
|
+
.join("") ?? "",
|
|
27
|
+
);
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<output data-testid="message-count">{count}</output>
|
|
31
|
+
<output data-testid="message-text">{text}</output>
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const TestApp = () => {
|
|
37
|
+
const [transport] = useState(
|
|
38
|
+
() => new AssistantChatTransport({ api: "/api/chat" }),
|
|
39
|
+
);
|
|
40
|
+
const runtime = useChatRuntime({
|
|
41
|
+
messages,
|
|
42
|
+
transport,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
47
|
+
<MessageProbe />
|
|
48
|
+
</AssistantRuntimeProvider>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("useChatRuntime integration", () => {
|
|
53
|
+
it("exposes seeded messages through the mounted thread scope", async () => {
|
|
54
|
+
render(
|
|
55
|
+
<StrictMode>
|
|
56
|
+
<TestApp />
|
|
57
|
+
</StrictMode>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await waitFor(() => {
|
|
61
|
+
expect(screen.getByTestId("message-count").textContent).toBe("1");
|
|
62
|
+
expect(screen.getByTestId("message-text").textContent).toBe(
|
|
63
|
+
"Hello from the server",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => {
|
|
7
|
+
const state = {
|
|
8
|
+
isLoadingHistory: false,
|
|
9
|
+
threadId: "thread-id",
|
|
10
|
+
mainThreadId: "thread-id",
|
|
11
|
+
};
|
|
12
|
+
const subscribers = new Set<() => void>();
|
|
13
|
+
const runtime = {
|
|
14
|
+
thread: {
|
|
15
|
+
getState: () => ({ isLoading: state.isLoadingHistory }),
|
|
16
|
+
getModelContext: () => ({}),
|
|
17
|
+
subscribe: (callback: () => void) => {
|
|
18
|
+
subscribers.add(callback);
|
|
19
|
+
return () => subscribers.delete(callback);
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
threads: {
|
|
23
|
+
mainItem: undefined,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
state,
|
|
28
|
+
subscribers,
|
|
29
|
+
runtime,
|
|
30
|
+
useChat: vi.fn(),
|
|
31
|
+
useAISDKRuntime: vi.fn(() => runtime),
|
|
32
|
+
useCloudThreadListAdapter: vi.fn(() => null),
|
|
33
|
+
useRemoteThreadListRuntime: vi.fn(
|
|
34
|
+
({ runtimeHook }: { runtimeHook: () => unknown }) => runtimeHook(),
|
|
35
|
+
),
|
|
36
|
+
useAui: vi.fn(() => ({
|
|
37
|
+
threadListItem: Object.assign(() => ({}), { source: undefined }),
|
|
38
|
+
})),
|
|
39
|
+
useAuiState: vi.fn(
|
|
40
|
+
(
|
|
41
|
+
selector: (state: {
|
|
42
|
+
threadListItem: { id: string };
|
|
43
|
+
threads: { mainThreadId: string };
|
|
44
|
+
}) => unknown,
|
|
45
|
+
) =>
|
|
46
|
+
selector({
|
|
47
|
+
threadListItem: { id: state.threadId },
|
|
48
|
+
threads: { mainThreadId: state.mainThreadId },
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
vi.mock("@ai-sdk/react", () => ({
|
|
55
|
+
useChat: mocks.useChat,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
|
|
59
|
+
...(await importOriginal<typeof import("@assistant-ui/core/react")>()),
|
|
60
|
+
useCloudThreadListAdapter: mocks.useCloudThreadListAdapter,
|
|
61
|
+
useRemoteThreadListRuntime: mocks.useRemoteThreadListRuntime,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => ({
|
|
65
|
+
...(await importOriginal<typeof import("@assistant-ui/store")>()),
|
|
66
|
+
useAui: mocks.useAui,
|
|
67
|
+
useAuiState: mocks.useAuiState,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock("./useAISDKRuntime", () => ({
|
|
71
|
+
useAISDKRuntime: mocks.useAISDKRuntime,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
75
|
+
import {
|
|
76
|
+
createResumableSessionStorage,
|
|
77
|
+
RESUMABLE_STREAM_ID_HEADER,
|
|
78
|
+
} from "../transport/resumable";
|
|
79
|
+
import { useChatRuntime } from "./useChatRuntime";
|
|
80
|
+
|
|
81
|
+
const sendMessagesOptions = {
|
|
82
|
+
trigger: "submit-message" as const,
|
|
83
|
+
chatId: "thread-id",
|
|
84
|
+
messageId: undefined,
|
|
85
|
+
messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
|
|
86
|
+
abortSignal: undefined,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
describe("useChatRuntime", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.clearAllMocks();
|
|
92
|
+
mocks.state.isLoadingHistory = false;
|
|
93
|
+
mocks.state.threadId = "thread-id";
|
|
94
|
+
mocks.state.mainThreadId = "thread-id";
|
|
95
|
+
mocks.subscribers.clear();
|
|
96
|
+
window.sessionStorage.clear();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("waits for external history to load before resuming a stream", async () => {
|
|
100
|
+
mocks.state.isLoadingHistory = true;
|
|
101
|
+
const resumeStream = vi.fn().mockResolvedValue(undefined);
|
|
102
|
+
mocks.useChat.mockReturnValue({ resumeStream });
|
|
103
|
+
|
|
104
|
+
const transport = {
|
|
105
|
+
getResumableAdapter: () => ({
|
|
106
|
+
storage: {
|
|
107
|
+
getStreamId: () => "stream-1",
|
|
108
|
+
setStreamId: vi.fn(),
|
|
109
|
+
clear: vi.fn(),
|
|
110
|
+
},
|
|
111
|
+
resumeApi: "/api/chat/resume",
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
renderHook(() => useChatRuntime({ transport: transport as never }));
|
|
116
|
+
|
|
117
|
+
expect(resumeStream).not.toHaveBeenCalled();
|
|
118
|
+
|
|
119
|
+
act(() => {
|
|
120
|
+
mocks.state.isLoadingHistory = false;
|
|
121
|
+
mocks.subscribers.forEach((callback) => callback());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await waitFor(() => expect(resumeStream).toHaveBeenCalledTimes(1));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("resumes a stream id discovered after mount", async () => {
|
|
128
|
+
let streamId: string | null = null;
|
|
129
|
+
const storageSubscribers = new Set<() => void>();
|
|
130
|
+
const resumeStream = vi.fn().mockResolvedValue(undefined);
|
|
131
|
+
mocks.useChat.mockReturnValue({ resumeStream });
|
|
132
|
+
|
|
133
|
+
const storage = {
|
|
134
|
+
getStreamId: () => streamId,
|
|
135
|
+
setStreamId: (id: string) => {
|
|
136
|
+
streamId = id;
|
|
137
|
+
storageSubscribers.forEach((callback) => callback());
|
|
138
|
+
},
|
|
139
|
+
clear: () => {
|
|
140
|
+
streamId = null;
|
|
141
|
+
storageSubscribers.forEach((callback) => callback());
|
|
142
|
+
},
|
|
143
|
+
subscribe: (callback: () => void) => {
|
|
144
|
+
storageSubscribers.add(callback);
|
|
145
|
+
return () => storageSubscribers.delete(callback);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const transport = {
|
|
149
|
+
getResumableAdapter: () => ({
|
|
150
|
+
storage,
|
|
151
|
+
resumeApi: "/api/chat/resume",
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
renderHook(() => useChatRuntime({ transport: transport as never }));
|
|
156
|
+
|
|
157
|
+
expect(resumeStream).not.toHaveBeenCalled();
|
|
158
|
+
|
|
159
|
+
act(() => storage.setStreamId("stream-1"));
|
|
160
|
+
|
|
161
|
+
await waitFor(() => expect(resumeStream).toHaveBeenCalledTimes(1));
|
|
162
|
+
|
|
163
|
+
act(() => storage.setStreamId("stream-1"));
|
|
164
|
+
|
|
165
|
+
expect(resumeStream).toHaveBeenCalledTimes(1);
|
|
166
|
+
|
|
167
|
+
act(() => storage.setStreamId("stream-2"));
|
|
168
|
+
|
|
169
|
+
await waitFor(() => expect(resumeStream).toHaveBeenCalledTimes(2));
|
|
170
|
+
|
|
171
|
+
act(() => storage.setStreamId("stream-1"));
|
|
172
|
+
|
|
173
|
+
expect(resumeStream).toHaveBeenCalledTimes(2);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not resume the same stored id again after a remount", async () => {
|
|
177
|
+
const storage = createResumableSessionStorage({
|
|
178
|
+
key: "remount-stream-id",
|
|
179
|
+
});
|
|
180
|
+
storage.setStreamId("stream-1", "thread-id");
|
|
181
|
+
const resumeStream = vi.fn().mockResolvedValue(undefined);
|
|
182
|
+
mocks.useChat.mockReturnValue({
|
|
183
|
+
resumeStream,
|
|
184
|
+
status: "ready",
|
|
185
|
+
});
|
|
186
|
+
const transport = {
|
|
187
|
+
getResumableAdapter: () => ({
|
|
188
|
+
storage,
|
|
189
|
+
resumeApi: "/api/chat/resume",
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const firstRuntime = renderHook(() =>
|
|
194
|
+
useChatRuntime({ transport: transport as never }),
|
|
195
|
+
);
|
|
196
|
+
await waitFor(() => expect(resumeStream).toHaveBeenCalledOnce());
|
|
197
|
+
|
|
198
|
+
firstRuntime.unmount();
|
|
199
|
+
renderHook(() => useChatRuntime({ transport: transport as never }));
|
|
200
|
+
|
|
201
|
+
expect(resumeStream).toHaveBeenCalledOnce();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not resume a stream id written by an active send", async () => {
|
|
205
|
+
const storage = createResumableSessionStorage({
|
|
206
|
+
key: "active-send-stream-id",
|
|
207
|
+
});
|
|
208
|
+
const fetchMock = vi.fn(async () => {
|
|
209
|
+
return new Response(
|
|
210
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
211
|
+
{
|
|
212
|
+
status: 200,
|
|
213
|
+
headers: {
|
|
214
|
+
"content-type": "text/event-stream",
|
|
215
|
+
[RESUMABLE_STREAM_ID_HEADER]: "stream-1",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
const transport = new AssistantChatTransport({
|
|
221
|
+
fetch: fetchMock as never,
|
|
222
|
+
resumable: {
|
|
223
|
+
storage,
|
|
224
|
+
resumeApi: "/api/chat/resume",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const resumeStream = vi.fn(async () => {
|
|
228
|
+
await transport.reconnectToStream({ chatId: "thread-id" });
|
|
229
|
+
});
|
|
230
|
+
const chatState = {
|
|
231
|
+
resumeStream,
|
|
232
|
+
status: "streaming",
|
|
233
|
+
};
|
|
234
|
+
mocks.useChat.mockImplementation(() => chatState);
|
|
235
|
+
|
|
236
|
+
const { rerender } = renderHook(() => useChatRuntime({ transport }));
|
|
237
|
+
|
|
238
|
+
await act(async () => {
|
|
239
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await waitFor(() =>
|
|
243
|
+
expect(storage.getStreamId("thread-id")).toBe("stream-1"),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
chatState.status = "ready";
|
|
247
|
+
rerender();
|
|
248
|
+
|
|
249
|
+
expect(resumeStream).not.toHaveBeenCalled();
|
|
250
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("does not broadcast a stream id to another mounted thread", async () => {
|
|
254
|
+
const storage = createResumableSessionStorage({
|
|
255
|
+
key: "multi-thread-stream-id",
|
|
256
|
+
});
|
|
257
|
+
const transport = {
|
|
258
|
+
getResumableAdapter: () => ({
|
|
259
|
+
storage,
|
|
260
|
+
resumeApi: "/api/chat/resume",
|
|
261
|
+
}),
|
|
262
|
+
};
|
|
263
|
+
const threadA = {
|
|
264
|
+
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
265
|
+
status: "ready",
|
|
266
|
+
};
|
|
267
|
+
const threadB = {
|
|
268
|
+
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
269
|
+
status: "streaming",
|
|
270
|
+
};
|
|
271
|
+
mocks.useChat.mockImplementation(({ id }: { id: string }) =>
|
|
272
|
+
id === "thread-a" ? threadA : threadB,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
mocks.state.threadId = "thread-a";
|
|
276
|
+
mocks.state.mainThreadId = "thread-a";
|
|
277
|
+
renderHook(() => useChatRuntime({ transport: transport as never }));
|
|
278
|
+
|
|
279
|
+
mocks.state.threadId = "thread-b";
|
|
280
|
+
mocks.state.mainThreadId = "thread-b";
|
|
281
|
+
const threadBHook = renderHook(() =>
|
|
282
|
+
useChatRuntime({ transport: transport as never }),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
act(() => storage.setStreamId("stream-b", "thread-b"));
|
|
286
|
+
|
|
287
|
+
threadB.status = "ready";
|
|
288
|
+
threadBHook.rerender();
|
|
289
|
+
|
|
290
|
+
expect(threadA.resumeStream).not.toHaveBeenCalled();
|
|
291
|
+
expect(threadB.resumeStream).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("resumes a persisted stream after reload changes the local thread id", async () => {
|
|
295
|
+
const storageKey = "reload-stream-id";
|
|
296
|
+
createResumableSessionStorage({ key: storageKey }).setStreamId(
|
|
297
|
+
"stream-a",
|
|
298
|
+
"__LOCALID_before_reload",
|
|
299
|
+
);
|
|
300
|
+
const reloadedStorage = createResumableSessionStorage({ key: storageKey });
|
|
301
|
+
const backgroundThread = {
|
|
302
|
+
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
303
|
+
status: "ready",
|
|
304
|
+
};
|
|
305
|
+
const mainThread = {
|
|
306
|
+
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
307
|
+
status: "ready",
|
|
308
|
+
};
|
|
309
|
+
mocks.useChat.mockImplementation(({ id }: { id: string }) =>
|
|
310
|
+
id === "__LOCALID_background" ? backgroundThread : mainThread,
|
|
311
|
+
);
|
|
312
|
+
const transport = {
|
|
313
|
+
getResumableAdapter: () => ({
|
|
314
|
+
storage: reloadedStorage,
|
|
315
|
+
resumeApi: "/api/chat/resume",
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
mocks.state.threadId = "__LOCALID_background";
|
|
320
|
+
mocks.state.mainThreadId = "__LOCALID_after_reload";
|
|
321
|
+
renderHook(() => useChatRuntime({ transport: transport as never }));
|
|
322
|
+
|
|
323
|
+
mocks.state.threadId = "__LOCALID_after_reload";
|
|
324
|
+
renderHook(() => useChatRuntime({ transport: transport as never }));
|
|
325
|
+
|
|
326
|
+
expect(backgroundThread.resumeStream).not.toHaveBeenCalled();
|
|
327
|
+
await waitFor(() => expect(mainThread.resumeStream).toHaveBeenCalledOnce());
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("observes resumable storage from a replacement transport", async () => {
|
|
331
|
+
const resumeStream = vi.fn().mockResolvedValue(undefined);
|
|
332
|
+
mocks.useChat.mockReturnValue({
|
|
333
|
+
resumeStream,
|
|
334
|
+
status: "ready",
|
|
335
|
+
});
|
|
336
|
+
const storageA = {
|
|
337
|
+
getStreamId: (): string | null => null,
|
|
338
|
+
setStreamId: vi.fn(),
|
|
339
|
+
clear: vi.fn(),
|
|
340
|
+
};
|
|
341
|
+
const storageB = {
|
|
342
|
+
getStreamId: (): string | null => "stream-b",
|
|
343
|
+
setStreamId: vi.fn(),
|
|
344
|
+
clear: vi.fn(),
|
|
345
|
+
};
|
|
346
|
+
const transportA = {
|
|
347
|
+
getResumableAdapter: () => ({
|
|
348
|
+
storage: storageA,
|
|
349
|
+
resumeApi: "/api/chat/resume",
|
|
350
|
+
}),
|
|
351
|
+
};
|
|
352
|
+
const transportB = {
|
|
353
|
+
getResumableAdapter: () => ({
|
|
354
|
+
storage: storageB,
|
|
355
|
+
resumeApi: "/api/chat/resume",
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const { rerender } = renderHook(
|
|
360
|
+
({ transport }) => useChatRuntime({ transport: transport as never }),
|
|
361
|
+
{ initialProps: { transport: transportA } },
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
expect(resumeStream).not.toHaveBeenCalled();
|
|
365
|
+
|
|
366
|
+
rerender({ transport: transportB });
|
|
367
|
+
|
|
368
|
+
await waitFor(() => expect(resumeStream).toHaveBeenCalledTimes(1));
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("does not clear a newer stream id when an older resume fails", async () => {
|
|
372
|
+
let streamId: string | null = "stream-1";
|
|
373
|
+
const storageSubscribers = new Set<() => void>();
|
|
374
|
+
const clear = vi.fn(() => {
|
|
375
|
+
streamId = null;
|
|
376
|
+
storageSubscribers.forEach((callback) => callback());
|
|
377
|
+
});
|
|
378
|
+
const storage = {
|
|
379
|
+
getStreamId: () => streamId,
|
|
380
|
+
setStreamId: (id: string) => {
|
|
381
|
+
streamId = id;
|
|
382
|
+
storageSubscribers.forEach((callback) => callback());
|
|
383
|
+
},
|
|
384
|
+
clear,
|
|
385
|
+
subscribe: (callback: () => void) => {
|
|
386
|
+
storageSubscribers.add(callback);
|
|
387
|
+
return () => storageSubscribers.delete(callback);
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
const error = new Error("resume failed");
|
|
391
|
+
let rejectFirstResume: ((error: Error) => void) | undefined;
|
|
392
|
+
const resumeStream = vi
|
|
393
|
+
.fn()
|
|
394
|
+
.mockImplementationOnce(
|
|
395
|
+
() =>
|
|
396
|
+
new Promise<void>((_resolve, reject) => {
|
|
397
|
+
rejectFirstResume = reject;
|
|
398
|
+
}),
|
|
399
|
+
)
|
|
400
|
+
.mockResolvedValueOnce(undefined);
|
|
401
|
+
const onResumeError = vi.fn();
|
|
402
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
403
|
+
mocks.useChat.mockReturnValue({
|
|
404
|
+
resumeStream,
|
|
405
|
+
status: "ready",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
renderHook(() =>
|
|
409
|
+
useChatRuntime({
|
|
410
|
+
transport: {
|
|
411
|
+
getResumableAdapter: () => ({
|
|
412
|
+
storage,
|
|
413
|
+
resumeApi: "/api/chat/resume",
|
|
414
|
+
}),
|
|
415
|
+
} as never,
|
|
416
|
+
onResumeError,
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
await waitFor(() => expect(resumeStream).toHaveBeenCalledTimes(1));
|
|
421
|
+
|
|
422
|
+
act(() => storage.setStreamId("stream-2"));
|
|
423
|
+
|
|
424
|
+
await waitFor(() => expect(resumeStream).toHaveBeenCalledTimes(2));
|
|
425
|
+
|
|
426
|
+
act(() => rejectFirstResume?.(error));
|
|
427
|
+
|
|
428
|
+
await waitFor(() => expect(onResumeError).toHaveBeenCalledWith(error));
|
|
429
|
+
expect(storage.getStreamId()).toBe("stream-2");
|
|
430
|
+
expect(clear).not.toHaveBeenCalled();
|
|
431
|
+
warn.mockRestore();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("calls onResumeError when automatic resumable stream resume fails", async () => {
|
|
435
|
+
const error = new Error("resume failed");
|
|
436
|
+
const resumeStream = vi.fn().mockRejectedValue(error);
|
|
437
|
+
const clear = vi.fn();
|
|
438
|
+
const onResumeError = vi.fn();
|
|
439
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
440
|
+
mocks.useChat.mockReturnValue({
|
|
441
|
+
resumeStream,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const transport = {
|
|
445
|
+
getResumableAdapter: () => ({
|
|
446
|
+
storage: {
|
|
447
|
+
getStreamId: () => "stream-1",
|
|
448
|
+
setStreamId: vi.fn(),
|
|
449
|
+
clear,
|
|
450
|
+
},
|
|
451
|
+
resumeApi: "/api/chat/resume",
|
|
452
|
+
}),
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
renderHook(() =>
|
|
456
|
+
useChatRuntime({
|
|
457
|
+
transport: transport as never,
|
|
458
|
+
onResumeError,
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
await waitFor(() => {
|
|
463
|
+
expect(onResumeError).toHaveBeenCalledWith(error);
|
|
464
|
+
});
|
|
465
|
+
expect(resumeStream).toHaveBeenCalledTimes(1);
|
|
466
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
467
|
+
expect(onResumeError.mock.invocationCallOrder[0]).toBeLessThan(
|
|
468
|
+
clear.mock.invocationCallOrder[0]!,
|
|
469
|
+
);
|
|
470
|
+
expect(warn).toHaveBeenCalledWith(
|
|
471
|
+
"[assistant-ui] resumable: resume failed",
|
|
472
|
+
error,
|
|
473
|
+
);
|
|
474
|
+
warn.mockRestore();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("clears resumable stream storage when onResumeError throws", async () => {
|
|
478
|
+
const error = new Error("resume failed");
|
|
479
|
+
const callbackError = new Error("callback failed");
|
|
480
|
+
const resumeStream = vi.fn().mockRejectedValue(error);
|
|
481
|
+
const clear = vi.fn();
|
|
482
|
+
const onResumeError = vi.fn(() => {
|
|
483
|
+
throw callbackError;
|
|
484
|
+
});
|
|
485
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
486
|
+
const consoleError = vi
|
|
487
|
+
.spyOn(console, "error")
|
|
488
|
+
.mockImplementation(() => {});
|
|
489
|
+
mocks.useChat.mockReturnValue({
|
|
490
|
+
resumeStream,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const transport = {
|
|
494
|
+
getResumableAdapter: () => ({
|
|
495
|
+
storage: {
|
|
496
|
+
getStreamId: () => "stream-1",
|
|
497
|
+
setStreamId: vi.fn(),
|
|
498
|
+
clear,
|
|
499
|
+
},
|
|
500
|
+
resumeApi: "/api/chat/resume",
|
|
501
|
+
}),
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
renderHook(() =>
|
|
505
|
+
useChatRuntime({
|
|
506
|
+
transport: transport as never,
|
|
507
|
+
onResumeError,
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
await waitFor(() => {
|
|
512
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
513
|
+
});
|
|
514
|
+
expect(onResumeError).toHaveBeenCalledWith(error);
|
|
515
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
516
|
+
"[assistant-ui] resumable: onResumeError callback failed",
|
|
517
|
+
callbackError,
|
|
518
|
+
);
|
|
519
|
+
warn.mockRestore();
|
|
520
|
+
consoleError.mockRestore();
|
|
521
|
+
});
|
|
522
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { UIMessage } from "@ai-sdk/react";
|
|
4
|
+
import type { AssistantCloud } from "assistant-cloud";
|
|
5
|
+
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
6
|
+
import {
|
|
7
|
+
useCloudThreadListAdapter,
|
|
8
|
+
useRemoteThreadListRuntime,
|
|
9
|
+
} from "@assistant-ui/core/react";
|
|
10
|
+
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
11
|
+
import { useChatThread, type ChatThreadOptions } from "./useChatThread";
|
|
12
|
+
|
|
13
|
+
export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
14
|
+
ChatThreadOptions<UI_MESSAGE> & {
|
|
15
|
+
cloud?: AssistantCloud | undefined;
|
|
16
|
+
onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
20
|
+
options?: ChatThreadOptions<UI_MESSAGE>,
|
|
21
|
+
): AssistantRuntime => {
|
|
22
|
+
const id = useAuiState((s) => s.threadListItem.id);
|
|
23
|
+
const isMainThread = useAuiState(
|
|
24
|
+
(s) => s.threads.mainThreadId === s.threadListItem.id,
|
|
25
|
+
);
|
|
26
|
+
const aui = useAui();
|
|
27
|
+
return useChatThread(options, {
|
|
28
|
+
id,
|
|
29
|
+
isMainThread,
|
|
30
|
+
getThreadListItem: () =>
|
|
31
|
+
aui.threadListItem.source ? aui.threadListItem : undefined,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const useChatRuntime = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
36
|
+
cloud,
|
|
37
|
+
onThreadIdChange,
|
|
38
|
+
...options
|
|
39
|
+
}: UseChatRuntimeOptions<UI_MESSAGE> = {}): AssistantRuntime => {
|
|
40
|
+
const cloudAdapter = useCloudThreadListAdapter({ cloud });
|
|
41
|
+
return useRemoteThreadListRuntime({
|
|
42
|
+
runtimeHook: function RuntimeHook() {
|
|
43
|
+
return useChatThreadRuntime(options);
|
|
44
|
+
},
|
|
45
|
+
adapter: cloudAdapter,
|
|
46
|
+
allowNesting: true,
|
|
47
|
+
onThreadIdChange,
|
|
48
|
+
});
|
|
49
|
+
};
|