@assistant-ui/core 0.3.14 → 0.3.15
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/dist/adapters/voice.js +11 -0
- package/dist/adapters/voice.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.js +2 -1
- package/dist/model-context/frame/host.d.ts +1 -1
- package/dist/model-context/frame/host.d.ts.map +1 -1
- package/dist/model-context/frame/host.js +17 -3
- package/dist/model-context/frame/host.js.map +1 -1
- package/dist/model-context/frame/provider.d.ts +4 -0
- package/dist/model-context/frame/provider.d.ts.map +1 -1
- package/dist/model-context/frame/provider.js +50 -3
- package/dist/model-context/frame/provider.js.map +1 -1
- package/dist/model-context/frame/types.d.ts +4 -1
- package/dist/model-context/frame/types.d.ts.map +1 -1
- package/dist/model-context/frame/types.js.map +1 -1
- package/dist/react/client/RemoteThreadList.d.ts +1 -1
- package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
- package/dist/react/client/RemoteThreadList.js +95 -23
- package/dist/react/client/RemoteThreadList.js.map +1 -1
- package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
- package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
- package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
- package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
- package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
- package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
- package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadResource.js +2 -0
- package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
- package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
- package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
- package/dist/runtime/utils/message-repository.d.ts.map +1 -1
- package/dist/runtime/utils/message-repository.js +27 -18
- package/dist/runtime/utils/message-repository.js.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
- package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
- package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
- package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
- package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
- package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
- package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
- package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
- package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
- package/dist/store/clients/external-thread.js +1 -1
- package/dist/store/clients/external-thread.js.map +1 -1
- package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
- package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
- package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
- package/dist/types/message.d.ts +1 -1
- package/dist/types/message.js.map +1 -1
- package/dist/utils/silent-runtime-action.d.ts +6 -0
- package/dist/utils/silent-runtime-action.d.ts.map +1 -0
- package/dist/utils/silent-runtime-action.js +7 -0
- package/dist/utils/silent-runtime-action.js.map +1 -0
- package/package.json +4 -4
- package/src/adapters/voice.test.ts +66 -1
- package/src/adapters/voice.ts +8 -0
- package/src/internal.ts +1 -0
- package/src/model-context/frame/host.test.ts +64 -5
- package/src/model-context/frame/host.ts +16 -3
- package/src/model-context/frame/provider.test.ts +347 -20
- package/src/model-context/frame/provider.ts +58 -2
- package/src/model-context/frame/types.ts +5 -0
- package/src/react/client/RemoteThreadList.test.ts +196 -0
- package/src/react/client/RemoteThreadList.ts +93 -24
- package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
- package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
- package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
- package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
- package/src/react/runtimes/RemoteThreadResource.ts +2 -0
- package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
- package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
- package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
- package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
- package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
- package/src/runtime/utils/message-repository.ts +26 -18
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
- package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
- package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
- package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
- package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
- package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
- package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
- package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
- package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
- package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
- package/src/runtimes/remote-thread-list/types.ts +4 -0
- package/src/store/clients/external-thread.ts +1 -1
- package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
- package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
- package/src/tests/MessageRepository.test.ts +48 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
- package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
- package/src/tests/empty-thread-core.test.ts +6 -0
- package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
- package/src/tests/external-thread-isEmpty.test.tsx +100 -0
- package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
- package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
- package/src/types/message.ts +1 -1
- package/src/utils/silent-runtime-action.ts +6 -0
|
@@ -180,6 +180,24 @@ describe("MessageRepository", () => {
|
|
|
180
180
|
});
|
|
181
181
|
|
|
182
182
|
describe("Branch management", () => {
|
|
183
|
+
const createLongBranchMessages = (
|
|
184
|
+
messageCount: number,
|
|
185
|
+
): Array<{ message: ThreadMessage; parentId: string | null }> => {
|
|
186
|
+
const messages: Array<{
|
|
187
|
+
message: ThreadMessage;
|
|
188
|
+
parentId: string | null;
|
|
189
|
+
}> = [{ message: createTestMessage({ id: "root" }), parentId: null }];
|
|
190
|
+
|
|
191
|
+
for (let index = 0; index < messageCount; index++) {
|
|
192
|
+
messages.push({
|
|
193
|
+
message: createTestMessage({ id: `branch-${index}` }),
|
|
194
|
+
parentId: index === 0 ? "root" : `branch-${index - 1}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return messages;
|
|
199
|
+
};
|
|
200
|
+
|
|
183
201
|
it("should create multiple branches from a parent message", () => {
|
|
184
202
|
const parent = createTestMessage({ id: "parent-id" });
|
|
185
203
|
const branch1 = createTestMessage({ id: "branch1-id" });
|
|
@@ -222,6 +240,36 @@ describe("MessageRepository", () => {
|
|
|
222
240
|
expect(messages2.map((m) => m.id)).toEqual(["parent-id", "branch2-id"]);
|
|
223
241
|
});
|
|
224
242
|
|
|
243
|
+
it("should operate on a long message history without overflowing the stack", () => {
|
|
244
|
+
const messageCount = 30_000;
|
|
245
|
+
const messages = createLongBranchMessages(messageCount);
|
|
246
|
+
|
|
247
|
+
messages.push({
|
|
248
|
+
message: createTestMessage({ id: "alternate" }),
|
|
249
|
+
parentId: "root",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
repository.import({ headId: "alternate", messages });
|
|
253
|
+
repository.switchToBranch("branch-0");
|
|
254
|
+
|
|
255
|
+
expect(repository.headId).toBe(`branch-${messageCount - 1}`);
|
|
256
|
+
|
|
257
|
+
repository.deleteMessage("branch-0", "root");
|
|
258
|
+
|
|
259
|
+
expect(repository.getMessage("branch-1")).toMatchObject({
|
|
260
|
+
parentId: "root",
|
|
261
|
+
index: 1,
|
|
262
|
+
});
|
|
263
|
+
expect(repository.headId).toBe(`branch-${messageCount - 1}`);
|
|
264
|
+
|
|
265
|
+
repository.resetHead("root");
|
|
266
|
+
|
|
267
|
+
expect(repository.getMessages().map((message) => message.id)).toEqual([
|
|
268
|
+
"root",
|
|
269
|
+
]);
|
|
270
|
+
expect(() => repository.getMessage("branch-1")).toThrow();
|
|
271
|
+
}, 20_000);
|
|
272
|
+
|
|
225
273
|
it("should throw error when switching to a non-existent branch", () => {
|
|
226
274
|
expect(() => {
|
|
227
275
|
repository.switchToBranch("non-existent-id");
|
|
@@ -3,10 +3,10 @@ import { createCore, makeAdapter } from "./remote-thread-list-test-helpers";
|
|
|
3
3
|
import { InMemoryThreadListAdapter } from "../runtimes/remote-thread-list/adapter/in-memory";
|
|
4
4
|
|
|
5
5
|
describe("RemoteThreadListThreadListRuntimeCore errors", () => {
|
|
6
|
-
it("includes the requested thread id when a thread is missing", () => {
|
|
6
|
+
it("includes the requested thread id when a thread is missing", async () => {
|
|
7
7
|
const core = createCore(makeAdapter());
|
|
8
8
|
|
|
9
|
-
expect(
|
|
9
|
+
await expect(core.rename("missing-thread", "New title")).rejects.toThrow(
|
|
10
10
|
'Thread "missing-thread" not found while renaming it.',
|
|
11
11
|
);
|
|
12
12
|
});
|
|
@@ -268,6 +268,7 @@ describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
|
|
|
268
268
|
await core.getLoadThreadsPromise();
|
|
269
269
|
expect(secondList).toHaveBeenCalledTimes(1);
|
|
270
270
|
expect(core.threadIds).toEqual(["new"]);
|
|
271
|
+
expect(core.getItemById("old")).toBeUndefined();
|
|
271
272
|
});
|
|
272
273
|
|
|
273
274
|
it("ignores an in-flight loadMore response when the adapter swaps mid-flight", async () => {
|
|
@@ -289,23 +290,24 @@ describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
|
|
|
289
290
|
await core.getLoadThreadsPromise();
|
|
290
291
|
const stale = core.loadMore();
|
|
291
292
|
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
});
|
|
293
|
+
const secondList = vi.fn<ListFn>(async () => ({
|
|
294
|
+
threads: [{ status: "regular", remoteId: "fresh", externalId: "fresh" }],
|
|
295
|
+
}));
|
|
296
|
+
const secondAdapter = makeAdapter({ list: secondList });
|
|
297
297
|
core.__internal_setOptions({
|
|
298
298
|
adapter: secondAdapter,
|
|
299
299
|
runtimeHook: () => ({}) as never,
|
|
300
300
|
});
|
|
301
|
+
const replacementLoad = core.getLoadThreadsPromise();
|
|
301
302
|
|
|
302
303
|
slow.resolve({
|
|
303
304
|
threads: [{ status: "regular", remoteId: "stale", externalId: "stale" }],
|
|
304
305
|
nextCursor: "stale-cursor",
|
|
305
306
|
});
|
|
306
307
|
await stale;
|
|
308
|
+
await replacementLoad;
|
|
307
309
|
|
|
308
|
-
expect(core.threadIds).toEqual(["
|
|
310
|
+
expect(core.threadIds).toEqual(["fresh"]);
|
|
309
311
|
expect(core.hasMore).toBe(false);
|
|
310
312
|
expect(core.isLoadingMore).toBe(false);
|
|
311
313
|
});
|
|
@@ -2,6 +2,12 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import { EMPTY_THREAD_CORE } from "../runtimes/remote-thread-list/empty-thread-core";
|
|
3
3
|
|
|
4
4
|
describe("EMPTY_THREAD_CORE", () => {
|
|
5
|
+
it("reads no messages, branches, or exported repository", () => {
|
|
6
|
+
expect(EMPTY_THREAD_CORE.getMessageById("a")).toBeUndefined();
|
|
7
|
+
expect(EMPTY_THREAD_CORE.getBranches("a")).toEqual([]);
|
|
8
|
+
expect(EMPTY_THREAD_CORE.export()).toEqual({ messages: [] });
|
|
9
|
+
});
|
|
10
|
+
|
|
5
11
|
it("has isLoading=true so it is not mistaken for an empty conversation", () => {
|
|
6
12
|
expect(EMPTY_THREAD_CORE.isLoading).toBe(true);
|
|
7
13
|
});
|
|
@@ -8,6 +8,7 @@ import type { ModelContextProvider } from "../model-context/types";
|
|
|
8
8
|
import type { AppendMessage, ThreadMessage } from "../types/message";
|
|
9
9
|
import { createMessageQueue } from "../runtime/queue/message-queue";
|
|
10
10
|
import { getThreadMessageText } from "../utils/text";
|
|
11
|
+
import { invalidateThreadRuntime } from "../runtime/utils/thread-runtime-lifecycle";
|
|
11
12
|
|
|
12
13
|
const createContextProvider = (): ModelContextProvider => ({
|
|
13
14
|
getModelContext: () => ({}),
|
|
@@ -304,6 +305,25 @@ describe("ExternalStoreThreadRuntimeCore adapter contract", () => {
|
|
|
304
305
|
expect(texts).not.toContain("partial answer");
|
|
305
306
|
});
|
|
306
307
|
|
|
308
|
+
it("does not resync after the runtime is invalidated", async () => {
|
|
309
|
+
const setMessages = vi.fn();
|
|
310
|
+
const core = new ExternalStoreThreadRuntimeCore(
|
|
311
|
+
contextProvider,
|
|
312
|
+
createBaseAdapter({
|
|
313
|
+
messages: [createAssistantMessage("a1", "partial answer")],
|
|
314
|
+
isRunning: true,
|
|
315
|
+
onCancel: vi.fn(),
|
|
316
|
+
setMessages,
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
core.cancelRun();
|
|
321
|
+
invalidateThreadRuntime(core);
|
|
322
|
+
|
|
323
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
324
|
+
expect(setMessages).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
|
|
307
327
|
it("re-applies the user leaf rollback when the store updates before the flush", async () => {
|
|
308
328
|
const messages = [createUserMessage("u1", "cancel me")];
|
|
309
329
|
const setMessages = vi.fn();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
import type { FC } from "react";
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { AuiIf, useAui, AuiProvider } from "@assistant-ui/store";
|
|
7
|
+
import {
|
|
8
|
+
ExternalThread,
|
|
9
|
+
type ExternalThreadProps,
|
|
10
|
+
} from "../store/clients/external-thread";
|
|
11
|
+
import { useThreadIsEmpty } from "../react/primitive-hooks/useThreadIsEmpty";
|
|
12
|
+
|
|
13
|
+
const HookProbe: FC = () => (
|
|
14
|
+
<div data-testid="hook">{useThreadIsEmpty() ? "empty" : "not-empty"}</div>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const renderThread = (props: ExternalThreadProps) => {
|
|
18
|
+
const captured: { aui?: ReturnType<typeof useAui> } = {};
|
|
19
|
+
const Capture: FC = () => {
|
|
20
|
+
captured.aui = useAui();
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
23
|
+
const App: FC<{ threadProps: ExternalThreadProps }> = ({ threadProps }) => {
|
|
24
|
+
const aui = useAui({ thread: ExternalThread(threadProps) });
|
|
25
|
+
return (
|
|
26
|
+
<AuiProvider value={aui}>
|
|
27
|
+
<Capture />
|
|
28
|
+
<HookProbe />
|
|
29
|
+
<AuiIf condition={(s) => s.thread.isEmpty}>
|
|
30
|
+
<div data-testid="aui-if">empty</div>
|
|
31
|
+
</AuiIf>
|
|
32
|
+
</AuiProvider>
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
const utils = render(<App threadProps={props} />);
|
|
36
|
+
return {
|
|
37
|
+
state: () => captured.aui!.thread.getState(),
|
|
38
|
+
hookSaysEmpty: () => utils.getByTestId("hook").textContent === "empty",
|
|
39
|
+
auiIfRendered: () => utils.queryByTestId("aui-if") !== null,
|
|
40
|
+
rerender: (next: ExternalThreadProps) =>
|
|
41
|
+
utils.rerender(<App threadProps={next} />),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const userMessage = (id: string) =>
|
|
46
|
+
({
|
|
47
|
+
id,
|
|
48
|
+
role: "user",
|
|
49
|
+
content: [{ type: "text", text: "hi" }],
|
|
50
|
+
createdAt: new Date(0),
|
|
51
|
+
metadata: { custom: {} },
|
|
52
|
+
}) as unknown as ExternalThreadProps["messages"][number];
|
|
53
|
+
|
|
54
|
+
describe("ExternalThread isEmpty", () => {
|
|
55
|
+
it("is empty when there are no messages and nothing is loading", () => {
|
|
56
|
+
const t = renderThread({ messages: [] });
|
|
57
|
+
expect(t.state().isEmpty).toBe(true);
|
|
58
|
+
expect(t.hookSaysEmpty()).toBe(true);
|
|
59
|
+
expect(t.auiIfRendered()).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("is not empty while the adapter is still loading", () => {
|
|
63
|
+
const t = renderThread({ messages: [], isLoading: true });
|
|
64
|
+
expect(t.state().isEmpty).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not render empty-state consumers while loading", () => {
|
|
68
|
+
const t = renderThread({ messages: [], isLoading: true });
|
|
69
|
+
expect(t.hookSaysEmpty()).toBe(false);
|
|
70
|
+
expect(t.auiIfRendered()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("is not empty once messages arrive", () => {
|
|
74
|
+
const t = renderThread({ messages: [userMessage("m1")] });
|
|
75
|
+
expect(t.state().isEmpty).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("becomes empty when loading finishes with no messages", () => {
|
|
79
|
+
const t = renderThread({ messages: [], isLoading: true });
|
|
80
|
+
expect(t.state().isEmpty).toBe(false);
|
|
81
|
+
expect(t.auiIfRendered()).toBe(false);
|
|
82
|
+
|
|
83
|
+
t.rerender({ messages: [], isLoading: false });
|
|
84
|
+
|
|
85
|
+
expect(t.state().isEmpty).toBe(true);
|
|
86
|
+
expect(t.hookSaysEmpty()).toBe(true);
|
|
87
|
+
expect(t.auiIfRendered()).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("stays non-empty when loading finishes with messages", () => {
|
|
91
|
+
const t = renderThread({ messages: [], isLoading: true });
|
|
92
|
+
expect(t.state().isEmpty).toBe(false);
|
|
93
|
+
|
|
94
|
+
t.rerender({ messages: [userMessage("m1")], isLoading: false });
|
|
95
|
+
|
|
96
|
+
expect(t.state().isEmpty).toBe(false);
|
|
97
|
+
expect(t.hookSaysEmpty()).toBe(false);
|
|
98
|
+
expect(t.auiIfRendered()).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createCore,
|
|
4
|
+
deferred,
|
|
5
|
+
makeAdapter,
|
|
6
|
+
setStartThreadRuntime,
|
|
7
|
+
} from "./remote-thread-list-test-helpers";
|
|
8
|
+
|
|
9
|
+
const thread = (remoteId: string) => ({
|
|
10
|
+
remoteId,
|
|
11
|
+
externalId: remoteId,
|
|
12
|
+
status: "regular" as const,
|
|
13
|
+
title: remoteId,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("RemoteThreadList adapter changes", () => {
|
|
17
|
+
it("clears the selected thread and cached data from the previous adapter", async () => {
|
|
18
|
+
const adapterA = makeAdapter({
|
|
19
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
20
|
+
});
|
|
21
|
+
const listAdapterB = vi.fn(async () => ({
|
|
22
|
+
threads: [thread("thread-b")],
|
|
23
|
+
}));
|
|
24
|
+
const adapterB = makeAdapter({ list: listAdapterB });
|
|
25
|
+
const core = createCore(adapterA);
|
|
26
|
+
const started: string[] = [];
|
|
27
|
+
setStartThreadRuntime(core, async (id) => {
|
|
28
|
+
started.push(id);
|
|
29
|
+
return {};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await core.getLoadThreadsPromise();
|
|
33
|
+
await core.switchToThread("thread-a");
|
|
34
|
+
expect(core.getItemById(core.mainThreadId)?.remoteId).toBe("thread-a");
|
|
35
|
+
|
|
36
|
+
core.__internal_setOptions({
|
|
37
|
+
adapter: adapterB,
|
|
38
|
+
runtimeHook: () => ({}) as never,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await core.getLoadThreadsPromise();
|
|
42
|
+
expect(listAdapterB).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(core.threadIds).toEqual(["thread-b"]);
|
|
44
|
+
expect(core.getItemById("thread-a")).toBeUndefined();
|
|
45
|
+
expect(core.getItemById(core.mainThreadId)?.remoteId).not.toBe("thread-a");
|
|
46
|
+
expect(started).toContain(core.mainThreadId);
|
|
47
|
+
|
|
48
|
+
await expect(core.rename("thread-a", "leaked")).rejects.toThrow(
|
|
49
|
+
'Thread "thread-a" not found',
|
|
50
|
+
);
|
|
51
|
+
expect(adapterB.rename).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does not resume an old adapter mutation through the new adapter", async () => {
|
|
55
|
+
const adapterA = makeAdapter({
|
|
56
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
57
|
+
});
|
|
58
|
+
const adapterB = makeAdapter();
|
|
59
|
+
const core = createCore(adapterA);
|
|
60
|
+
|
|
61
|
+
await core.getLoadThreadsPromise();
|
|
62
|
+
await core.switchToThread("thread-a");
|
|
63
|
+
|
|
64
|
+
const runtimeStart = deferred<unknown>();
|
|
65
|
+
setStartThreadRuntime(core, () => runtimeStart.promise);
|
|
66
|
+
const archiveTask = core.archive("thread-a");
|
|
67
|
+
|
|
68
|
+
core.__internal_setOptions({
|
|
69
|
+
adapter: adapterB,
|
|
70
|
+
runtimeHook: () => ({}) as never,
|
|
71
|
+
});
|
|
72
|
+
runtimeStart.resolve({});
|
|
73
|
+
|
|
74
|
+
await expect(archiveTask).rejects.toThrow("adapter changed");
|
|
75
|
+
expect(adapterA.archive).not.toHaveBeenCalled();
|
|
76
|
+
expect(adapterB.archive).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("preserves an adapter failure that races an adapter change", async () => {
|
|
80
|
+
const unarchiveRequest = deferred<void>();
|
|
81
|
+
const adapterA = makeAdapter({
|
|
82
|
+
list: async () => ({
|
|
83
|
+
threads: [{ ...thread("thread-a"), status: "archived" as const }],
|
|
84
|
+
}),
|
|
85
|
+
unarchive: vi.fn(() => unarchiveRequest.promise),
|
|
86
|
+
});
|
|
87
|
+
const adapterB = makeAdapter();
|
|
88
|
+
const core = createCore(adapterA);
|
|
89
|
+
|
|
90
|
+
await core.getLoadThreadsPromise();
|
|
91
|
+
const unarchiveTask = core.unarchive("thread-a");
|
|
92
|
+
await vi.waitFor(() => expect(adapterA.unarchive).toHaveBeenCalledOnce());
|
|
93
|
+
|
|
94
|
+
core.__internal_setOptions({
|
|
95
|
+
adapter: adapterB,
|
|
96
|
+
runtimeHook: () => ({}) as never,
|
|
97
|
+
});
|
|
98
|
+
const failure = new Error("network error");
|
|
99
|
+
unarchiveRequest.reject(failure);
|
|
100
|
+
|
|
101
|
+
await expect(unarchiveTask).rejects.toBe(failure);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("does not apply a late initialize to the replacement adapter state", async () => {
|
|
105
|
+
const initializeRequest = deferred<{
|
|
106
|
+
remoteId: string;
|
|
107
|
+
externalId: string;
|
|
108
|
+
}>();
|
|
109
|
+
const adapterA = makeAdapter({
|
|
110
|
+
list: async () => ({ threads: [] }),
|
|
111
|
+
initialize: vi.fn(() => initializeRequest.promise),
|
|
112
|
+
});
|
|
113
|
+
const adapterB = makeAdapter();
|
|
114
|
+
const core = createCore(adapterA);
|
|
115
|
+
|
|
116
|
+
await core.getLoadThreadsPromise();
|
|
117
|
+
const localId = core.newThreadId;
|
|
118
|
+
expect(localId).toBeDefined();
|
|
119
|
+
const initializeTask = core.initialize(localId!);
|
|
120
|
+
|
|
121
|
+
core.__internal_setOptions({
|
|
122
|
+
adapter: adapterB,
|
|
123
|
+
runtimeHook: () => ({}) as never,
|
|
124
|
+
});
|
|
125
|
+
initializeRequest.resolve({
|
|
126
|
+
remoteId: "leaked",
|
|
127
|
+
externalId: "leaked",
|
|
128
|
+
});
|
|
129
|
+
await expect(initializeTask).rejects.toThrow("adapter changed");
|
|
130
|
+
expect(core.getItemById("leaked")).toBeUndefined();
|
|
131
|
+
expect(core.threadIds).not.toContain("leaked");
|
|
132
|
+
|
|
133
|
+
await core.getLoadThreadsPromise();
|
|
134
|
+
expect(core.getItemById(core.mainThreadId)?.status).toBe("new");
|
|
135
|
+
expect(core.getItemById(core.mainThreadId)?.remoteId).toBeUndefined();
|
|
136
|
+
await expect(core.initialize(core.mainThreadId)).resolves.toEqual({
|
|
137
|
+
remoteId: core.mainThreadId,
|
|
138
|
+
externalId: core.mainThreadId,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("selects a controlled thread that arrives on the replacement page", async () => {
|
|
143
|
+
const adapterA = makeAdapter({
|
|
144
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
145
|
+
});
|
|
146
|
+
const adapterB = makeAdapter({
|
|
147
|
+
list: async () => ({ threads: [thread("thread-b")] }),
|
|
148
|
+
});
|
|
149
|
+
const core = createCore(adapterA, "thread-a");
|
|
150
|
+
|
|
151
|
+
await core.getLoadThreadsPromise();
|
|
152
|
+
core.__internal_load();
|
|
153
|
+
await core.switchToThread("thread-a");
|
|
154
|
+
|
|
155
|
+
core.__internal_setOptions({
|
|
156
|
+
adapter: adapterB,
|
|
157
|
+
runtimeHook: () => ({}) as never,
|
|
158
|
+
threadId: "thread-b",
|
|
159
|
+
});
|
|
160
|
+
await core.getLoadThreadsPromise();
|
|
161
|
+
|
|
162
|
+
expect(core.getItemById(core.mainThreadId)?.remoteId).toBe("thread-b");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("keeps the unsent draft runtime across an empty-list adapter swap", async () => {
|
|
166
|
+
const adapterA = makeAdapter();
|
|
167
|
+
const adapterB = makeAdapter();
|
|
168
|
+
const core = createCore(adapterA);
|
|
169
|
+
const stopped: string[] = [];
|
|
170
|
+
const hookManager = (
|
|
171
|
+
core as unknown as {
|
|
172
|
+
_hookManager: { stopThreadRuntime: (id: string) => void };
|
|
173
|
+
}
|
|
174
|
+
)._hookManager;
|
|
175
|
+
const originalStop = hookManager.stopThreadRuntime.bind(hookManager);
|
|
176
|
+
hookManager.stopThreadRuntime = (id: string) => {
|
|
177
|
+
stopped.push(id);
|
|
178
|
+
originalStop(id);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
await core.getLoadThreadsPromise();
|
|
182
|
+
const draftId = core.mainThreadId;
|
|
183
|
+
expect(core.getItemById(draftId)?.status).toBe("new");
|
|
184
|
+
|
|
185
|
+
core.__internal_setOptions({
|
|
186
|
+
adapter: adapterB,
|
|
187
|
+
runtimeHook: () => ({}) as never,
|
|
188
|
+
});
|
|
189
|
+
await core.getLoadThreadsPromise();
|
|
190
|
+
|
|
191
|
+
expect(core.mainThreadId).toBe(draftId);
|
|
192
|
+
expect(core.getItemById(draftId)?.status).toBe("new");
|
|
193
|
+
expect(stopped).not.toContain(draftId);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("stops runtimes for threads missing from the replacement list", async () => {
|
|
197
|
+
const adapterA = makeAdapter({
|
|
198
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
199
|
+
});
|
|
200
|
+
const adapterB = makeAdapter({
|
|
201
|
+
list: async () => ({ threads: [thread("thread-b")] }),
|
|
202
|
+
});
|
|
203
|
+
const core = createCore(adapterA);
|
|
204
|
+
const stopped: string[] = [];
|
|
205
|
+
const hookManager = (
|
|
206
|
+
core as unknown as {
|
|
207
|
+
_hookManager: { stopThreadRuntime: (id: string) => void };
|
|
208
|
+
}
|
|
209
|
+
)._hookManager;
|
|
210
|
+
const originalStop = hookManager.stopThreadRuntime.bind(hookManager);
|
|
211
|
+
hookManager.stopThreadRuntime = (id: string) => {
|
|
212
|
+
stopped.push(id);
|
|
213
|
+
originalStop(id);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
await core.getLoadThreadsPromise();
|
|
217
|
+
await core.switchToThread("thread-a");
|
|
218
|
+
|
|
219
|
+
core.__internal_setOptions({
|
|
220
|
+
adapter: adapterB,
|
|
221
|
+
runtimeHook: () => ({}) as never,
|
|
222
|
+
});
|
|
223
|
+
await core.getLoadThreadsPromise();
|
|
224
|
+
|
|
225
|
+
expect(core.getItemById("thread-a")).toBeUndefined();
|
|
226
|
+
expect(stopped).toContain("thread-a");
|
|
227
|
+
expect(stopped).not.toContain(core.mainThreadId);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("does not reject when a controlled thread is missing from the replacement adapter", async () => {
|
|
231
|
+
const adapterA = makeAdapter({
|
|
232
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
233
|
+
});
|
|
234
|
+
const adapterB = makeAdapter({
|
|
235
|
+
list: async () => ({ threads: [thread("thread-b")] }),
|
|
236
|
+
fetch: vi.fn(async () => {
|
|
237
|
+
throw new Error("not found");
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
const core = createCore(adapterA, "thread-a");
|
|
241
|
+
|
|
242
|
+
await core.getLoadThreadsPromise();
|
|
243
|
+
await core.switchToThread("thread-a");
|
|
244
|
+
|
|
245
|
+
core.__internal_setOptions({
|
|
246
|
+
adapter: adapterB,
|
|
247
|
+
runtimeHook: () => ({}) as never,
|
|
248
|
+
threadId: "thread-a",
|
|
249
|
+
});
|
|
250
|
+
await expect(core.getLoadThreadsPromise()).resolves.toBeUndefined();
|
|
251
|
+
await vi.waitFor(() => {
|
|
252
|
+
expect(adapterB.fetch).toHaveBeenCalledWith("thread-a");
|
|
253
|
+
});
|
|
254
|
+
expect(core.getItemById("thread-a")).toBeUndefined();
|
|
255
|
+
expect(core.getItemById(core.mainThreadId)?.status).toBe("new");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("reuses the preserved unsent draft when the selected thread is missing after swap", async () => {
|
|
259
|
+
const adapterA = makeAdapter({
|
|
260
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
261
|
+
});
|
|
262
|
+
const adapterB = makeAdapter({
|
|
263
|
+
list: async () => ({ threads: [thread("thread-b")] }),
|
|
264
|
+
});
|
|
265
|
+
const core = createCore(adapterA);
|
|
266
|
+
|
|
267
|
+
await core.getLoadThreadsPromise();
|
|
268
|
+
const draftId = core.newThreadId;
|
|
269
|
+
expect(draftId).toBeDefined();
|
|
270
|
+
await core.switchToThread("thread-a");
|
|
271
|
+
expect(core.mainThreadId).toBe("thread-a");
|
|
272
|
+
expect(core.newThreadId).toBe(draftId);
|
|
273
|
+
|
|
274
|
+
core.__internal_setOptions({
|
|
275
|
+
adapter: adapterB,
|
|
276
|
+
runtimeHook: () => ({}) as never,
|
|
277
|
+
});
|
|
278
|
+
await core.getLoadThreadsPromise();
|
|
279
|
+
|
|
280
|
+
expect(core.mainThreadId).toBe(draftId);
|
|
281
|
+
expect(core.newThreadId).toBe(draftId);
|
|
282
|
+
expect(core.getItemById(draftId!)?.status).toBe("new");
|
|
283
|
+
expect(
|
|
284
|
+
Object.values(core.threadItems).filter((item) => item.status === "new"),
|
|
285
|
+
).toHaveLength(1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("does not send a rename through the replacement adapter while its list is pending", async () => {
|
|
289
|
+
const adapterA = makeAdapter({
|
|
290
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
291
|
+
});
|
|
292
|
+
const listB = deferred<{ threads: ReturnType<typeof thread>[] }>();
|
|
293
|
+
const adapterB = makeAdapter({
|
|
294
|
+
list: vi.fn(() => listB.promise),
|
|
295
|
+
});
|
|
296
|
+
const core = createCore(adapterA);
|
|
297
|
+
|
|
298
|
+
await core.getLoadThreadsPromise();
|
|
299
|
+
await core.switchToThread("thread-a");
|
|
300
|
+
|
|
301
|
+
core.__internal_setOptions({
|
|
302
|
+
adapter: adapterB,
|
|
303
|
+
runtimeHook: () => ({}) as never,
|
|
304
|
+
});
|
|
305
|
+
await expect(core.rename("thread-a", "leaked")).rejects.toThrow(
|
|
306
|
+
"adapter changed",
|
|
307
|
+
);
|
|
308
|
+
await expect(core.switchToThread("thread-a")).rejects.toThrow(
|
|
309
|
+
"adapter changed",
|
|
310
|
+
);
|
|
311
|
+
expect(adapterB.rename).not.toHaveBeenCalled();
|
|
312
|
+
|
|
313
|
+
listB.resolve({ threads: [thread("thread-b")] });
|
|
314
|
+
await core.getLoadThreadsPromise();
|
|
315
|
+
expect(adapterB.rename).not.toHaveBeenCalled();
|
|
316
|
+
expect(core.getItemById("thread-a")).toBeUndefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("does not freeze mutations when the replacement list fails", async () => {
|
|
320
|
+
const adapterA = makeAdapter({
|
|
321
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
322
|
+
});
|
|
323
|
+
const adapterB = makeAdapter({
|
|
324
|
+
list: vi.fn(async () => {
|
|
325
|
+
throw new Error("network");
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
const core = createCore(adapterA);
|
|
329
|
+
const consoleError = vi
|
|
330
|
+
.spyOn(console, "error")
|
|
331
|
+
.mockImplementation(() => {});
|
|
332
|
+
|
|
333
|
+
await core.getLoadThreadsPromise();
|
|
334
|
+
await core.switchToThread("thread-a");
|
|
335
|
+
|
|
336
|
+
core.__internal_setOptions({
|
|
337
|
+
adapter: adapterB,
|
|
338
|
+
runtimeHook: () => ({}) as never,
|
|
339
|
+
});
|
|
340
|
+
await core.getLoadThreadsPromise();
|
|
341
|
+
|
|
342
|
+
expect(core.getItemById("thread-a")).toBeUndefined();
|
|
343
|
+
expect(core.getItemById(core.mainThreadId)?.status).toBe("new");
|
|
344
|
+
await expect(core.switchToNewThread()).resolves.toBeUndefined();
|
|
345
|
+
expect(consoleError).toHaveBeenCalled();
|
|
346
|
+
consoleError.mockRestore();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("keeps a thread initialized while the replacement list is pending", async () => {
|
|
350
|
+
const adapterA = makeAdapter({
|
|
351
|
+
list: async () => ({ threads: [thread("thread-a")] }),
|
|
352
|
+
});
|
|
353
|
+
const listB = deferred<{ threads: ReturnType<typeof thread>[] }>();
|
|
354
|
+
const adapterB = makeAdapter({
|
|
355
|
+
list: vi.fn(() => listB.promise),
|
|
356
|
+
initialize: vi.fn(async () => ({
|
|
357
|
+
remoteId: "created-on-b",
|
|
358
|
+
externalId: "created-on-b",
|
|
359
|
+
})),
|
|
360
|
+
});
|
|
361
|
+
const core = createCore(adapterA);
|
|
362
|
+
|
|
363
|
+
await core.getLoadThreadsPromise();
|
|
364
|
+
const draftId = core.newThreadId;
|
|
365
|
+
expect(draftId).toBeDefined();
|
|
366
|
+
|
|
367
|
+
core.__internal_setOptions({
|
|
368
|
+
adapter: adapterB,
|
|
369
|
+
runtimeHook: () => ({}) as never,
|
|
370
|
+
});
|
|
371
|
+
await expect(core.initialize(draftId!)).resolves.toEqual({
|
|
372
|
+
remoteId: "created-on-b",
|
|
373
|
+
externalId: "created-on-b",
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
listB.resolve({ threads: [thread("thread-b")] });
|
|
377
|
+
await core.getLoadThreadsPromise();
|
|
378
|
+
|
|
379
|
+
expect(core.getItemById(draftId!)?.remoteId).toBe("created-on-b");
|
|
380
|
+
expect(core.mainThreadId).toBe(draftId);
|
|
381
|
+
expect(core.threadIds).toContain("created-on-b");
|
|
382
|
+
});
|
|
383
|
+
});
|