feat(agent): reject unsupported session search

This commit is contained in:
Christian Klotz 2026-08-02 11:14:03 +03:00
parent cbc893b0d4
commit a4c7cb025d
No known key found for this signature in database
8 changed files with 33 additions and 25 deletions

View file

@ -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.

View file

@ -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

View file

@ -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<TMetadata, TCreateOptions, TListOptions>;
private readonly sessionSearch: SessionSearch<TMetadata> | null;
private readonly sessionSearch: SessionSearch<TMetadata> | undefined;
private readonly contextBuildOptions: SessionContextBuildOptions;
constructor(options: {
collection: SessionCollection<TMetadata, TCreateOptions, TListOptions>;
search?: SessionSearch<TMetadata> | null;
search?: SessionSearch<TMetadata>;
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<SessionSearch<TMetadata>["search"]>[0]): Promise<SessionSearchHit<TMetadata>[]> {
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<TMetadata, TCreateOptions, TListOptions>;
search?: SessionSearch<TMetadata> | null;
search?: SessionSearch<TMetadata>;
contextBuildOptions?: SessionContextBuildOptions;
}): SessionRepository<TMetadata, TCreateOptions, TListOptions> {
return new SessionRepository(options);
}
export function findSessionEntryMatches<TMetadata extends SessionMetadata>(
metadata: TMetadata,
entries: readonly SessionTreeEntry[],
text: string,
): SessionSearchHit<TMetadata>[] {
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<TValue>(result: Result<TValue, FileError>, message: string): TValue {
if (!result.ok) {
const code = result.error.code === "not_found" ? "not_found" : "storage";

View file

@ -5,7 +5,6 @@ import type {
SessionSearchOptions,
SessionStorage,
} from "../types.ts";
import { findSessionEntryMatches } from "./repository.ts";
type SessionSearchSource<TMetadata extends SessionMetadata> = {
open(metadata: TMetadata): Promise<SessionStorage<TMetadata>>;
@ -21,12 +20,23 @@ class ScanningSessionSearch<TMetadata extends SessionMetadata = SessionMetadata>
}
async search(options: SessionSearchOptions): Promise<SessionSearchHit<TMetadata>[]> {
const normalizedText = options.text.trim().toLowerCase();
if (!normalizedText) return [];
const hits: SessionSearchHit<TMetadata>[] = [];
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;
}

View file

@ -225,6 +225,7 @@ export class BranchSummaryError extends Error {
export type SessionErrorCode =
| "not_found"
| "unsupported"
| "invalid_session"
| "invalid_entry"
| "invalid_fork_target"

View file

@ -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,

View file

@ -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" });

View file

@ -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";