@assistant-ui/core 0.1.17 → 0.2.0

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 (122) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/react/index.d.ts +1 -0
  6. package/dist/react/index.d.ts.map +1 -1
  7. package/dist/react/index.js +1 -0
  8. package/dist/react/index.js.map +1 -1
  9. package/dist/react/primitive-hooks/useThreadListLoadMore.d.ts +5 -0
  10. package/dist/react/primitive-hooks/useThreadListLoadMore.d.ts.map +1 -0
  11. package/dist/react/primitive-hooks/useThreadListLoadMore.js +11 -0
  12. package/dist/react/primitive-hooks/useThreadListLoadMore.js.map +1 -0
  13. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +0 -2
  14. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  15. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +4 -3
  16. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  17. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -4
  18. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  19. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +86 -38
  20. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  21. package/dist/react/runtimes/useLocalRuntime.d.ts +1 -1
  22. package/dist/runtime/api/assistant-runtime.d.ts +0 -33
  23. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  24. package/dist/runtime/api/assistant-runtime.js +0 -23
  25. package/dist/runtime/api/assistant-runtime.js.map +1 -1
  26. package/dist/runtime/api/bindings.d.ts +1 -3
  27. package/dist/runtime/api/bindings.d.ts.map +1 -1
  28. package/dist/runtime/api/message-runtime.d.ts +1 -6
  29. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  30. package/dist/runtime/api/message-runtime.js.map +1 -1
  31. package/dist/runtime/api/thread-list-runtime.d.ts +4 -0
  32. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  33. package/dist/runtime/api/thread-list-runtime.js +6 -0
  34. package/dist/runtime/api/thread-list-runtime.js.map +1 -1
  35. package/dist/runtime/api/thread-runtime.d.ts +1 -24
  36. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  37. package/dist/runtime/api/thread-runtime.js +1 -20
  38. package/dist/runtime/api/thread-runtime.js.map +1 -1
  39. package/dist/runtime/base/base-thread-runtime-core.d.ts +0 -1
  40. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  41. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  42. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +3 -0
  43. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
  44. package/dist/runtime/interfaces/thread-runtime-core.d.ts +0 -4
  45. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  46. package/dist/runtime/utils/chat-model-adapter.d.ts +0 -4
  47. package/dist/runtime/utils/chat-model-adapter.d.ts.map +1 -1
  48. package/dist/runtime/utils/external-store-message.d.ts +0 -4
  49. package/dist/runtime/utils/external-store-message.d.ts.map +1 -1
  50. package/dist/runtime/utils/external-store-message.js +0 -7
  51. package/dist/runtime/utils/external-store-message.js.map +1 -1
  52. package/dist/runtimes/assistant-transport/utils.d.ts +0 -9
  53. package/dist/runtimes/assistant-transport/utils.d.ts.map +1 -1
  54. package/dist/runtimes/assistant-transport/utils.js +0 -13
  55. package/dist/runtimes/assistant-transport/utils.js.map +1 -1
  56. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +0 -1
  57. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  58. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +0 -3
  59. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  60. package/dist/runtimes/local/local-thread-runtime-core.d.ts +0 -1
  61. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  62. package/dist/runtimes/local/local-thread-runtime-core.js +0 -4
  63. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  64. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +0 -1
  65. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  66. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +0 -3
  67. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  68. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  69. package/dist/runtimes/remote-thread-list/empty-thread-core.js +0 -3
  70. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  71. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +12 -1
  72. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
  73. package/dist/runtimes/remote-thread-list/remote-thread-state.js +34 -0
  74. package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
  75. package/dist/runtimes/remote-thread-list/types.d.ts +5 -1
  76. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  77. package/dist/store/clients/thread-message-client.d.ts.map +1 -1
  78. package/dist/store/clients/thread-message-client.js +0 -1
  79. package/dist/store/clients/thread-message-client.js.map +1 -1
  80. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  81. package/dist/store/runtime-clients/thread-list-runtime-client.js +3 -0
  82. package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
  83. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  84. package/dist/store/runtime-clients/thread-runtime-client.js +0 -1
  85. package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
  86. package/dist/store/scopes/message.d.ts +1 -3
  87. package/dist/store/scopes/message.d.ts.map +1 -1
  88. package/dist/store/scopes/thread.d.ts +0 -4
  89. package/dist/store/scopes/thread.d.ts.map +1 -1
  90. package/dist/store/scopes/threads.d.ts +3 -0
  91. package/dist/store/scopes/threads.d.ts.map +1 -1
  92. package/package.json +9 -9
  93. package/src/index.ts +2 -6
  94. package/src/react/index.ts +1 -0
  95. package/src/react/primitive-hooks/useThreadListLoadMore.ts +15 -0
  96. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +7 -6
  97. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +96 -43
  98. package/src/runtime/api/assistant-runtime.ts +0 -62
  99. package/src/runtime/api/bindings.ts +1 -6
  100. package/src/runtime/api/message-runtime.ts +1 -8
  101. package/src/runtime/api/thread-list-runtime.ts +10 -0
  102. package/src/runtime/api/thread-runtime.ts +1 -46
  103. package/src/runtime/base/base-thread-runtime-core.ts +0 -1
  104. package/src/runtime/interfaces/thread-list-runtime-core.ts +3 -0
  105. package/src/runtime/interfaces/thread-runtime-core.ts +0 -5
  106. package/src/runtime/utils/chat-model-adapter.ts +0 -5
  107. package/src/runtime/utils/external-store-message.ts +0 -8
  108. package/src/runtimes/assistant-transport/utils.ts +0 -28
  109. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +0 -4
  110. package/src/runtimes/local/local-thread-runtime-core.ts +0 -5
  111. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +0 -4
  112. package/src/runtimes/remote-thread-list/empty-thread-core.ts +0 -4
  113. package/src/runtimes/remote-thread-list/remote-thread-state.ts +54 -1
  114. package/src/runtimes/remote-thread-list/types.ts +6 -1
  115. package/src/store/clients/thread-message-client.ts +0 -1
  116. package/src/store/runtime-clients/thread-list-runtime-client.ts +3 -0
  117. package/src/store/runtime-clients/thread-runtime-client.ts +0 -1
  118. package/src/store/scopes/message.ts +1 -6
  119. package/src/store/scopes/thread.ts +0 -5
  120. package/src/store/scopes/threads.ts +3 -0
  121. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +448 -0
  122. package/src/tests/RemoteThreadListThreadListRuntimeCore-reload.test.ts +6 -1
