Skip to content
Source
Sign inSign up

threadSort.test.ts

324 lines10.6 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 it("orders last user message by what the user wrote, not agent messages in the user role", () => {
177 const sorted = sortThreads(
178 [
179 makeThread({
180 id: "agent-posted",
181 latestUserAuthoredMessageAt: "2026-03-09T10:00:00.000Z",
182 latestUserMessageAt: "2026-03-09T10:30:00.000Z",
183 }),
184 makeThread({
185 id: "user-wrote",
186 latestUserAuthoredMessageAt: "2026-03-09T10:10:00.000Z",
187 latestUserMessageAt: "2026-03-09T10:10:00.000Z",
188 }),
189 ],
190 "updated_at",
191 );
192​
193 expect(sorted.map((thread) => thread.id)).toEqual(["user-wrote", "agent-posted"]);
194 });
195});
196​
197describe("planPinnedReorder with hidden rows", () => {
198 it("keeps hidden slots available when inserting between visible neighbors", () => {
199 const midpoint = pinOrderKeyBetween("f", "t")!;
200 const keysById = new Map([
201 ["a", "f"],
202 ["b", "t"],
203 ["moved", "z"],
204 ["snoozed", midpoint],
205 ]);
206 const assignments = planPinnedReorder({
207 orderedIds: ["a", "moved", "b"],
208 keysById,
209 movedId: "moved",
210 });
211 expect(assignments).toHaveLength(1);
212 const key = assignments[0]!.orderKey;
213 expect(key > "f" && key < "t").toBe(true);
214 expect(key).not.toBe(midpoint);
215 expect(assignments[0]!.id).toBe("moved");
216 });
217​
218 it("materializes keyless rows without overwriting hidden slots", () => {
219 const reserved = generateSpreadPinOrderKeys(6);
220 const keysById = new Map<string, string | null>([
221 ["a", null],
222 ["b", null],
223 ["c", null],
224 ...reserved.map((key, i) => [`hidden-${i}`, key] as const),
225 ]);
226 const assignments = planPinnedReorder({ orderedIds: ["c", "a", "b"], keysById, movedId: "c" });
227 expect(assignments.map(({ id }) => id)).toEqual(["c", "a", "b"]);
228 const keys = assignments.map(({ orderKey }) => orderKey);
229 expect(keys).toEqual([...keys].sort());
230 expect(new Set(keys).size).toBe(3);
231 expect(keys.every((key) => !reserved.includes(key))).toBe(true);
232 });
233});
234​
235describe("generateSpreadPinOrderKeys", () => {
236 it.each([0, 1, 650, 675, 676, 1_001, 2_000])(
237 "leaves unique, insertable keys for %i threads",
238 (count) => {
239 const keys = generateSpreadPinOrderKeys(count);
240 expect(keys).toHaveLength(count);
241 expect(new Set(keys).size).toBe(count);
242 expect([...keys].sort()).toEqual(keys);
243 for (let index = 0; index < keys.length; index += 1) {
244 const before = keys[index - 1] ?? null;
245 const after = keys[index]!;
246 expect(after).toMatch(/^[a-z]*[b-z]$/);
247 const between = pinOrderKeyBetween(before, after);
248 expect(between).not.toBeNull();
249 expect(between! < after).toBe(true);
250 if (before !== null) expect(between! > before).toBe(true);
251 }
252 },
253 );
254});
255​
256describe("sortActiveThreadsByOrderKey", () => {
257 it("keeps new and reopened threads ahead of the saved order", () => {
258 const sorted = sortActiveThreadsByOrderKey([
259 {
260 id: "arranged-first",
261 createdAt: "2026-03-09T09:00:00.000Z",
262 activeOrderKey: "f",
263 },
264 {
265 id: "new",
266 createdAt: "2026-03-09T11:00:00.000Z",
267 activeOrderKey: null,
268 },
269 {
270 id: "arranged-last",
271 createdAt: "2026-03-09T12:00:00.000Z",
272 unsettledAt: "2026-03-09T13:00:00.000Z",
273 activeOrderKey: "t",
274 },
275 {
276 id: "reopened",
277 createdAt: "2026-03-01T09:00:00.000Z",
278 unsettledAt: "2026-03-09T12:00:00.000Z",
279 },
280 ]);
281 expect(sorted.map((thread) => thread.id)).toEqual([
282 "reopened",
283 "new",
284 "arranged-first",
285 "arranged-last",
286 ]);
287 });
288​
289 it("breaks equal order keys and timestamps by thread then environment", () => {
290 for (const activeOrderKey of [null, "m"]) {
291 const threads = [
292 { id: "thread-b", environmentId: "env-a" },
293 { id: "thread-a", environmentId: "env-b" },
294 { id: "thread-a", environmentId: "env-a" },
295 ].map((thread) => ({
296 ...thread,
297 createdAt: "2026-03-09T10:00:00.000Z",
298 activeOrderKey,
299 }));
300 expect(
301 sortActiveThreadsByOrderKey(threads).map(
302 (thread) => `${thread.id}:${thread.environmentId}`,
303 ),
304 ).toEqual(["thread-a:env-a", "thread-a:env-b", "thread-b:env-a"]);
305 }
306 });
307​
308 it("materializes a large active list without changing the requested order", () => {
309 const threads = Array.from({ length: 1_200 }, (_, index) => ({
310 id: String(index),
311 createdAt: "2026-03-09T10:00:00.000Z",
312 activeOrderKey: null as string | null,
313 }));
314 const orderedIds = threads.map((thread) => thread.id).toReversed();
315 const assignments = planPinnedReorder({
316 orderedIds,
317 movedId: orderedIds[0]!,
318 keysById: new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])),
319 });
320 const keys = new Map(assignments.map((assignment) => [assignment.id, assignment.orderKey]));
321 const updated = threads.map((thread) => ({ ...thread, activeOrderKey: keys.get(thread.id) }));
322 expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds);
323 });
324});