Skip to content

Commit 5d66e72

Browse files
feat(server): add bounded thread content search
1 parent 490318a commit 5d66e72

3 files changed

Lines changed: 631 additions & 1 deletion

File tree

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { assert, it } from "@effect/vitest";
3+
import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
4+
import * as Effect from "effect/Effect";
5+
import * as Layer from "effect/Layer";
6+
import * as Schema from "effect/Schema";
7+
import * as SqlClient from "effect/unstable/sql/SqlClient";
8+
9+
import { ServerConfig } from "../../config.ts";
10+
import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11+
import * as ProjectEnrichment from "../../project/ProjectEnrichmentService.ts";
12+
import * as ProjectFaviconResolver from "../../project/ProjectFaviconResolver.ts";
13+
import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
14+
import {
15+
PROJECTION_THREAD_CONTENT_SEARCH_LIMITS,
16+
ProjectionSnapshotQuery,
17+
} from "../Services/ProjectionSnapshotQuery.ts";
18+
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
19+
import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
20+
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
21+
22+
const metadataLayer = Layer.merge(
23+
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
24+
resolve: (workspaceRoot) =>
25+
Effect.succeed({
26+
canonicalKey: `test:${workspaceRoot}`,
27+
locator: {
28+
source: "git-remote" as const,
29+
remoteName: "origin",
30+
remoteUrl: "https://example.test/search.git",
31+
},
32+
rootPath: workspaceRoot,
33+
}),
34+
}),
35+
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
36+
resolvePath: () => Effect.succeed(null),
37+
}),
38+
);
39+
40+
const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
41+
42+
const testLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
43+
Layer.provideMerge(ThreadBackgroundLiveness.layer),
44+
Layer.provideMerge(ThreadPlanProgress.layer),
45+
Layer.provideMerge(ProjectEnrichment.layer),
46+
Layer.provideMerge(metadataLayer),
47+
Layer.provideMerge(SqlitePersistenceMemory),
48+
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "thread-content-search-test-" })),
49+
Layer.provide(NodeServices.layer),
50+
);
51+
52+
it.effect("searches bounded durable thread content without changing the legacy search RPC", () =>
53+
Effect.gen(function* () {
54+
const sql = yield* SqlClient.SqlClient;
55+
const query = yield* ProjectionSnapshotQuery;
56+
const projectId = ProjectId.make("project:search-current");
57+
const otherProjectId = ProjectId.make("project:search-other");
58+
const activeThreadId = ThreadId.make("thread:search-active");
59+
const archivedThreadId = ThreadId.make("thread:search-archived");
60+
const deletedThreadId = ThreadId.make("thread:search-deleted");
61+
const otherThreadId = ThreadId.make("thread:search-other");
62+
const longText = `${"prefix ".repeat(2_000)}literal %_! needle😀終 ${"suffix ".repeat(2_000)}`;
63+
64+
yield* sql`
65+
INSERT INTO projection_projects (
66+
project_id, title, workspace_root, default_model_selection_json, scripts_json,
67+
created_at, updated_at, deleted_at
68+
) VALUES
69+
(${projectId}, 'Current', '/tmp/search-current', NULL, '[]',
70+
'2026-01-01T00:00:00.000Z', '2026-01-10T00:00:00.000Z', NULL),
71+
(${otherProjectId}, 'Other', '/tmp/search-other', NULL, '[]',
72+
'2026-01-01T00:00:00.000Z', '2026-01-10T00:00:00.000Z', NULL)
73+
`;
74+
yield* sql`
75+
INSERT INTO projection_threads (
76+
thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode,
77+
branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at,
78+
settled_override, settled_at, deleted_at
79+
) VALUES
80+
(${activeThreadId}, ${projectId}, 'Needle active title', '{}', 'full-access', 'default',
81+
'main', '/tmp/search-current', NULL, '2026-01-01T00:00:00.000Z',
82+
'2026-01-10T00:00:00.000Z', NULL, NULL, NULL, NULL),
83+
(${archivedThreadId}, ${projectId}, 'Archived history', '{}', 'full-access', 'default',
84+
'main', '/tmp/search-current', NULL, '2026-01-01T00:00:00.000Z',
85+
'2026-01-09T00:00:00.000Z', '2026-01-09T00:00:00.000Z', NULL, NULL, NULL),
86+
(${deletedThreadId}, ${projectId}, 'Deleted needle', '{}', 'full-access', 'default',
87+
'main', '/tmp/search-current', NULL, '2026-01-01T00:00:00.000Z',
88+
'2026-01-08T00:00:00.000Z', NULL, NULL, NULL, '2026-01-08T00:00:00.000Z'),
89+
(${otherThreadId}, ${otherProjectId}, 'Other needle', '{}', 'full-access', 'default',
90+
'main', '/tmp/search-other', NULL, '2026-01-01T00:00:00.000Z',
91+
'2026-01-07T00:00:00.000Z', NULL, NULL, NULL, NULL)
92+
`;
93+
yield* sql`
94+
INSERT INTO projection_thread_messages (
95+
message_id, thread_id, turn_id, role, text, attachments_json, is_streaming,
96+
created_at, updated_at
97+
) VALUES
98+
('message:legacy-active', ${activeThreadId}, NULL, 'user', 'legacy needle', '[]', 0,
99+
'2026-01-03T00:00:00.000Z', '2026-01-03T00:00:00.000Z'),
100+
('message:legacy-archived', ${archivedThreadId}, NULL, 'user', 'archived needle', '[]', 0,
101+
'2026-01-04T00:00:00.000Z', '2026-01-04T00:00:00.000Z'),
102+
('message:legacy-other', ${otherThreadId}, NULL, 'user', 'secret needle', '[]', 0,
103+
'2026-01-05T00:00:00.000Z', '2026-01-05T00:00:00.000Z')
104+
`;
105+
yield* sql`
106+
INSERT INTO orchestration_v2_projection_runs (
107+
run_id, thread_id, ordinal, provider, provider_thread_id, status,
108+
requested_at, completed_at, payload_json
109+
) VALUES
110+
('run:visible', ${activeThreadId}, 1, 'codex', NULL, 'completed',
111+
'2026-01-06T00:00:00.000Z', '2026-01-06T00:01:00.000Z', '{}'),
112+
('run:rolled-back', ${activeThreadId}, 2, 'codex', NULL, 'rolled_back',
113+
'2026-01-07T00:00:00.000Z', '2026-01-07T00:01:00.000Z', '{}'),
114+
('run:cancelled', ${activeThreadId}, 3, 'codex', NULL, 'cancelled',
115+
'2026-01-08T00:00:00.000Z', '2026-01-08T00:01:00.000Z', '{}')
116+
`;
117+
yield* sql`
118+
INSERT INTO orchestration_v2_projection_messages (
119+
message_id, thread_id, run_id, node_id, role, streaming, created_at, updated_at, payload_json
120+
) VALUES
121+
('message:v2-visible', ${activeThreadId}, 'run:visible', NULL, 'assistant', 0,
122+
'2026-01-06T00:00:00.000Z', '2026-01-06T00:00:00.000Z',
123+
${encodeUnknownJsonString({ text: longText })}),
124+
('message:v2-rolled-back', ${activeThreadId}, 'run:rolled-back', NULL, 'assistant', 0,
125+
'2026-01-07T00:00:00.000Z', '2026-01-07T00:00:00.000Z',
126+
${encodeUnknownJsonString({ text: "rolled-back needle" })}),
127+
('message:v2-cancelled', ${activeThreadId}, 'run:cancelled', NULL, 'user', 0,
128+
'2026-01-08T00:00:00.000Z', '2026-01-08T00:00:00.000Z',
129+
${encodeUnknownJsonString({ text: "cancelled needle" })})
130+
`;
131+
yield* sql`
132+
INSERT INTO orchestration_v2_projection_turn_items (
133+
turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id,
134+
parent_item_id, ordinal, type, status, updated_at, payload_json
135+
) VALUES
136+
('item:v2-visible', ${activeThreadId}, 'run:visible', NULL, NULL, NULL, NULL, 1,
137+
'assistant_message', 'completed', '2026-01-06T00:00:00.000Z',
138+
${encodeUnknownJsonString({ messageId: "message:v2-visible", text: longText, streaming: false })}),
139+
('item:v2-rolled-back', ${activeThreadId}, 'run:rolled-back', NULL, NULL, NULL, NULL, 2,
140+
'assistant_message', 'completed', '2026-01-07T00:00:00.000Z',
141+
${encodeUnknownJsonString({ messageId: "message:v2-rolled-back", text: "rolled-back needle", streaming: false })}),
142+
('item:v2-cancelled', ${activeThreadId}, 'run:cancelled', NULL, NULL, NULL, NULL, 3,
143+
'user_message', 'completed', '2026-01-08T00:00:00.000Z',
144+
${encodeUnknownJsonString({ messageId: "message:v2-cancelled", text: "cancelled needle", inputIntent: "queued_turn" })})
145+
`;
146+
147+
const firstPage = yield* query.searchThreadContent({
148+
projectId,
149+
query: "needle",
150+
includeArchived: true,
151+
offset: 0,
152+
limit: 2,
153+
snippetChars: 80,
154+
});
155+
assert.lengthOf(firstPage.hits, 2);
156+
assert.isTrue(firstPage.hasMore);
157+
assert.equal(firstPage.nextOffset, 2);
158+
assert.deepEqual(
159+
firstPage.hits.map((hit) => [hit.source, hit.origin]),
160+
[
161+
["title", "legacy"],
162+
["assistant", "v2"],
163+
],
164+
);
165+
const anchored = firstPage.hits[1];
166+
assert.equal(anchored?.sourceThreadId, activeThreadId);
167+
assert.equal(anchored?.messageId, "message:v2-visible");
168+
assert.equal(anchored?.itemId, "item:v2-visible");
169+
assert.match(anchored?.snippet ?? "", /needle/);
170+
assert.isAtMost(Array.from(anchored?.snippet ?? "").length, 80);
171+
assert.isTrue(anchored?.snippetTruncated ?? false);
172+
173+
const secondPage = yield* query.searchThreadContent({
174+
projectId,
175+
query: "needle",
176+
includeArchived: true,
177+
offset: firstPage.nextOffset ?? 0,
178+
limit: 10,
179+
snippetChars: 80,
180+
});
181+
assert.deepEqual(
182+
secondPage.hits.map((hit) => hit.threadId),
183+
[archivedThreadId, activeThreadId],
184+
);
185+
assert.isNull(secondPage.hits[0]?.sourceThreadId ?? null);
186+
assert.isTrue(secondPage.hits[0]?.archived ?? false);
187+
assert.notInclude(
188+
secondPage.hits.map((hit) => hit.threadId),
189+
otherThreadId,
190+
);
191+
assert.notInclude(
192+
secondPage.hits.map((hit) => hit.threadId),
193+
deletedThreadId,
194+
);
195+
196+
const activeOnly = yield* query.searchThreadContent({
197+
projectId,
198+
query: "needle",
199+
includeArchived: false,
200+
offset: 0,
201+
limit: 10,
202+
snippetChars: 80,
203+
});
204+
assert.isTrue(activeOnly.hits.every((hit) => !hit.archived));
205+
206+
const literal = yield* query.searchThreadContent({
207+
projectId,
208+
threadId: activeThreadId,
209+
query: "%_!",
210+
includeArchived: false,
211+
offset: 0,
212+
limit: 10,
213+
snippetChars: 64,
214+
});
215+
assert.deepEqual(
216+
literal.hits.map((hit) => hit.messageId),
217+
[MessageId.make("message:v2-visible")],
218+
);
219+
220+
const nonAscii = yield* query.searchThreadContent({
221+
projectId,
222+
threadId: activeThreadId,
223+
query: "needle😀終",
224+
includeArchived: false,
225+
offset: 0,
226+
limit: 10,
227+
snippetChars: 64,
228+
});
229+
assert.match(nonAscii.hits[0]?.snippet ?? "", /needle😀終/);
230+
assert.isAtMost(Array.from(nonAscii.hits[0]?.snippet ?? "").length, 64);
231+
232+
const legacyResult = yield* query.searchThreads({ query: "needle", limit: 50 });
233+
assert.deepEqual(Object.keys(legacyResult), ["matches"]);
234+
assert.isTrue(legacyResult.matches.every((match) => match.source !== undefined));
235+
assert.isTrue(legacyResult.matches.every((match) => !("itemId" in match)));
236+
}).pipe(Effect.provide(testLayer)),
237+
);
238+
239+
it.effect("rejects out-of-bounds lower-layer requests before querying", () =>
240+
Effect.gen(function* () {
241+
const query = yield* ProjectionSnapshotQuery;
242+
const failure = yield* Effect.flip(
243+
query.searchThreadContent({
244+
projectId: ProjectId.make("project:unused"),
245+
query: "needle",
246+
includeArchived: false,
247+
offset: PROJECTION_THREAD_CONTENT_SEARCH_LIMITS.offsetMax + 1,
248+
limit: 1,
249+
snippetChars: 64,
250+
}),
251+
);
252+
assert.equal(failure._tag, "ProjectionThreadContentSearchInputError");
253+
}).pipe(Effect.provide(testLayer)),
254+
);

0 commit comments

Comments
 (0)