@@ -0,0 +1,448 @@
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
+ import type {
3
+ RemoteThreadListPageOptions,
4
+ RemoteThreadListResponse,
5
+ } from "../runtimes/remote-thread-list/types";
6
+ import {
7
+ createCore,
8
+ deferred,
9
+ makeAdapter,
10
+ } from "./remote-thread-list-test-helpers";
11
+
12
+ type ListFn = (
13
+ params?: RemoteThreadListPageOptions,
14
+ ) => Promise<RemoteThreadListResponse>;
15
+
16
+ describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it("initial list response with nextCursor sets hasMore=true", async () => {
22
+ const adapter = makeAdapter({
23
+ list: vi.fn(async () => ({
24
+ threads: [
25
+ {
26
+ status: "regular",
27
+ remoteId: "t-1",
28
+ externalId: "t-1",
29
+ title: "First",
30
+ },
31
+ ],
32
+ nextCursor: "cursor-1",
33
+ })),
34
+ });
35
+ const core = createCore(adapter);
36
+
37
+ await core.getLoadThreadsPromise();
38
+ expect(core.threadIds).toEqual(["t-1"]);
39
+ expect(core.hasMore).toBe(true);
40
+ });
41
+
42
+ it("absent nextCursor leaves hasMore=false", async () => {
43
+ const adapter = makeAdapter({
44
+ list: vi.fn(async () => ({
45
+ threads: [
46
+ {
47
+ status: "regular",
48
+ remoteId: "t-1",
49
+ externalId: "t-1",
50
+ title: "Only",
51
+ },
52
+ ],
53
+ })),
54
+ });
55
+ const core = createCore(adapter);
56
+
57
+ await core.getLoadThreadsPromise();
58
+ expect(core.hasMore).toBe(false);
59
+ });
60
+
61
+ it("loadMore appends the next page and advances the cursor", async () => {
62
+ const listFn = vi
63
+ .fn<ListFn>()
64
+ .mockResolvedValueOnce({
65
+ threads: [
66
+ { status: "regular", remoteId: "p1-a", externalId: "p1-a" },
67
+ { status: "regular", remoteId: "p1-b", externalId: "p1-b" },
68
+ ],
69
+ nextCursor: "c1",
70
+ })
71
+ .mockResolvedValueOnce({
72
+ threads: [
73
+ { status: "regular", remoteId: "p2-a", externalId: "p2-a" },
74
+ { status: "regular", remoteId: "p2-b", externalId: "p2-b" },
75
+ ],
76
+ nextCursor: "c2",
77
+ });
78
+ const adapter = makeAdapter({ list: listFn });
79
+ const core = createCore(adapter);
80
+
81
+ await core.getLoadThreadsPromise();
82
+ expect(core.threadIds).toEqual(["p1-a", "p1-b"]);
83
+ expect(core.hasMore).toBe(true);
84
+
85
+ await core.loadMore();
86
+
87
+ expect(listFn).toHaveBeenNthCalledWith(2, { after: "c1" });
88
+ expect(core.threadIds).toEqual(["p1-a", "p1-b", "p2-a", "p2-b"]);
89
+ expect(core.hasMore).toBe(true);
90
+ });
91
+
92
+ it("loadMore without nextCursor flips hasMore to false", async () => {
93
+ const listFn = vi
94
+ .fn<ListFn>()
95
+ .mockResolvedValueOnce({
96
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
97
+ nextCursor: "c1",
98
+ })
99
+ .mockResolvedValueOnce({
100
+ threads: [{ status: "regular", remoteId: "b", externalId: "b" }],
101
+ });
102
+ const adapter = makeAdapter({ list: listFn });
103
+ const core = createCore(adapter);
104
+
105
+ await core.getLoadThreadsPromise();
106
+ await core.loadMore();
107
+
108
+ expect(core.hasMore).toBe(false);
109
+ expect(core.threadIds).toEqual(["a", "b"]);
110
+ });
111
+
112
+ it("loadMore is a no-op when hasMore is false", async () => {
113
+ const listFn = vi.fn<ListFn>().mockResolvedValueOnce({
114
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
115
+ });
116
+ const adapter = makeAdapter({ list: listFn });
117
+ const core = createCore(adapter);
118
+
119
+ await core.getLoadThreadsPromise();
120
+ expect(core.hasMore).toBe(false);
121
+
122
+ await core.loadMore();
123
+ expect(listFn).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it("loadMore is a no-op while the initial list is in flight", async () => {
127
+ const first = deferred<RemoteThreadListResponse>();
128
+ const listFn = vi.fn<ListFn>().mockReturnValueOnce(first.promise);
129
+ const adapter = makeAdapter({ list: listFn });
130
+ const core = createCore(adapter);
131
+
132
+ core.getLoadThreadsPromise();
133
+ await core.loadMore();
134
+
135
+ expect(listFn).toHaveBeenCalledTimes(1);
136
+ first.resolve({ threads: [] });
137
+ });
138
+
139
+ it("concurrent loadMore calls dedupe to a single in-flight request", async () => {
140
+ const first = deferred<RemoteThreadListResponse>();
141
+ const listFn = vi
142
+ .fn<ListFn>()
143
+ .mockResolvedValueOnce({
144
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
145
+ nextCursor: "c1",
146
+ })
147
+ .mockReturnValueOnce(first.promise);
148
+ const adapter = makeAdapter({ list: listFn });
149
+ const core = createCore(adapter);
150
+
151
+ await core.getLoadThreadsPromise();
152
+
153
+ const p1 = core.loadMore();
154
+ const p2 = core.loadMore();
155
+ expect(listFn).toHaveBeenCalledTimes(2);
156
+
157
+ first.resolve({
158
+ threads: [{ status: "regular", remoteId: "b", externalId: "b" }],
159
+ });
160
+ await Promise.all([p1, p2]);
161
+
162
+ expect(listFn).toHaveBeenCalledTimes(2);
163
+ expect(core.threadIds).toEqual(["a", "b"]);
164
+ });
165
+
166
+ it("drops a stale loadMore when reload bumps the generation mid-flight", async () => {
167
+ const loadMoreCall = deferred<RemoteThreadListResponse>();
168
+ const listFn = vi
169
+ .fn<ListFn>()
170
+ .mockResolvedValueOnce({
171
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
172
+ nextCursor: "c1",
173
+ })
174
+ .mockReturnValueOnce(loadMoreCall.promise)
175
+ .mockResolvedValueOnce({
176
+ threads: [
177
+ { status: "regular", remoteId: "fresh", externalId: "fresh" },
178
+ ],
179
+ });
180
+ const adapter = makeAdapter({ list: listFn });
181
+ const core = createCore(adapter);
182
+
183
+ await core.getLoadThreadsPromise();
184
+
185
+ const stale = core.loadMore();
186
+ const reloaded = core.reload();
187
+
188
+ loadMoreCall.resolve({
189
+ threads: [{ status: "regular", remoteId: "stale", externalId: "stale" }],
190
+ nextCursor: "stale-cursor",
191
+ });
192
+ await Promise.all([stale, reloaded]);
193
+
194
+ expect(core.threadIds).toEqual(["fresh"]);
195
+ expect(core.hasMore).toBe(false);
196
+ });
197
+
198
+ it("releases the dedup handle after a rejection so retries can proceed", async () => {
199
+ vi.spyOn(console, "error").mockImplementation(() => {});
200
+ const listFn = vi
201
+ .fn<ListFn>()
202
+ .mockResolvedValueOnce({
203
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
204
+ nextCursor: "c1",
205
+ })
206
+ .mockRejectedValueOnce(new Error("network"))
207
+ .mockResolvedValueOnce({
208
+ threads: [{ status: "regular", remoteId: "b", externalId: "b" }],
209
+ });
210
+ const adapter = makeAdapter({ list: listFn });
211
+ const core = createCore(adapter);
212
+
213
+ await core.getLoadThreadsPromise();
214
+ await core.loadMore();
215
+ expect(core.threadIds).toEqual(["a"]);
216
+ expect(core.isLoadingMore).toBe(false);
217
+
218
+ await core.loadMore();
219
+ expect(core.threadIds).toEqual(["a", "b"]);
220
+ });
221
+
222
+ it("dedupes thread ids that appear on more than one page", async () => {
223
+ const listFn = vi
224
+ .fn<ListFn>()
225
+ .mockResolvedValueOnce({
226
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
227
+ nextCursor: "c1",
228
+ })
229
+ .mockResolvedValueOnce({
230
+ threads: [
231
+ { status: "regular", remoteId: "a", externalId: "a" },
232
+ { status: "regular", remoteId: "b", externalId: "b" },
233
+ ],
234
+ });
235
+ const adapter = makeAdapter({ list: listFn });
236
+ const core = createCore(adapter);
237
+
238
+ await core.getLoadThreadsPromise();
239
+ await core.loadMore();
240
+
241
+ expect(core.threadIds).toEqual(["a", "b"]);
242
+ });
243
+
244
+ it("__internal_setOptions clears cursor and dedup handles on adapter swap, then refetches via the new adapter", async () => {
245
+ const firstAdapter = makeAdapter({
246
+ list: vi.fn(async () => ({
247
+ threads: [{ status: "regular", remoteId: "old", externalId: "old" }],
248
+ nextCursor: "old-cursor",
249
+ })),
250
+ });
251
+ const core = createCore(firstAdapter);
252
+ await core.getLoadThreadsPromise();
253
+ expect(core.threadIds).toEqual(["old"]);
254
+ expect(core.hasMore).toBe(true);
255
+
256
+ const secondList = vi.fn(async () => ({
257
+ threads: [{ status: "regular", remoteId: "new", externalId: "new" }],
258
+ }));
259
+ const secondAdapter = makeAdapter({ list: secondList });
260
+ core.__internal_setOptions({
261
+ adapter: secondAdapter,
262
+ runtimeHook: () => ({}) as never,
263
+ });
264
+
265
+ expect(core.hasMore).toBe(false);
266
+ expect(core.threadIds).toEqual(["old"]);
267
+
268
+ await core.getLoadThreadsPromise();
269
+ expect(secondList).toHaveBeenCalledTimes(1);
270
+ expect(core.threadIds).toEqual(["new"]);
271
+ });
272
+
273
+ it("ignores an in-flight loadMore response when the adapter swaps mid-flight", async () => {
274
+ const slow = deferred<RemoteThreadListResponse>();
275
+ const firstList = vi
276
+ .fn<
277
+ (
278
+ params?: RemoteThreadListPageOptions,
279
+ ) => Promise<RemoteThreadListResponse>
280
+ >()
281
+ .mockResolvedValueOnce({
282
+ threads: [{ status: "regular", remoteId: "p1", externalId: "p1" }],
283
+ nextCursor: "c1",
284
+ })
285
+ .mockReturnValueOnce(slow.promise);
286
+ const firstAdapter = makeAdapter({ list: firstList });
287
+ const core = createCore(firstAdapter);
288
+
289
+ await core.getLoadThreadsPromise();
290
+ const stale = core.loadMore();
291
+
292
+ const secondAdapter = makeAdapter({
293
+ list: vi.fn(async () => ({
294
+ threads: [{ status: "regular", remoteId: "ignored", externalId: "x" }],
295
+ })),
296
+ });
297
+ core.__internal_setOptions({
298
+ adapter: secondAdapter,
299
+ runtimeHook: () => ({}) as never,
300
+ });
301
+
302
+ slow.resolve({
303
+ threads: [{ status: "regular", remoteId: "stale", externalId: "stale" }],
304
+ nextCursor: "stale-cursor",
305
+ });
306
+ await stale;
307
+
308
+ expect(core.threadIds).toEqual(["p1"]);
309
+ expect(core.hasMore).toBe(false);
310
+ expect(core.isLoadingMore).toBe(false);
311
+ });
312
+
313
+ it("drops the in-flight initial list when the adapter swaps mid-flight", async () => {
314
+ const slow = deferred<RemoteThreadListResponse>();
315
+ const firstList = vi.fn<ListFn>().mockReturnValueOnce(slow.promise);
316
+ const firstAdapter = makeAdapter({ list: firstList });
317
+ const core = createCore(firstAdapter);
318
+
319
+ core.getLoadThreadsPromise();
320
+
321
+ const secondList = vi.fn<ListFn>().mockResolvedValueOnce({
322
+ threads: [{ status: "regular", remoteId: "fresh", externalId: "fresh" }],
323
+ });
324
+ const secondAdapter = makeAdapter({ list: secondList });
325
+ core.__internal_setOptions({
326
+ adapter: secondAdapter,
327
+ runtimeHook: () => ({}) as never,
328
+ });
329
+
330
+ slow.resolve({
331
+ threads: [{ status: "regular", remoteId: "stale", externalId: "stale" }],
332
+ nextCursor: "stale-cursor",
333
+ });
334
+ await Promise.resolve();
335
+ await Promise.resolve();
336
+
337
+ expect(core.threadIds).toEqual([]);
338
+ expect(core.hasMore).toBe(false);
339
+
340
+ await core.getLoadThreadsPromise();
341
+ expect(secondList).toHaveBeenCalledTimes(1);
342
+ expect(core.threadIds).toEqual(["fresh"]);
343
+ });
344
+
345
+ it("dedupes thread ids that appear twice within a single page", async () => {
346
+ const listFn = vi
347
+ .fn<ListFn>()
348
+ .mockResolvedValueOnce({
349
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
350
+ nextCursor: "c1",
351
+ })
352
+ .mockResolvedValueOnce({
353
+ threads: [
354
+ { status: "regular", remoteId: "b", externalId: "b" },
355
+ { status: "regular", remoteId: "b", externalId: "b" },
356
+ ],
357
+ });
358
+ const adapter = makeAdapter({ list: listFn });
359
+ const core = createCore(adapter);
360
+
361
+ await core.getLoadThreadsPromise();
362
+ await core.loadMore();
363
+
364
+ expect(core.threadIds).toEqual(["a", "b"]);
365
+ });
366
+
367
+ it("treats an empty-string nextCursor as no more pages", async () => {
368
+ const listFn = vi.fn<ListFn>().mockResolvedValueOnce({
369
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
370
+ nextCursor: "",
371
+ });
372
+ const adapter = makeAdapter({ list: listFn });
373
+ const core = createCore(adapter);
374
+
375
+ await core.getLoadThreadsPromise();
376
+ expect(core.hasMore).toBe(false);
377
+
378
+ await core.loadMore();
379
+ expect(listFn).toHaveBeenCalledTimes(1);
380
+ });
381
+
382
+ it("does not advance the cursor when the reducer rejects an unknown thread status", async () => {
383
+ vi.spyOn(console, "error").mockImplementation(() => {});
384
+ const listFn = vi
385
+ .fn<ListFn>()
386
+ .mockResolvedValueOnce({
387
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
388
+ nextCursor: "c1",
389
+ })
390
+ .mockResolvedValueOnce({
391
+ threads: [
392
+ {
393
+ status: "weird" as unknown as "regular",
394
+ remoteId: "bad",
395
+ externalId: "bad",
396
+ },
397
+ ],
398
+ nextCursor: "c2",
399
+ })
400
+ .mockResolvedValueOnce({
401
+ threads: [
402
+ { status: "regular", remoteId: "retry", externalId: "retry" },
403
+ ],
404
+ });
405
+ const adapter = makeAdapter({ list: listFn });
406
+ const core = createCore(adapter);
407
+
408
+ await core.getLoadThreadsPromise();
409
+ expect(core.hasMore).toBe(true);
410
+
411
+ await core.loadMore();
412
+
413
+ expect(core.threadIds).toEqual(["a"]);
414
+ expect(core.hasMore).toBe(true);
415
+ expect(core.isLoadingMore).toBe(false);
416
+
417
+ await core.loadMore();
418
+ expect(listFn).toHaveBeenNthCalledWith(2, { after: "c1" });
419
+ expect(listFn).toHaveBeenNthCalledWith(3, { after: "c1" });
420
+ expect(core.threadIds).toEqual(["a", "retry"]);
421
+ });
422
+
423
+ it("reload resets cursor and hasMore so loadMore becomes a no-op", async () => {
424
+ const listFn = vi
425
+ .fn<ListFn>()
426
+ .mockResolvedValueOnce({
427
+ threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
428
+ nextCursor: "c1",
429
+ })
430
+ .mockResolvedValueOnce({
431
+ threads: [
432
+ { status: "regular", remoteId: "fresh", externalId: "fresh" },
433
+ ],
434
+ });
435
+ const adapter = makeAdapter({ list: listFn });
436
+ const core = createCore(adapter);
437
+
438
+ await core.getLoadThreadsPromise();
439
+ expect(core.hasMore).toBe(true);
440
+
441
+ await core.reload();
442
+ expect(core.hasMore).toBe(false);
443
+ expect(core.threadIds).toEqual(["fresh"]);
444
+
445
+ await core.loadMore();
446
+ expect(listFn).toHaveBeenCalledTimes(2);
447
+ });
448
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { afterEach, describe, it, expect, vi } from "vitest";
2
2
  import type { RemoteThreadListResponse } from "../runtimes/remote-thread-list/types";
3
3
  import {
4
4
  createCore,
@@ -7,6 +7,10 @@ import {
7
7
  } from "./remote-thread-list-test-helpers";
8
8
 
9
9
  describe("RemoteThreadListThreadListRuntimeCore.reload", () => {
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
10
14
  it("refetches list() after a successful empty load", async () => {
11
15
  const listFn = vi
12
16
  .fn<() => Promise<RemoteThreadListResponse>>()
@@ -92,6 +96,7 @@ describe("RemoteThreadListThreadListRuntimeCore.reload", () => {
92
96
  });
93
97
 
94
98
  it("recovers after a failed initial load", async () => {
99
+ vi.spyOn(console, "error").mockImplementation(() => {});
95
100
  const listFn = vi
96
101
  .fn<() => Promise<RemoteThreadListResponse>>()
97
102
  .mockRejectedValueOnce(new Error("401"))