@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.
Files changed (137) hide show
  1. package/dist/adapters/voice.js +11 -0
  2. package/dist/adapters/voice.js.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/frame/host.d.ts +1 -1
  7. package/dist/model-context/frame/host.d.ts.map +1 -1
  8. package/dist/model-context/frame/host.js +17 -3
  9. package/dist/model-context/frame/host.js.map +1 -1
  10. package/dist/model-context/frame/provider.d.ts +4 -0
  11. package/dist/model-context/frame/provider.d.ts.map +1 -1
  12. package/dist/model-context/frame/provider.js +50 -3
  13. package/dist/model-context/frame/provider.js.map +1 -1
  14. package/dist/model-context/frame/types.d.ts +4 -1
  15. package/dist/model-context/frame/types.d.ts.map +1 -1
  16. package/dist/model-context/frame/types.js.map +1 -1
  17. package/dist/react/client/RemoteThreadList.d.ts +1 -1
  18. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  19. package/dist/react/client/RemoteThreadList.js +95 -23
  20. package/dist/react/client/RemoteThreadList.js.map +1 -1
  21. package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
  22. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  23. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
  24. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
  25. package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
  26. package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
  27. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
  28. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
  30. package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
  31. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
  32. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  33. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
  34. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  35. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
  36. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  37. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
  38. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  39. package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
  40. package/dist/react/runtimes/RemoteThreadResource.js +2 -0
  41. package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
  42. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  43. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
  44. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  45. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  46. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
  47. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  48. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  49. package/dist/runtime/utils/message-repository.js +27 -18
  50. package/dist/runtime/utils/message-repository.js.map +1 -1
  51. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  52. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
  53. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  54. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
  55. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
  56. package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
  57. package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
  58. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  59. package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
  60. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  61. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
  62. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  63. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
  64. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  65. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
  66. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
  67. package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
  68. package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
  69. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  70. package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
  71. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  72. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
  73. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  74. package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
  75. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  76. package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
  77. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  78. package/dist/store/clients/external-thread.js +1 -1
  79. package/dist/store/clients/external-thread.js.map +1 -1
  80. package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
  81. package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
  82. package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
  83. package/dist/types/message.d.ts +1 -1
  84. package/dist/types/message.js.map +1 -1
  85. package/dist/utils/silent-runtime-action.d.ts +6 -0
  86. package/dist/utils/silent-runtime-action.d.ts.map +1 -0
  87. package/dist/utils/silent-runtime-action.js +7 -0
  88. package/dist/utils/silent-runtime-action.js.map +1 -0
  89. package/package.json +4 -4
  90. package/src/adapters/voice.test.ts +66 -1
  91. package/src/adapters/voice.ts +8 -0
  92. package/src/internal.ts +1 -0
  93. package/src/model-context/frame/host.test.ts +64 -5
  94. package/src/model-context/frame/host.ts +16 -3
  95. package/src/model-context/frame/provider.test.ts +347 -20
  96. package/src/model-context/frame/provider.ts +58 -2
  97. package/src/model-context/frame/types.ts +5 -0
  98. package/src/react/client/RemoteThreadList.test.ts +196 -0
  99. package/src/react/client/RemoteThreadList.ts +93 -24
  100. package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
  101. package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
  102. package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
  103. package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
  104. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
  105. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
  106. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
  107. package/src/react/runtimes/RemoteThreadResource.ts +2 -0
  108. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
  109. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
  110. package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
  111. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
  112. package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
  113. package/src/runtime/utils/message-repository.ts +26 -18
  114. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
  115. package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
  116. package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
  117. package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
  118. package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
  119. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
  120. package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
  121. package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
  122. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
  123. package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
  124. package/src/runtimes/remote-thread-list/types.ts +4 -0
  125. package/src/store/clients/external-thread.ts +1 -1
  126. package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
  127. package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
  128. package/src/tests/MessageRepository.test.ts +48 -0
  129. package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
  130. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
  131. package/src/tests/empty-thread-core.test.ts +6 -0
  132. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
  133. package/src/tests/external-thread-isEmpty.test.tsx +100 -0
  134. package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
  135. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
  136. package/src/types/message.ts +1 -1
  137. 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(() => core.rename("missing-thread", "New title")).toThrow(
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 secondAdapter = makeAdapter({
293
- list: vi.fn<ListFn>(async () => ({
294
- threads: [{ status: "regular", remoteId: "ignored", externalId: "x" }],
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(["p1"]);
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
+ });