Skip to content
Source
Sign inSign up

threadSort.test.ts

304 lines9.9 KB

1import { RunId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3​
4import {
5 generateSpreadPinOrderKeys,
6 pinOrderKeyBetween,
7 planPinnedReorder,
8 resolveSettledThreadTimestamp,
9 sortActiveThreadsByOrderKey,
10 sortSettledThreads,
11 sortThreads,
12 type SettledThreadTimestampInput,
13 type ThreadSortInput,
14} from "./threadSort.ts";
15​
16type TestThread = { readonly id: string } & ThreadSortInput;
17​
18function makeThread(overrides: Partial<TestThread> = {}): TestThread {
19 return {
20 id: "thread-1",
21 createdAt: "2026-03-09T10:00:00.000Z",
22 updatedAt: "2026-03-09T10:00:00.000Z",
23 messages: [],
24 latestUserMessageAt: null,
25 ...overrides,
26 };
28​
29describe("resolveSettledThreadTimestamp", () => {
30 it("prefers the persisted settlement stamp over later activity", () => {
31 expect(
32 resolveSettledThreadTimestamp({
33 settledAt: "2026-03-09T10:00:00.000Z",
34 latestUserMessageAt: "2026-03-09T11:00:00.000Z",
35 latestRun: null,
36 updatedAt: "2026-03-09T12:00:00.000Z",
37 }),
38 ).toBe("2026-03-09T10:00:00.000Z");
39 });
40​
41 it("falls back to the latest activity when the stamp is missing or malformed", () => {
42 expect(
43 resolveSettledThreadTimestamp({
44 settledAt: "invalid",
45 latestUserMessageAt: "2026-03-09T11:00:00.000Z",
46 latestRun: null,
47 updatedAt: "2026-03-09T12:00:00.000Z",
48 }),
49 ).toBe("2026-03-09T11:00:00.000Z");
50 expect(
51 resolveSettledThreadTimestamp({
52 settledAt: null,
53 latestUserMessageAt: null,
54 latestRun: null,
55 updatedAt: "2026-03-09T12:00:00.000Z",
56 }),
57 ).toBe("2026-03-09T12:00:00.000Z");
58 });
59});
60​
61describe("sortSettledThreads", () => {
62 const settled = (input: {
63 id: string;
64 settledAt?: string | null;
65 latestUserMessageAt?: string | null;
66 latestRun?: SettledThreadTimestampInput["latestRun"];
67 updatedAt?: string;
68 }) => ({
69 id: input.id,
70 settledAt: input.settledAt ?? null,
71 latestUserMessageAt: input.latestUserMessageAt ?? null,
72 latestRun: input.latestRun ?? null,
73 updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z",
74 });
75​
76 it("counts a turn completion as activity for auto-settled threads", () => {
77 const sorted = sortSettledThreads([
78 settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }),
79 settled({
80 id: "completed-later",
81 latestUserMessageAt: "2026-03-09T10:00:00.000Z",
82 latestRun: {
83 runId: RunId.make("run-1"),
84 status: "completed",
85 assistantMessageId: null,
86 requestedAt: "2026-03-09T10:00:00.000Z",
87 startedAt: "2026-03-09T10:00:00.000Z",
88 completedAt: "2026-03-09T10:30:00.000Z",
89 },
90 }),
91 ]);
92​
93 expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]);
94 });
95​
96 it("breaks timestamp ties by id so the order is stable", () => {
97 const sorted = sortSettledThreads([
98 settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }),
99 settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }),
100 ]);
101​
102 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
103 });
104});
105​
106describe("sortThreads", () => {
107 it.each(["created_at", "updated_at"] as const)(
108 "preserves references, input order and descending id ties for %s",
109 (sortOrder) => {
110 const threads = Object.freeze([
111 makeThread({ id: "a" }),
112 makeThread({ id: "z" }),
113 makeThread({ id: "invalid-a", createdAt: "invalid", updatedAt: "invalid" }),
114 makeThread({ id: "invalid-z", createdAt: "invalid", updatedAt: "invalid" }),
115 ]);
116 const sorted = sortThreads(threads, sortOrder);
117 expect(sorted).toEqual([threads[1], threads[0], threads[3], threads[2]]);
118 expect(sorted[0]).toBe(threads[1]);
119 expect(threads[0]?.id).toBe("a");
120 },
121 );
122​
123 it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => {
124 const sorted = sortThreads(
125 [
126 makeThread({
127 id: "thread-1",
128 latestUserMessageAt: "not-a-date",
129 createdAt: "2026-03-09T10:00:00.000Z",
130 updatedAt: "2026-03-09T10:05:00.000Z",
131 }),
132 makeThread({
133 id: "thread-2",
134 latestUserMessageAt: "still-not-a-date",
135 createdAt: "invalid-created-at",
136 updatedAt: "invalid-updated-at",
137 }),
138 makeThread({
139 id: "thread-3",
140 latestUserMessageAt: "invalid-latest-user-message-at",
141 createdAt: "2026-03-09T10:06:00.000Z",
142 updatedAt: "invalid-updated-at",
143 }),
144 ],
145 "updated_at",
146 );
147​
148 expect(sorted.map((thread) => thread.id)).toEqual(["thread-3", "thread-1", "thread-2"]);
149 });
150​
151 it("falls back to the latest valid user message when latestUserMessageAt is invalid", () => {
152 const sorted = sortThreads(
153 [
154 makeThread({
155 id: "thread-1",
156 latestUserMessageAt: "invalid-latest-user-message-at",
157 updatedAt: "2026-03-09T10:00:00.000Z",
158 messages: [
159 { role: "user", createdAt: "2026-03-09T10:05:00.000Z" },
160 { role: "assistant", createdAt: "2026-03-09T10:30:00.000Z" },
161 { role: "user", createdAt: "2026-03-09T10:20:00.000Z" },
162 ],
163 }),
164 makeThread({
165 id: "thread-2",
166 createdAt: "2026-03-09T10:15:00.000Z",
167 updatedAt: "2026-03-09T10:15:00.000Z",
168 }),
169 ],
170 "updated_at",
171 );
172​
173 expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]);
174 });
175});
176​
177describe("planPinnedReorder with hidden rows", () => {
178 it("keeps hidden slots available when inserting between visible neighbors", () => {
179 const midpoint = pinOrderKeyBetween("f", "t")!;
180 const keysById = new Map([
181 ["a", "f"],
182 ["b", "t"],
183 ["moved", "z"],
184 ["snoozed", midpoint],
185 ]);
186 const assignments = planPinnedReorder({
187 orderedIds: ["a", "moved", "b"],
188 keysById,
189 movedId: "moved",
190 });
191 expect(assignments).toHaveLength(1);
192 const key = assignments[0]!.orderKey;
193 expect(key > "f" && key < "t").toBe(true);
194 expect(key).not.toBe(midpoint);
195 expect(assignments[0]!.id).toBe("moved");
196 });
197​
198 it("materializes keyless rows without overwriting hidden slots", () => {
199 const reserved = generateSpreadPinOrderKeys(6);
200 const keysById = new Map<string, string | null>([
201 ["a", null],
202 ["b", null],
203 ["c", null],
204 ...reserved.map((key, i) => [`hidden-${i}`, key] as const),
205 ]);
206 const assignments = planPinnedReorder({ orderedIds: ["c", "a", "b"], keysById, movedId: "c" });
207 expect(assignments.map(({ id }) => id)).toEqual(["c", "a", "b"]);
208 const keys = assignments.map(({ orderKey }) => orderKey);
209 expect(keys).toEqual([...keys].sort());
210 expect(new Set(keys).size).toBe(3);
211 expect(keys.every((key) => !reserved.includes(key))).toBe(true);
212 });
213});
214​
215describe("generateSpreadPinOrderKeys", () => {
216 it.each([0, 1, 650, 675, 676, 1_001, 2_000])(
217 "leaves unique, insertable keys for %i threads",
218 (count) => {
219 const keys = generateSpreadPinOrderKeys(count);
220 expect(keys).toHaveLength(count);
221 expect(new Set(keys).size).toBe(count);
222 expect([...keys].sort()).toEqual(keys);
223 for (let index = 0; index < keys.length; index += 1) {
224 const before = keys[index - 1] ?? null;
225 const after = keys[index]!;
226 expect(after).toMatch(/^[a-z]*[b-z]$/);
227 const between = pinOrderKeyBetween(before, after);
228 expect(between).not.toBeNull();
229 expect(between! < after).toBe(true);
230 if (before !== null) expect(between! > before).toBe(true);
231 }
232 },
233 );
234});
235​
236describe("sortActiveThreadsByOrderKey", () => {
237 it("keeps new and reopened threads ahead of the saved order", () => {
238 const sorted = sortActiveThreadsByOrderKey([
239 {
240 id: "arranged-first",
241 createdAt: "2026-03-09T09:00:00.000Z",
242 activeOrderKey: "f",
243 },
244 {
245 id: "new",
246 createdAt: "2026-03-09T11:00:00.000Z",
247 activeOrderKey: null,
248 },
249 {
250 id: "arranged-last",
251 createdAt: "2026-03-09T12:00:00.000Z",
252 unsettledAt: "2026-03-09T13:00:00.000Z",
253 activeOrderKey: "t",
254 },
255 {
256 id: "reopened",
257 createdAt: "2026-03-01T09:00:00.000Z",
258 unsettledAt: "2026-03-09T12:00:00.000Z",
259 },
260 ]);
261 expect(sorted.map((thread) => thread.id)).toEqual([
262 "reopened",
263 "new",
264 "arranged-first",
265 "arranged-last",
266 ]);
267 });
268​
269 it("breaks equal order keys and timestamps by thread then environment", () => {
270 for (const activeOrderKey of [null, "m"]) {
271 const threads = [
272 { id: "thread-b", environmentId: "env-a" },
273 { id: "thread-a", environmentId: "env-b" },
274 { id: "thread-a", environmentId: "env-a" },
275 ].map((thread) => ({
276 ...thread,
277 createdAt: "2026-03-09T10:00:00.000Z",
278 activeOrderKey,
279 }));
280 expect(
281 sortActiveThreadsByOrderKey(threads).map(
282 (thread) => `${thread.id}:${thread.environmentId}`,
283 ),
284 ).toEqual(["thread-a:env-a", "thread-a:env-b", "thread-b:env-a"]);
285 }
286 });
287​
288 it("materializes a large active list without changing the requested order", () => {
289 const threads = Array.from({ length: 1_200 }, (_, index) => ({
290 id: String(index),
291 createdAt: "2026-03-09T10:00:00.000Z",
292 activeOrderKey: null as string | null,
293 }));
294 const orderedIds = threads.map((thread) => thread.id).toReversed();
295 const assignments = planPinnedReorder({
296 orderedIds,
297 movedId: orderedIds[0]!,
298 keysById: new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])),
299 });
300 const keys = new Map(assignments.map((assignment) => [assignment.id, assignment.orderKey]));
301 const updated = threads.map((thread) => ({ ...thread, activeOrderKey: keys.get(thread.id) }));
302 expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds);
303 });
304});