1import { ProjectId, RunId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 activeThreadAnchorTimestampMs,
6 generateSpreadPinOrderKeys,
7 getLatestThreadForProject,
8 pinOrderKeyBetween,
9 planPinnedMove,
10 planPinnedReorder,
11 resolveSettledThreadTimestamp,
12 sortActiveThreadsByOrderKey,
13 sortPinnedThreadsByOrderKey,
14 sortSettledThreads,
15 sortThreads,
16 type SettledThreadTimestampInput,
17 type ThreadSortInput,
18} from "./threadSort.ts";
19
20describe("activeThreadAnchorTimestampMs", () => {
21 it("uses the later unsettle time when an old thread re-enters the active list", () => {
22 expect(
23 activeThreadAnchorTimestampMs({
24 createdAt: "2026-01-01T00:00:00.000Z",
25 unsettledAt: "2026-08-01T00:00:00.000Z",
26 }),
27 ).toBe(Date.parse("2026-08-01T00:00:00.000Z"));
28 });
29});
30
31type TestThread = { readonly id: string } & ThreadSortInput;
32
33function makeThread(overrides: Partial<TestThread> = {}): TestThread {
34 return {
35 id: "thread-1",
36 createdAt: "2026-03-09T10:00:00.000Z",
37 updatedAt: "2026-03-09T10:00:00.000Z",
38 messages: [],
39 latestUserMessageAt: null,
40 ...overrides,
41 };
42}
43
44describe("resolveSettledThreadTimestamp", () => {
45 it("prefers the persisted settlement stamp over later activity", () => {
46 expect(
47 resolveSettledThreadTimestamp({
48 settledAt: "2026-03-09T10:00:00.000Z",
49 latestUserMessageAt: "2026-03-09T11:00:00.000Z",
50 latestRun: null,
51 updatedAt: "2026-03-09T12:00:00.000Z",
52 }),
53 ).toBe("2026-03-09T10:00:00.000Z");
54 });
55
56 it("falls back to the latest activity when the stamp is missing or malformed", () => {
57 expect(
58 resolveSettledThreadTimestamp({
59 settledAt: "invalid",
60 latestUserMessageAt: "2026-03-09T11:00:00.000Z",
61 latestRun: null,
62 updatedAt: "2026-03-09T12:00:00.000Z",
63 }),
64 ).toBe("2026-03-09T11:00:00.000Z");
65 expect(
66 resolveSettledThreadTimestamp({
67 settledAt: null,
68 latestUserMessageAt: null,
69 latestRun: null,
70 updatedAt: "2026-03-09T12:00:00.000Z",
71 }),
72 ).toBe("2026-03-09T12:00:00.000Z");
73 });
74});
75
76describe("sortSettledThreads", () => {
77 const settled = (input: {
78 id: string;
79 settledAt?: string | null;
80 latestUserMessageAt?: string | null;
81 latestRun?: SettledThreadTimestampInput["latestRun"];
82 updatedAt?: string;
83 }) => ({
84 id: input.id,
85 settledAt: input.settledAt ?? null,
86 latestUserMessageAt: input.latestUserMessageAt ?? null,
87 latestRun: input.latestRun ?? null,
88 updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z",
89 });
90
91 it("orders by settle time, most recently settled first", () => {
92 const sorted = sortSettledThreads([
93 settled({
94 id: "settled-first",
95 settledAt: "2026-03-09T10:00:00.000Z",
96 // Created/active later than the other thread: settle time must win.
97 latestUserMessageAt: "2026-03-09T09:59:00.000Z",
98 }),
99 settled({
100 id: "settled-last",
101 settledAt: "2026-03-09T12:00:00.000Z",
102 latestUserMessageAt: "2026-03-09T08:00:00.000Z",
103 }),
104 ]);
105
106 expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]);
107 });
108
109 it("falls back to last activity for auto-settled threads without a settledAt stamp", () => {
110 const sorted = sortSettledThreads([
111 settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }),
112 settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }),
113 settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }),
114 ]);
115
116 expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]);
117 });
118
119 it("counts a turn completion as activity for auto-settled threads", () => {
120 // The message came in before the other thread's, but its turn finished
121 // after: completion time is the real "work ended" moment.
122 const sorted = sortSettledThreads([
123 settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }),
124 settled({
125 id: "completed-later",
126 latestUserMessageAt: "2026-03-09T10:00:00.000Z",
127 latestRun: {
128 runId: RunId.make("run-1"),
129 status: "completed",
130 assistantMessageId: null,
131 requestedAt: "2026-03-09T10:00:00.000Z",
132 startedAt: "2026-03-09T10:00:00.000Z",
133 completedAt: "2026-03-09T10:30:00.000Z",
134 },
135 }),
136 ]);
137
138 expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]);
139 });
140
141 it("breaks timestamp ties by id so the order is stable", () => {
142 const sorted = sortSettledThreads([
143 settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }),
144 settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }),
145 ]);
146
147 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
148 });
149
150 it("matches the per-comparison order on a shuffled list with ties", () => {
151 const stamps = [
152 { settledAt: "2026-03-09T10:00:00.000Z" },
153 { settledAt: "invalid", latestUserMessageAt: "2026-03-09T10:00:00.000Z" },
154 { latestUserMessageAt: "2026-03-09T11:00:00.000Z" },
155 { updatedAt: "2026-03-09T09:00:00.000Z" },
156 { updatedAt: "invalid" },
157 ];
158 // Ids repeat every 3 rows and stamps every 5, so rows tie on the time,
159 // on the id, and on both. (index * 7) % 30 scrambles the input order.
160 const threads = Array.from({ length: 30 }, (_, index) => {
161 const row = (index * 7) % 30;
162 return { ...settled({ id: `thread-${row % 3}`, ...stamps[row % 5] }), row };
163 });
164 // The comparator this sort replaced: it resolved both keys on every call.
165 const timestampMs = (thread: SettledThreadTimestampInput) => {
166 const timestamp = resolveSettledThreadTimestamp(thread);
167 return timestamp === null ? 0 : Date.parse(timestamp);
168 };
169 const expected = threads.toSorted(
170 (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id),
171 );
172
173 expect(sortSettledThreads(threads).map((thread) => thread.row)).toEqual(
174 expected.map((thread) => thread.row),
175 );
176 });
177});
178
179describe("sortThreads", () => {
180 it.each(["created_at", "updated_at"] as const)(
181 "preserves references, input order and descending id ties for %s",
182 (sortOrder) => {
183 const threads = Object.freeze([
184 makeThread({ id: "a" }),
185 makeThread({ id: "z" }),
186 makeThread({ id: "invalid-a", createdAt: "invalid", updatedAt: "invalid" }),
187 makeThread({ id: "invalid-z", createdAt: "invalid", updatedAt: "invalid" }),
188 ]);
189 const sorted = sortThreads(threads, sortOrder);
190 expect(sorted).toEqual([threads[1], threads[0], threads[3], threads[2]]);
191 expect(sorted[0]).toBe(threads[1]);
192 expect(threads[0]?.id).toBe("a");
193 },
194 );
195
196 it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => {
197 const sorted = sortThreads(
198 [
199 makeThread({
200 id: "thread-1",
201 latestUserMessageAt: "not-a-date",
202 createdAt: "2026-03-09T10:00:00.000Z",
203 updatedAt: "2026-03-09T10:05:00.000Z",
204 }),
205 makeThread({
206 id: "thread-2",
207 latestUserMessageAt: "still-not-a-date",
208 createdAt: "invalid-created-at",
209 updatedAt: "invalid-updated-at",
210 }),
211 makeThread({
212 id: "thread-3",
213 latestUserMessageAt: "invalid-latest-user-message-at",
214 createdAt: "2026-03-09T10:06:00.000Z",
215 updatedAt: "invalid-updated-at",
216 }),
217 ],
218 "updated_at",
219 );
220
221 expect(sorted.map((thread) => thread.id)).toEqual(["thread-3", "thread-1", "thread-2"]);
222 });
223
224 it("falls back to the latest valid user message when latestUserMessageAt is invalid", () => {
225 const sorted = sortThreads(
226 [
227 makeThread({
228 id: "thread-1",
229 latestUserMessageAt: "invalid-latest-user-message-at",
230 updatedAt: "2026-03-09T10:00:00.000Z",
231 messages: [
232 { role: "user", createdAt: "2026-03-09T10:05:00.000Z" },
233 { role: "assistant", createdAt: "2026-03-09T10:30:00.000Z" },
234 { role: "user", createdAt: "2026-03-09T10:20:00.000Z" },
235 ],
236 }),
237 makeThread({
238 id: "thread-2",
239 createdAt: "2026-03-09T10:15:00.000Z",
240 updatedAt: "2026-03-09T10:15:00.000Z",
241 }),
242 ],
243 "updated_at",
244 );
245
246 expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
247 });
248});
249
250describe("getLatestThreadForProject", () => {
251 it.each(["created_at", "updated_at"] as const)(
252 "matches the first sorted eligible thread for %s",
253 (sortOrder) => {
254 const projectId = ProjectId.make("project");
255 const threads = [
256 { ...makeThread({ id: "a" }), projectId, archivedAt: null },
257 { ...makeThread({ id: "z" }), projectId, archivedAt: null },
258 { ...makeThread({ id: "zz" }), projectId, archivedAt: "2026-03-10T00:00:00Z" },
259 { ...makeThread({ id: "zzz" }), projectId: ProjectId.make("other"), archivedAt: null },
260 ];
261 expect(getLatestThreadForProject(threads, projectId, sortOrder)).toBe(threads[1]);
262 expect(getLatestThreadForProject([], projectId, sortOrder)).toBeNull();
263 expect(getLatestThreadForProject(threads, ProjectId.make("missing"), sortOrder)).toBeNull();
264 const invalid = threads.slice(0, 2).map((thread) => ({
265 ...thread,
266 createdAt: "invalid",
267 updatedAt: "invalid",
268 }));
269 expect(getLatestThreadForProject(invalid, projectId, sortOrder)).toBe(invalid[1]);
270 expect(
271 getLatestThreadForProject([threads[1]!, { ...threads[1]! }], projectId, sortOrder),
272 ).toBe(threads[1]);
273 },
274 );
275});
276
277describe("planPinnedReorder with hidden rows", () => {
278 it("keeps hidden slots available when inserting between visible neighbors", () => {
279 const midpoint = pinOrderKeyBetween("f", "t")!;
280 const keysById = new Map([
281 ["a", "f"],
282 ["b", "t"],
283 ["moved", "z"],
284 ["snoozed", midpoint],
285 ]);
286 const assignments = planPinnedReorder({
287 orderedIds: ["a", "moved", "b"],
288 keysById,
289 movedId: "moved",
290 });
291 expect(assignments).toHaveLength(1);
292 const key = assignments[0]!.orderKey;
293 expect(key > "f" && key < "t").toBe(true);
294 expect(key).not.toBe(midpoint);
295 expect(assignments[0]!.id).toBe("moved");
296 });
297
298 it("materializes keyless rows without overwriting hidden slots", () => {
299 const reserved = generateSpreadPinOrderKeys(6);
300 const keysById = new Map<string, string | null>([
301 ["a", null],
302 ["b", null],
303 ["c", null],
304 ...reserved.map((key, i) => [`hidden-${i}`, key] as const),
305 ]);
306 const assignments = planPinnedReorder({ orderedIds: ["c", "a", "b"], keysById, movedId: "c" });
307 expect(assignments.map(({ id }) => id)).toEqual(["c", "a", "b"]);
308 const keys = assignments.map(({ orderKey }) => orderKey);
309 expect(keys).toEqual([...keys].sort());
310 expect(new Set(keys).size).toBe(3);
311 expect(keys.every((key) => !reserved.includes(key))).toBe(true);
312 });
313});
314
315describe("planPinnedMove", () => {
316 it("moves a thread up with a single key write", () => {
317 const assignments = planPinnedMove({
318 orderedIds: ["a", "b", "c"],
319 keysById: new Map([
320 ["a", "f"],
321 ["b", "m"],
322 ["c", "t"],
323 ]),
324 movedId: "c",
325 direction: "up",
326 });
327 expect(assignments).toHaveLength(1);
328 expect(assignments![0]!.id).toBe("c");
329 expect(assignments![0]!.orderKey > "f" && assignments![0]!.orderKey < "m").toBe(true);
330 });
331
332 it("returns null when the move falls off the end of the list", () => {
333 const input = {
334 orderedIds: ["a", "b"],
335 keysById: new Map([
336 ["a", "f"],
337 ["b", "m"],
338 ]),
339 };
340 expect(planPinnedMove({ ...input, movedId: "a", direction: "up" })).toBeNull();
341 expect(planPinnedMove({ ...input, movedId: "b", direction: "down" })).toBeNull();
342 });
343
344 it("materializes keys for the whole section when a neighbor is keyless", () => {
345 const assignments = planPinnedMove({
346 orderedIds: ["a", "b", "c"],
347 keysById: new Map([
348 ["a", null],
349 ["b", "m"],
350 ["c", null],
351 ]),
352 movedId: "b",
353 direction: "up",
354 });
355 expect(assignments).not.toBeNull();
356 const keys = assignments!.map((entry) => entry.orderKey);
357 expect([...keys].sort()).toEqual(keys);
358 });
359});
360
361describe("sortPinnedThreadsByOrderKey", () => {
362 it("breaks equal keys by id THEN environment so merged lists are stable everywhere", () => {
363 const sorted = sortPinnedThreadsByOrderKey([
364 {
365 id: "thread-1",
366 createdAt: "2026-03-09T10:00:00.000Z",
367 pinOrderKey: "m",
368 environmentId: "env-b",
369 },
370 {
371 id: "thread-1",
372 createdAt: "2026-03-09T11:00:00.000Z",
373 pinOrderKey: "m",
374 environmentId: "env-a",
375 },
376 ]);
377 expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]);
378 });
379});
380
381describe("generateSpreadPinOrderKeys", () => {
382 it.each([0, 1, 650, 675, 676, 1_001, 2_000])(
383 "leaves unique, insertable keys for %i threads",
384 (count) => {
385 const keys = generateSpreadPinOrderKeys(count);
386 expect(keys).toHaveLength(count);
387 expect(new Set(keys).size).toBe(count);
388 expect([...keys].sort()).toEqual(keys);
389 for (let index = 0; index < keys.length; index += 1) {
390 const before = keys[index - 1] ?? null;
391 const after = keys[index]!;
392 expect(after).toMatch(/^[a-z]*[b-z]$/);
393 const between = pinOrderKeyBetween(before, after);
394 expect(between).not.toBeNull();
395 expect(between! < after).toBe(true);
396 if (before !== null) expect(between! > before).toBe(true);
397 }
398 },
399 );
400});
401
402describe("sortActiveThreadsByOrderKey", () => {
403 it("keeps new and reopened threads ahead of the saved order", () => {
404 const sorted = sortActiveThreadsByOrderKey([
405 {
406 id: "arranged-first",
407 createdAt: "2026-03-09T09:00:00.000Z",
408 activeOrderKey: "f",
409 },
410 {
411 id: "new",
412 createdAt: "2026-03-09T11:00:00.000Z",
413 activeOrderKey: null,
414 },
415 {
416 id: "arranged-last",
417 createdAt: "2026-03-09T12:00:00.000Z",
418 unsettledAt: "2026-03-09T13:00:00.000Z",
419 activeOrderKey: "t",
420 },
421 {
422 id: "reopened",
423 createdAt: "2026-03-01T09:00:00.000Z",
424 unsettledAt: "2026-03-09T12:00:00.000Z",
425 },
426 ]);
427 expect(sorted.map((thread) => thread.id)).toEqual([
428 "reopened",
429 "new",
430 "arranged-first",
431 "arranged-last",
432 ]);
433 });
434
435 it("breaks equal order keys and timestamps by thread then environment", () => {
436 for (const activeOrderKey of [null, "m"]) {
437 const threads = [
438 { id: "thread-b", environmentId: "env-a" },
439 { id: "thread-a", environmentId: "env-b" },
440 { id: "thread-a", environmentId: "env-a" },
441 ].map((thread) => ({
442 ...thread,
443 createdAt: "2026-03-09T10:00:00.000Z",
444 activeOrderKey,
445 }));
446 expect(
447 sortActiveThreadsByOrderKey(threads).map(
448 (thread) => `${thread.id}:${thread.environmentId}`,
449 ),
450 ).toEqual(["thread-a:env-a", "thread-a:env-b", "thread-b:env-a"]);
451 }
452 });
453
454 it("applies every move across a mixed keyless and keyed section", () => {
455 const threads = Array.from({ length: 6 }, (_, index) => ({
456 id: String(index),
457 createdAt: `2026-03-09T0${6 - index}:00:00.000Z`,
458 activeOrderKey: index < 3 ? null : ["f", "m", "t"][index - 3]!,
459 }));
460 const ids = threads.map((thread) => thread.id);
461 const keysById = new Map(threads.map((thread) => [thread.id, thread.activeOrderKey]));
462 for (const movedId of ids) {
463 for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) {
464 const desired = ids.filter((id) => id !== movedId);
465 desired.splice(targetIndex, 0, movedId);
466 const assignments = planPinnedReorder({ orderedIds: desired, keysById, movedId });
467 const nextKeys = new Map(
468 assignments.map((assignment) => [assignment.id, assignment.orderKey]),
469 );
470 const updated = threads.map((thread) => ({
471 ...thread,
472 activeOrderKey: nextKeys.get(thread.id) ?? thread.activeOrderKey,
473 }));
474 expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(desired);
475 }
476 }
477 });
478
479 it("moves a keyless thread into the arranged run with one write", () => {
480 const assignments = planPinnedMove({
481 orderedIds: ["new", "reopened", "first", "last"],
482 keysById: new Map([
483 ["new", null],
484 ["reopened", null],
485 ["first", "f"],
486 ["last", "t"],
487 ]),
488 movedId: "reopened",
489 direction: "down",
490 });
491 expect(assignments).toHaveLength(1);
492 expect(assignments![0]!.id).toBe("reopened");
493 expect(assignments![0]!.orderKey > "f").toBe(true);
494 expect(assignments![0]!.orderKey < "t").toBe(true);
495 });
496
497 it("materializes a large active list without changing the requested order", () => {
498 const threads = Array.from({ length: 1_200 }, (_, index) => ({
499 id: String(index),
500 createdAt: "2026-03-09T10:00:00.000Z",
501 activeOrderKey: null as string | null,
502 }));
503 const orderedIds = threads.map((thread) => thread.id).toReversed();
504 const assignments = planPinnedReorder({
505 orderedIds,
506 movedId: orderedIds[0]!,
507 keysById: new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])),
508 });
509 const keys = new Map(assignments.map((assignment) => [assignment.id, assignment.orderKey]));
510 const updated = threads.map((thread) => ({ ...thread, activeOrderKey: keys.get(thread.id) }));
511 expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds);
512 });
513});