diff --git a/packages/agent/src/harness/session/jsonl-repo.ts b/packages/agent/src/harness/session/jsonl-repo.ts index 1a08d4d3..f1f16522 100644 --- a/packages/agent/src/harness/session/jsonl-repo.ts +++ b/packages/agent/src/harness/session/jsonl-repo.ts @@ -85,6 +85,7 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi { cwd: options.cwd, sessionId: id, parentSessionPath: options.parentSessionPath, + metadata: options.metadata, }); return toSession(storage); } @@ -150,6 +151,7 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi { cwd: options.cwd, sessionId: id, parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, + metadata: options.metadata ?? sourceMetadata.metadata, }, ); for (const entry of forkedEntries) { diff --git a/packages/agent/src/harness/session/jsonl-storage.ts b/packages/agent/src/harness/session/jsonl-storage.ts index 912673ff..b4111572 100644 --- a/packages/agent/src/harness/session/jsonl-storage.ts +++ b/packages/agent/src/harness/session/jsonl-storage.ts @@ -12,6 +12,7 @@ interface SessionHeader { timestamp: string; cwd: string; parentSession?: string; + metadata?: Record; } function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { @@ -75,6 +76,12 @@ function parseHeaderLine(line: string, filePath: string): SessionHeader { if (header.parentSession !== undefined && typeof header.parentSession !== "string") { throw invalidSession(filePath, "session header parentSession must be a string"); } + if ( + header.metadata !== undefined && + (typeof header.metadata !== "object" || header.metadata === null || Array.isArray(header.metadata)) + ) { + throw invalidSession(filePath, "session header metadata must be an object"); + } return { type: "session", version: 3, @@ -82,6 +89,7 @@ function parseHeaderLine(line: string, filePath: string): SessionHeader { timestamp: header.timestamp, cwd: header.cwd, parentSession: header.parentSession, + metadata: header.metadata, }; } @@ -127,6 +135,7 @@ function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSess cwd: header.cwd, path, parentSessionPath: header.parentSession, + metadata: header.metadata, }; } @@ -205,6 +214,7 @@ export class JsonlSessionStorage implements SessionStorage cwd: string; sessionId: string; parentSessionPath?: string; + metadata?: Record; }, ): Promise { const header: SessionHeader = { @@ -214,6 +224,7 @@ export class JsonlSessionStorage implements SessionStorage timestamp: new Date().toISOString(), cwd: options.cwd, parentSession: options.parentSessionPath, + metadata: options.metadata, }; getFileSystemResultOrThrow( await fs.writeFile(filePath, `${JSON.stringify(header)}\n`), diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 29048e61..3f965550 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -435,6 +435,7 @@ export interface JsonlSessionMetadata extends SessionMetadata { cwd: string; path: string; parentSessionPath?: string; + metadata?: Record; } export interface SessionStorage { @@ -480,6 +481,7 @@ export interface SessionRepo< export interface JsonlSessionCreateOptions extends SessionCreateOptions { cwd: string; parentSessionPath?: string; + metadata?: Record; } export interface JsonlSessionListOptions { diff --git a/packages/agent/test/harness/repo.test.ts b/packages/agent/test/harness/repo.test.ts index b768fc1a..009fb0ce 100644 --- a/packages/agent/test/harness/repo.test.ts +++ b/packages/agent/test/harness/repo.test.ts @@ -65,4 +65,28 @@ describe("JsonlSessionRepo", () => { expect(existsSync(sourceMetadata.path)).toBe(false); await expect(repo.open(sourceMetadata)).rejects.toThrow("Session not found"); }); + + it("persists header metadata through create, list, and fork", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); + const source = await repo.create({ + cwd: "/tmp/source", + id: "source-session", + metadata: { profile: "reviewer" }, + }); + const sourceMetadata = await source.getMetadata(); + expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" }); + expect((await repo.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([ + { profile: "reviewer" }, + ]); + const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" }); + expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" }); + const overridden = await repo.fork(sourceMetadata, { + cwd: "/tmp/target", + id: "overridden-session", + metadata: { profile: "writer" }, + }); + expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" }); + }); }); diff --git a/packages/agent/test/harness/storage.test.ts b/packages/agent/test/harness/storage.test.ts index e8383ce4..07f07df2 100644 --- a/packages/agent/test/harness/storage.test.ts +++ b/packages/agent/test/harness/storage.test.ts @@ -186,6 +186,48 @@ describe("JsonlSessionStorage", () => { expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata); }); + it("round-trips custom header metadata", async () => { + const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); + const filePath = join(dir, "session.jsonl"); + const storage = await JsonlSessionStorage.create(env, filePath, { + cwd: dir, + sessionId: "session-1", + metadata: { profile: "reviewer" }, + }); + expect((await storage.getMetadata()).metadata).toEqual({ profile: "reviewer" }); + const loaded = await JsonlSessionStorage.open(env, filePath); + expect((await loaded.getMetadata()).metadata).toEqual({ profile: "reviewer" }); + expect((await loadJsonlSessionMetadata(env, filePath)).metadata).toEqual({ profile: "reviewer" }); + }); + + it("omits header metadata when not provided", async () => { + const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); + const filePath = join(dir, "session.jsonl"); + await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" }); + expect(JSON.parse(readFileSync(filePath, "utf8").trim())).not.toHaveProperty("metadata"); + expect((await loadJsonlSessionMetadata(env, filePath)).metadata).toBeUndefined(); + }); + + it("throws for non-object header metadata", async () => { + const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); + const filePath = join(dir, "session.jsonl"); + const header = { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: dir, + metadata: "profile", + }; + writeFileSync(filePath, `${JSON.stringify(header)}\n`); + await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow( + "session header metadata must be an object", + ); + }); + it("loads existing entries and reconstructs leaf", async () => { const dir = createTempDir(); const env = new NodeExecutionEnv({ cwd: dir });