Skip to content
Source
Sign inSign up

threadSearch.test.ts

249 lines9.1 KB

1import {
2 EnvironmentId,
3 ProjectId,
4 ThreadId,
5 type OrchestrationSearchThreadsResult,
6 type OrchestrationV2SearchThreadsInput,
7 type OrchestrationV2ThreadShell,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
11import { expect, it } from "vite-plus/test";
12​
13import {
14 claimThreadOpen,
15 nextShownOrder,
16 createThreadSearchResultsAtomFamily,
17 makeThreadSearchKey,
18 threadSearchMatchKey,
19 threadSearchSubject,
20 visibleThreadSearch,
21} from "./threadSearch.ts";
22​
23const envA = EnvironmentId.make("env-a");
24const envB = EnvironmentId.make("env-b");
25​
26it("creates keys without array methods unavailable in Hermes", () => {
27 const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted");
28 Reflect.deleteProperty(Array.prototype, "toSorted");
29​
30 try {
31 expect(makeThreadSearchKey([envB, envA], "needle")).toBe('[["env-a","env-b"],"needle"]');
32 } finally {
33 if (descriptor !== undefined) {
34 Reflect.defineProperty(Array.prototype, "toSorted", descriptor);
35 }
36 }
37});
38​
39it("encodes scoped thread keys without delimiter collisions", () => {
40 const first = threadSearchMatchKey({
41 environmentId: EnvironmentId.make("env\u0000thread"),
42 threadId: ThreadId.make("id"),
43 });
44 const second = threadSearchMatchKey({
45 environmentId: EnvironmentId.make("env"),
46 threadId: ThreadId.make("thread\u0000id"),
47 });
48​
49 expect(first).not.toBe(second);
50});
51​
52it("ignores invalid search keys", () => {
53 let searchCount = 0;
54 const searchAtom = createThreadSearchResultsAtomFamily<Error>({
55 getSearchAtom: () => {
56 searchCount += 1;
57 return Atom.make(AsyncResult.success({ matches: [] }));
58 },
59 labelPrefix: "test:thread-search",
60 }).results;
61 const registry = AtomRegistry.make();
62​
63 try {
64 const state = registry.get(searchAtom(makeThreadSearchKey([envA], "a".repeat(201))));
65 const malformedState = registry.get(searchAtom("not json{{"));
66​
67 expect(state).toEqual({ matches: [], isLoading: false, rows: [] });
68 expect(malformedState).toEqual({ matches: [], isLoading: false, rows: [] });
69 expect(searchCount).toBe(0);
70 } finally {
71 registry.dispose();
72 }
73});
74​
75it("merges successful environments and silently ignores failures", () => {
76 const result: OrchestrationSearchThreadsResult = {
77 matches: [
78 {
79 threadId: ThreadId.make("thread-a"),
80 projectId: ProjectId.make("project-a"),
81 source: "user",
82 snippet: "needle",
83 messageCreatedAt: "2026-07-30T00:00:00.000Z",
84 },
85 ],
86 };
87 const searchAtom = createThreadSearchResultsAtomFamily<Error>({
88 getSearchAtom: (environmentId) =>
89 environmentId === envA
90 ? Atom.make(AsyncResult.success(result))
91 : Atom.make(
92 AsyncResult.failure<OrchestrationSearchThreadsResult, Error>(
93 Cause.fail(new Error("unsupported rpc")),
94 ),
95 ),
96 labelPrefix: "test:thread-search",
97 }).results;
98 const registry = AtomRegistry.make();
99​
100 const state = registry.get(searchAtom(makeThreadSearchKey([envB, envA], "needle")));
101 expect(state).toEqual({
102 matches: [{ ...result.matches[0], environmentId: envA }],
103 isLoading: false,
104 rows: [],
105 });
106 expect(threadSearchMatchKey(state.matches[0]!)).toBe('["env-a","thread-a"]');
107​
108 registry.dispose();
109});
110​
111it("asks environments that omit older settled threads for their rows, with the caller's scope", () => {
112 const inputs: Array<readonly [EnvironmentId, OrchestrationV2SearchThreadsInput]> = [];
113 const row = { id: ThreadId.make("thread-old") } as OrchestrationV2ThreadShell;
114 const search = createThreadSearchResultsAtomFamily<Error>({
115 getSearchAtom: (environmentId, input) => {
116 inputs.push([environmentId, input]);
117 return Atom.make(AsyncResult.success({ matches: [], snapshotSequence: 9, threads: [row] }));
118 },
119 olderSettledBefore: (environmentId) =>
120 Atom.make(environmentId === envA ? "2026-10-01T00:00:00.000Z" : null),
121 labelPrefix: "test:thread-search",
122 });
123 const registry = AtomRegistry.make();
124 const scope = {
125 projectIds: [ProjectId.make("project-a")],
126 environmentLabels: { [envA]: "Laptop" },
127 };
128​
129 // Too short to match messages: only the environment with older rows is asked, for titles.
130 const key = makeThreadSearchKey([envA, envB], "ui", scope);
131 expect(registry.get(search.results(key)).rows).toEqual([
132 { environmentId: envA, read: { snapshotSequence: 9, threads: [row] } },
133 ]);
134 expect(registry.get(search.rows(key, envA))).toEqual([{ snapshotSequence: 9, threads: [row] }]);
135 expect(inputs).toEqual([
136 [
137 envA,
138 {
139 query: "ui",
140 olderSettledBefore: "2026-10-01T00:00:00.000Z",
141 projectIds: scope.projectIds,
142 environmentLabel: "Laptop",
143 titlesOnly: true,
144 },
145 ],
146 ]);
147 inputs.length = 0;
148 registry.get(search.results(makeThreadSearchKey([envB], "needle")));
149 expect(inputs).toEqual([[envB, { query: "needle" }]]);
150​
151 registry.dispose();
152});
153​
154it("keeps the previous results visible only while a refined or widened query is pending", () => {
155 const previous = {
156 query: "websock",
157 matches: [
158 {
159 environmentId: envA,
160 threadId: ThreadId.make("thread-a"),
161 projectId: ProjectId.make("project-a"),
162 source: "user" as const,
163 snippet: "websocket reconnect",
164 messageCreatedAt: null,
165 },
166 ],
167 };
168 expect(visibleThreadSearch("websocke", null, previous)).toBe(previous);
169 expect(visibleThreadSearch("webso", null, previous)).toBe(previous);
170 expect(visibleThreadSearch("billing", null, previous)).toEqual({ query: "", matches: [] });
171 const ready = { query: "websocke", matches: [] };
172 expect(visibleThreadSearch("websocke", ready, previous)).toBe(ready);
173});
174​
175it("ranks recent threads by this client's own latest open", () => {
176 const thread = {
177 environmentId: envA,
178 id: ThreadId.make("thread-local"),
179 title: "Local",
180 branch: null,
181 createdAt: "2026-10-01T00:00:00.000Z",
182 updatedAt: "2026-10-01T00:00:00.000Z",
183 latestUserMessageAt: null,
184 opens: { openedAt: 1_000, score: 1, picks: [] },
185 };
186 claimThreadOpen(envA, "thread-local", 5_000);
187 expect(threadSearchSubject(thread, null).opens?.openedAt).toBe(5_000);
188 // Also when the server has no record of opens, as an older server would not.
189 claimThreadOpen(envA, "thread-unrecorded", 6_000);
190 expect(
191 threadSearchSubject({ ...thread, id: ThreadId.make("thread-unrecorded"), opens: null }, null)
192 .opens,
193 ).toEqual({ openedAt: 6_000, score: 1, picks: [] });
194 // A subject built before the open does not hide it.
195 claimThreadOpen(envA, "thread-local", 7_000);
196 expect(threadSearchSubject(thread, null).opens?.openedAt).toBe(7_000);
197 expect(threadSearchSubject(thread, "Project").projectTitle).toBe("Project");
198});
199​
200it("counts recency from what the user wrote, else from when the thread began", () => {
201 const thread = {
202 environmentId: envA,
203 id: ThreadId.make("thread-agent"),
204 title: "Launched by an agent",
205 branch: null,
206 createdAt: "2026-10-01T00:00:00.000Z",
207 updatedAt: "2026-10-20T00:00:00.000Z",
208 latestUserMessageAt: "2026-10-20T00:00:00.000Z",
209 opens: null,
210 };
211 expect(
212 threadSearchSubject({ ...thread, latestUserAuthoredMessageAt: null }, null).activityAt,
213 ).toBe(Date.parse("2026-10-01T00:00:00.000Z"));
214 expect(
215 threadSearchSubject(
216 { ...thread, latestUserAuthoredMessageAt: "2026-10-05T00:00:00.000Z" },
217 null,
218 ).activityAt,
219 ).toBe(Date.parse("2026-10-05T00:00:00.000Z"));
220 // An older server omits the field.
221 expect(threadSearchSubject(thread, null).activityAt).toBe(Date.parse("2026-10-20T00:00:00.000Z"));
222});
223​
224it("keeps shown message rows in place and leaves room for a slower environment", () => {
225 // The fast environment answers first and fills the limit.
226 const fast = Array.from({ length: 12 }, (_, index) => `a${index}`);
227 const first = nextShownOrder(null, "rust", fast, 10);
228 expect(first.keys.slice(0, first.shown)).toEqual(fast.slice(0, 10));
229 // The slow one's matches rank first: shown rows hold, and its best few come next.
230 const slow = ["b0", "b1", "b2", "b3"];
231 const second = nextShownOrder(first, "rust", [...slow, ...fast], 10);
232 expect(second.keys.slice(0, second.shown)).toEqual([...fast.slice(0, 10), "b0", "b1", "b2"]);
233 // Nothing new: nothing moves and nothing grows.
234 expect(nextShownOrder(second, "rust", [...slow, ...fast], 10)).toBe(second);
235 // A third environment gets no more room past the limit.
236 const third = nextShownOrder(second, "rust", ["c0", ...slow, ...fast], 10);
237 expect(third.shown).toBe(13);
238 expect(third.keys.slice(0, 13)).toEqual(second.keys.slice(0, 13));
239 // A new query starts from its own ranking.
240 expect(nextShownOrder(second, "rusty", ["b0"], 10).keys).toEqual(["b0"]);
241});
242​
243it("sends one open per thread every ten minutes by this client's clock", () => {
244 const minute = 60_000;
245 expect(claimThreadOpen(envA, "thread-open", 0)).toBe(true);
246 expect(claimThreadOpen(envA, "thread-open", 9 * minute)).toBe(false);
247 expect(claimThreadOpen(envB, "thread-open", 9 * minute)).toBe(true);
248 expect(claimThreadOpen(envA, "thread-open", 10 * minute)).toBe(true);
249});