From 7198e78f99d50776cce5368efe958e616f35156d Mon Sep 17 00:00:00 2001 From: ArcadiaLin <107421070+ArcadiaLin@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:18:43 +0800 Subject: [PATCH] feat(agent): support custom metadata in jsonl session headers (#6417) Allow callers to attach an opaque JSON object to the session header so application context needed to rebuild a harness (for example an agent profile reference) is readable from the first line alone, without scanning session entries. The field is optional and ignored by readers that do not use it; fork inherits the source metadata unless overridden. Co-authored-by: Claude Fable 5 --- .../agent/src/harness/session/jsonl-repo.ts | 2 + .../src/harness/session/jsonl-storage.ts | 11 +++++ packages/agent/src/harness/types.ts | 2 + packages/agent/test/harness/repo.test.ts | 24 +++++++++++ packages/agent/test/harness/storage.test.ts | 42 +++++++++++++++++++ 5 files changed, 81 insertions(+) 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 });