| 1 | import { |
| 2 | EnvironmentId, |
| 3 | ProjectId, |
| 4 | ThreadId, |
| 5 | type OrchestrationSearchThreadsResult, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Cause from "effect/Cause"; |
| 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 9 | import { expect, it } from "vite-plus/test"; |
| 10 | |
| 11 | import { |
| 12 | createThreadSearchResultsAtomFamily, |
| 13 | makeThreadSearchKey, |
| 14 | threadSearchMatchKey, |
| 15 | } from "./threadSearch.ts"; |
| 16 | |
| 17 | const envA = EnvironmentId.make("env-a"); |
| 18 | const envB = EnvironmentId.make("env-b"); |
| 19 | |
| 20 | it("creates stable keys regardless of environment order", () => { |
| 21 | expect(makeThreadSearchKey([envB, envA], "needle")).toBe( |
| 22 | makeThreadSearchKey([envA, envB], "needle"), |
| 23 | ); |
| 24 | }); |
| 25 | |
| 26 | it("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 | |
| 39 | it("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 | |
| 52 | it("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 | |
| 74 | it("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 | |
| 97 | it("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 | }); |