diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 9f3aa8396..bf3744569 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Breaking Changes +- `SessionRepository.search()` now throws a `SessionError` with code `unsupported` when no search implementation is configured instead of returning no hits. - Changed `Session` into the sole opened-session aggregate and replaced `SessionRepo` and concrete per-session persistence classes with a non-owning `SessionRepository`, caller-owned `SessionCollection`, and coherent per-session `SessionStorage`. Create collections with `createInMemorySessionCollection()` or `createJsonlSessionCollection()`, compose them with `createSessionRepository({ collection, search: createScanningSessionSearch(collection) })`, and dispose the collection after draining harness and session work. - `Session` instances are now created by `SessionRepository`; direct construction from an independently supplied collection and snapshot was removed. - Replaced per-session `SessionReader` objects and repository-level append routing with coherent `SessionStorage` implementations that own both reads and writes. diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index d2dcec917..35b2e6ffc 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -2009,7 +2009,8 @@ const session = await repository.create({}); Use `createJsonlSessionCollection({ fs, sessionsRoot })` for filesystem persistence; the same scanning search composes with it. Omitting `search` is valid, but -`repository.search()` then returns no hits. SQLite support is provided +`repository.search()` then throws a `SessionError` with code `unsupported`. +SQLite support is provided separately by `createSqliteSessionCollection()` and should use `createSqliteSessionSearch(options)` against the same canonical database. Drain all session or harness operations before the collection leaves diff --git a/packages/agent/src/harness/session/repository.ts b/packages/agent/src/harness/session/repository.ts index 54c9dbbe0..3c5b414aa 100644 --- a/packages/agent/src/harness/session/repository.ts +++ b/packages/agent/src/harness/session/repository.ts @@ -9,7 +9,6 @@ import { type SessionMetadata, type SessionSearch, type SessionSearchHit, - type SessionTreeEntry, } from "../types.ts"; import { createSessionForkSelection } from "./fork.ts"; import { createSession, type Session, type SessionContextBuildOptions } from "./session.ts"; @@ -28,16 +27,16 @@ export class SessionRepository< TListOptions = void, > { private readonly collection: SessionCollection; - private readonly sessionSearch: SessionSearch | null; + private readonly sessionSearch: SessionSearch | undefined; private readonly contextBuildOptions: SessionContextBuildOptions; constructor(options: { collection: SessionCollection; - search?: SessionSearch | null; + search?: SessionSearch; contextBuildOptions?: SessionContextBuildOptions; }) { this.collection = options.collection; - this.sessionSearch = options.search ?? null; + this.sessionSearch = options.search; this.contextBuildOptions = options.contextBuildOptions ?? {}; } @@ -66,7 +65,8 @@ export class SessionRepository< } async search(options: Parameters["search"]>[0]): Promise[]> { - return this.sessionSearch ? await this.sessionSearch.search(options) : []; + if (!this.sessionSearch) throw new SessionError("unsupported", "Session search is not configured"); + return await this.sessionSearch.search(options); } } @@ -76,26 +76,12 @@ export function createSessionRepository< TListOptions = void, >(options: { collection: SessionCollection; - search?: SessionSearch | null; + search?: SessionSearch; contextBuildOptions?: SessionContextBuildOptions; }): SessionRepository { return new SessionRepository(options); } -export function findSessionEntryMatches( - metadata: TMetadata, - entries: readonly SessionTreeEntry[], - text: string, -): SessionSearchHit[] { - const normalizedText = text.trim().toLowerCase(); - if (!normalizedText) return []; - return entries.flatMap((entry) => { - const payload = JSON.stringify(entry); - if (!payload.toLowerCase().includes(normalizedText)) return []; - return [{ metadata, entryId: entry.id, timestamp: entry.timestamp, snippet: payload }]; - }); -} - export function getFileSystemResultOrThrow(result: Result, message: string): TValue { if (!result.ok) { const code = result.error.code === "not_found" ? "not_found" : "storage"; diff --git a/packages/agent/src/harness/session/search-backend.ts b/packages/agent/src/harness/session/scanning-session-search.ts similarity index 75% rename from packages/agent/src/harness/session/search-backend.ts rename to packages/agent/src/harness/session/scanning-session-search.ts index 99b00b0d7..72548cde5 100644 --- a/packages/agent/src/harness/session/search-backend.ts +++ b/packages/agent/src/harness/session/scanning-session-search.ts @@ -5,7 +5,6 @@ import type { SessionSearchOptions, SessionStorage, } from "../types.ts"; -import { findSessionEntryMatches } from "./repository.ts"; type SessionSearchSource = { open(metadata: TMetadata): Promise>; @@ -21,12 +20,23 @@ class ScanningSessionSearch } async search(options: SessionSearchOptions): Promise[]> { + const normalizedText = options.text.trim().toLowerCase(); + if (!normalizedText) return []; const hits: SessionSearchHit[] = []; for (const metadata of await this.source.list()) { const cwd = (metadata as { cwd?: unknown }).cwd; if (options.cwd !== undefined && cwd !== options.cwd) continue; const storage = await this.source.open(metadata); - hits.push(...findSessionEntryMatches(storage.metadata, await storage.readEntries(), options.text)); + for (const entry of await storage.readEntries()) { + const payload = JSON.stringify(entry); + if (!payload.toLowerCase().includes(normalizedText)) continue; + hits.push({ + metadata: storage.metadata, + entryId: entry.id, + timestamp: entry.timestamp, + snippet: payload, + }); + } } return hits; } diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index c5f3c0886..2d8e9aa1e 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -225,6 +225,7 @@ export class BranchSummaryError extends Error { export type SessionErrorCode = | "not_found" + | "unsupported" | "invalid_session" | "invalid_entry" | "invalid_fork_target" diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index ddf2c570b..e228d0002 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -40,7 +40,7 @@ export { type InMemorySessionCreateOptions, } from "./harness/session/memory-collection.ts"; export * from "./harness/session/repository.ts"; -export * from "./harness/session/search-backend.ts"; +export * from "./harness/session/scanning-session-search.ts"; export { buildContextEntries, buildSessionContext, diff --git a/packages/agent/test/harness/repo.test.ts b/packages/agent/test/harness/repo.test.ts index 60709da50..88f5267d6 100644 --- a/packages/agent/test/harness/repo.test.ts +++ b/packages/agent/test/harness/repo.test.ts @@ -200,6 +200,15 @@ function createCountingInMemorySessionCollection(): { } describe("InMemorySessionCollection", () => { + it("rejects search when no search implementation is configured", async () => { + const repo = createSessionRepository({ collection: createInMemorySessionCollection() }); + + await expect(repo.search({ text: "needle" })).rejects.toMatchObject({ + code: "unsupported", + message: "Session search is not configured", + }); + }); + it("opens, deletes, and forks by metadata", async () => { const repo = createSessionRepository({ collection: createInMemorySessionCollection() }); const session = await repo.create({ id: "session-1" }); diff --git a/packages/agent/test/harness/sqlite-node.test.ts b/packages/agent/test/harness/sqlite-node.test.ts index bdff9636d..797210659 100644 --- a/packages/agent/test/harness/sqlite-node.test.ts +++ b/packages/agent/test/harness/sqlite-node.test.ts @@ -9,7 +9,7 @@ import { import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { createJsonlSessionCollection } from "../../src/harness/session/jsonl-collection.ts"; import { createSessionRepository } from "../../src/harness/session/repository.ts"; -import { createScanningSessionSearch } from "../../src/harness/session/search-backend.ts"; +import { createScanningSessionSearch } from "../../src/harness/session/scanning-session-search.ts"; import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "../../src/harness/types.ts"; import { createTempDir, createUserMessage } from "./session-test-utils.ts";