feat(agent): support clearing session names

This commit is contained in:
David Brailovsky 2026-08-08 19:19:17 +02:00
parent c185d41238
commit 7bdb16c28d
12 changed files with 69 additions and 32 deletions

View file

@ -180,7 +180,10 @@ function parseLaneMutation(value: Record<string, unknown>, seq: number): Extract
function parseFactMutation(value: Record<string, unknown>, seq: number): Extract<SessionMutation, { kind: "fact" }> {
if (value.fact === "name") {
return { kind: "fact", seq, fact: "name", name: requireString(value.name, "name") };
if (value.name !== undefined && typeof value.name !== "string") {
throw new JsonlDecodeError("schema", "has invalid name");
}
return { kind: "fact", seq, fact: "name", name: value.name };
}
if (value.fact === "label") {
if (value.label !== undefined && typeof value.label !== "string") {

View file

@ -224,7 +224,7 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
return this.state.getName();
}
setName(name: string): Promise<void> {
setName(name: string | undefined): Promise<void> {
return this.enqueue(async () => {
const mutation: SessionMutation = { kind: "fact", seq: this.state.nextSequence, fact: "name", name };
await this.appendMutation(mutation);

View file

@ -121,7 +121,7 @@ export class InMemorySessionStorage implements SessionStorage {
return this.state.getName();
}
async setName(name: string): Promise<void> {
async setName(name: string | undefined): Promise<void> {
this.state.applyMutation({ kind: "fact", seq: this.state.nextSequence, fact: "name", name });
}

View file

@ -147,7 +147,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> implem
return this.storage.getName();
}
async setName(name: string): Promise<void> {
async setName(name: string | undefined): Promise<void> {
await this.storage.setName(name);
}

View file

@ -18,7 +18,7 @@ export type SessionMutation =
| { kind: "entry"; lane?: string; entry: Entry }
| { kind: "record"; record: LaneRecord }
| { kind: "lane"; seq: number; lane: string; leafId: string | null }
| { kind: "fact"; seq: number; fact: "name"; name: string }
| { kind: "fact"; seq: number; fact: "name"; name: string | undefined }
| { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined };
type InvalidMutation = (message: string) => never;

View file

@ -640,6 +640,29 @@ export function createSessionBackendConformance(
},
),
createCase(factory, "queries and facts", "clears session names durably", async (repository) => {
const session = await repository.create({ id: "session" });
await session.setName("Temporary");
await session.setName(undefined);
strictEqual(await session.getName(), undefined);
deepStrictEqual(await session.getLog(), [
{ kind: "fact", seq: 1, fact: "name", name: "Temporary" },
{ kind: "fact", seq: 2, fact: "name", name: undefined },
]);
const metadata = await session.getMetadata();
const reopened = await repository.open(metadata);
strictEqual(await reopened.getName(), undefined);
deepStrictEqual(await reopened.getLog(), [
{ kind: "fact", seq: 1, fact: "name", name: "Temporary" },
{ kind: "fact", seq: 2, fact: "name", name: undefined },
]);
const fork = await repository.fork(metadata, { id: "fork" });
strictEqual(await fork.getName(), undefined);
}),
createCase(factory, "validation and immutability", "returns immutable copies from reads", async (repository) => {
const session = await repository.create({ id: "immutable" });
const metadata = await session.getMetadata();

View file

@ -279,7 +279,7 @@ export type LogItem =
| { kind: "entry"; seq: number; entry: Entry }
| { kind: "record"; seq: number; record: LaneRecord }
| { kind: "lane"; seq: number; lane: string; leafId: string | null }
| { kind: "fact"; seq: number; fact: "name"; name: string }
| { kind: "fact"; seq: number; fact: "name"; name: string | undefined }
| { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined };
export interface LogOptions {
@ -319,7 +319,7 @@ export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetad
// Global facts
getName(): Promise<string | undefined>;
setName(name: string): Promise<void>;
setName(name: string | undefined): Promise<void>;
getLabel(id: string): Promise<string | undefined>;
setLabel(id: string, label: string | undefined): Promise<void>;
getStats(): Promise<SessionStats>;
@ -333,7 +333,7 @@ export interface SessionTree {
// Global facts. Latest wins; not branch-scoped. "set", not "append":
// append vocabulary is reserved for tree writes.
getName(): Promise<string | undefined>;
setName(name: string): Promise<void>;
setName(name: string | undefined): Promise<void>;
getLabel(targetId: string): Promise<string | undefined>;
setLabel(targetId: string, label: string | undefined): Promise<void>;

View file

@ -134,11 +134,12 @@ describe("JSONL v4 codec", () => {
expectMutationRoundTrip({ kind: "lane", seq: 1, lane: "thread", leafId: "entry-1" });
});
it("round trips both fact line discriminants", () => {
it("round trips fact lines, including cleared values", () => {
expectMutationRoundTrip({ kind: "fact", seq: 1, fact: "name", name: "Example" });
expectMutationRoundTrip({ kind: "fact", seq: 2, fact: "name", name: undefined });
expectMutationRoundTrip({
kind: "fact",
seq: 2,
seq: 3,
fact: "label",
targetId: "entry-1",
label: "checkpoint",

View file

@ -606,7 +606,7 @@ class SqliteSessionStorage implements SessionStorage<SqliteSessionMetadata> {
kind: "fact" as const,
seq: row.seq,
fact: "name" as const,
name: JSON.parse(row.value ?? "null") as string,
name: row.value === null ? undefined : (JSON.parse(row.value) as string),
};
return {
kind: "fact" as const,
@ -627,10 +627,10 @@ class SqliteSessionStorage implements SessionStorage<SqliteSessionMetadata> {
return row?.value === undefined || row.value === null ? undefined : (JSON.parse(row.value) as string);
}
async setName(name: string): Promise<void> {
async setName(name: string | undefined): Promise<void> {
return this.enqueueWrite(() => {
const seq = getNextSequence(this.db, this.metadata.id);
appendFact(this.db, this.metadata.id, seq, "name", null, JSON.stringify(name));
appendFact(this.db, this.metadata.id, seq, "name", null, name === undefined ? null : JSON.stringify(name));
advanceSequence(this.db, this.metadata.id, seq);
});
}

View file

@ -98,10 +98,8 @@ export function deleteSessionRow(db: SqliteDatabase, sessionId: string) {
sql`DELETE FROM sessions WHERE id = ${sessionId}`.run(db);
}
function parseSessionName(value: string | null, sessionId: string): string {
if (value === null) {
throw new SessionError("storage", `Invalid SQLite session ${sessionId}: name must be a string`);
}
function parseSessionName(value: string | null, sessionId: string): string | undefined {
if (value === null) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(value);

View file

@ -275,7 +275,6 @@ END;
it.each([
["invalid JSON", "not json", "name is not valid JSON"],
["a non-string value", "{}", "name must be a string"],
["a NULL value", null, "name must be a string"],
])("rejects stored session names containing %s", async (_case, stored, message) => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
@ -302,6 +301,26 @@ END;
});
});
it("omits a cleared session name from metadata", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");
const env = new NodeExecutionEnv({ cwd: root });
await using repo = new SqliteSessionRepository({
env,
sqlite: createNodeSqliteFactory(),
databasePath,
});
const session = await repo.create({ cwd: root, id: "session-1" });
await session.setName("Temporary");
expect(await session.getMetadata()).toMatchObject({ name: "Temporary" });
await session.setName(undefined);
expect(await session.getName()).toBeUndefined();
expect(await session.getMetadata()).not.toHaveProperty("name");
expect((await repo.list())[0]).not.toHaveProperty("name");
});
it("fails loudly when a stored entry is read and cannot be decoded", async () => {
const root = createTempDir();
const databasePath = join(root, "sessions.sqlite");

View file

@ -42,7 +42,7 @@ describe("SQLite FTS5 session search", () => {
]);
});
it("rejects a stored NULL session name", async () => {
it("omits a cleared session name from search metadata", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const sqlite = createNodeSqliteFactory();
@ -50,20 +50,13 @@ describe("SQLite FTS5 session search", () => {
await using fixture = createSqliteFixture({ env, sqlite, databasePath });
const { repository, search } = fixture;
const session = await repository.create({ cwd: root, id: "session-1" });
await session.appendMessage(createUserMessage("Find the auth defect"));
await session.setName("valid name");
const entryId = await session.appendMessage(createUserMessage("Find the auth defect"));
await session.setName("Temporary");
await session.setName(undefined);
const db = await sqlite.open(databasePath);
try {
await db.prepare("UPDATE facts SET value = NULL WHERE session_id = ? AND kind = 'name'").run("session-1");
} finally {
await db.close();
}
await expect(search.search({ text: "auth" })).rejects.toMatchObject({
code: "storage",
message: expect.stringContaining("name must be a string"),
});
const [result] = await search.search({ text: "auth" });
expect(result).toMatchObject({ entryId, metadata: { id: "session-1" } });
expect(result?.metadata).not.toHaveProperty("name");
});
it("handles quoted search text without exposing FTS syntax", async () => {