1import {
2 EnvironmentId,
3 ProjectId,
4 ThreadId,
5 type OrchestrationSearchThreadsResult,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
9import { expect, it } from "vite-plus/test";
10
11import {
12 createThreadSearchResultsAtomFamily,
13 makeThreadSearchKey,
14 threadSearchMatchKey,
15} from "./threadSearch.ts";
16
17const envA = EnvironmentId.make("env-a");
18const envB = EnvironmentId.make("env-b");
19
20it("creates stable keys regardless of environment order", () => {
21 expect(makeThreadSearchKey([envB, envA], "needle")).toBe(
22 makeThreadSearchKey([envA, envB], "needle"),
23 );
24});
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("accepts search keys at the maximum decoded query length", () => {
53 const queries: string[] = [];
54 const searchAtom = createThreadSearchResultsAtomFamily<Error>({
55 getSearchAtom: (_environmentId, query) => {
56 queries.push(query);
57 return Atom.make(AsyncResult.success({ matches: [] }));
58 },
59 labelPrefix: "test:thread-search",
60 });
61 const registry = AtomRegistry.make();
62 const query = "a".repeat(200);
63
64 try {
65 registry.get(searchAtom(makeThreadSearchKey([envA], query)));
66 registry.get(searchAtom(makeThreadSearchKey([envA], ` ${query} `)));
67
68 expect(queries).toEqual([query, query]);
69 } finally {
70 registry.dispose();
71 }
72});
73
74it("ignores invalid search keys", () => {
75 let searchCount = 0;
76 const searchAtom = createThreadSearchResultsAtomFamily<Error>({
77 getSearchAtom: () => {
78 searchCount += 1;
79 return Atom.make(AsyncResult.success({ matches: [] }));
80 },
81 labelPrefix: "test:thread-search",
82 });
83 const registry = AtomRegistry.make();
84
85 try {
86 const state = registry.get(searchAtom(makeThreadSearchKey([envA], "a".repeat(201))));
87 const malformedState = registry.get(searchAtom("not json{{"));
88
89 expect(state).toEqual({ matches: [], isLoading: false });
90 expect(malformedState).toEqual({ matches: [], isLoading: false });
91 expect(searchCount).toBe(0);
92 } finally {
93 registry.dispose();
94 }
95});
96
97it("merges successful environments and silently ignores failures", () => {
98 const result: OrchestrationSearchThreadsResult = {
99 matches: [
100 {
101 threadId: ThreadId.make("thread-a"),
102 projectId: ProjectId.make("project-a"),
103 source: "user",
104 snippet: "needle",
105 messageCreatedAt: "2026-07-30T00:00:00.000Z",
106 },
107 ],
108 };
109 const searchAtom = createThreadSearchResultsAtomFamily<Error>({
110 getSearchAtom: (environmentId) =>
111 environmentId === envA
112 ? Atom.make(AsyncResult.success(result))
113 : Atom.make(
114 AsyncResult.failure<OrchestrationSearchThreadsResult, Error>(
115 Cause.fail(new Error("unsupported rpc")),
116 ),
117 ),
118 labelPrefix: "test:thread-search",
119 });
120 const registry = AtomRegistry.make();
121
122 const state = registry.get(searchAtom(makeThreadSearchKey([envB, envA], "needle")));
123 expect(state).toEqual({
124 matches: [{ ...result.matches[0], environmentId: envA }],
125 isLoading: false,
126 });
127 expect(threadSearchMatchKey(state.matches[0]!)).toBe('["env-a","thread-a"]');
128
129 registry.dispose();
130});