From 762291b2a877c3852e1a3988ade94c50065b32e6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 27 Aug 2026 22:30:55 -0400 Subject: [PATCH] feat(core): durable session metadata at creation (#45805) --- packages/client/src/effect/api/api.ts | 2 + .../client/src/effect/generated/client.ts | 1 + .../client/src/promise/generated/client.ts | 1 + .../client/src/promise/generated/types.ts | 20 ++++++++++ packages/core/src/session.ts | 4 ++ packages/core/src/session/info.ts | 1 + packages/core/src/session/projector.ts | 2 + packages/core/src/session/sql.ts | 2 +- packages/core/src/session/transfer.ts | 5 +++ packages/core/test/session-create.test.ts | 39 ++++++++++++++++++- packages/protocol/src/groups/session.ts | 1 + packages/schema/src/session-event.ts | 3 ++ packages/schema/src/session-metadata.ts | 11 ++++++ packages/schema/src/session.ts | 5 +++ packages/server/src/handlers/session.ts | 1 + 15 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 packages/schema/src/session-metadata.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 2b42f6f6f35..46b5419b92d 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -183,6 +183,7 @@ export type SessionCreateInput = { readonly agent?: Agent.ID | undefined readonly model?: Model.Ref | undefined readonly location?: Location.Ref | undefined + readonly metadata?: Session.Metadata | undefined } export type SessionCreateOutput = Session.Info export type SessionCreateOperation = (input?: SessionCreateInput) => Effect.Effect @@ -410,6 +411,7 @@ export type SessionLogOutput = readonly title?: string | undefined readonly agent?: Agent.ID | undefined readonly model?: Model.Ref | undefined + readonly metadata?: Session.Metadata | undefined readonly version: string } } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 92aae3154d4..e667eb6c665 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -360,6 +360,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses agent: input?.["agent"], model: input?.["model"], location: input?.["location"], + metadata: input?.["metadata"], }, }).pipe( Effect.mapError(mapClientError), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 35753a96bef..b5abafe6d2e 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -513,6 +513,7 @@ export function make(options: ClientOptions) { agent: input?.["agent"], model: input?.["model"], location: input?.["location"], + metadata: input?.["metadata"], }, successStatus: 200, declaredStatuses: [401, 400], diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 4c8b77379d2..c204555fc3f 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -29,6 +29,8 @@ export type TokenUsageInfo = { export type LocationRef = { directory: string; workspaceID?: string } +export type SessionMetadata = { [x: string]: JsonValue } + export type FileDiffInfo = { file: string patch: string @@ -548,6 +550,7 @@ export type SessionCreated = { title?: string agent?: string model?: ModelRef + metadata?: SessionMetadata version: string } } @@ -1642,6 +1645,7 @@ export type SessionInfo = { title?: string location: LocationRef subpath?: string + metadata?: SessionMetadata revert?: SessionRevert } @@ -2687,6 +2691,7 @@ export type SessionCreateInput = { readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + readonly metadata?: { readonly [x: string]: JsonValue } | null }["id"] readonly title?: { readonly id?: string | null @@ -2694,6 +2699,7 @@ export type SessionCreateInput = { readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + readonly metadata?: { readonly [x: string]: JsonValue } | null }["title"] readonly agent?: { readonly id?: string | null @@ -2701,6 +2707,7 @@ export type SessionCreateInput = { readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + readonly metadata?: { readonly [x: string]: JsonValue } | null }["agent"] readonly model?: { readonly id?: string | null @@ -2708,6 +2715,7 @@ export type SessionCreateInput = { readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + readonly metadata?: { readonly [x: string]: JsonValue } | null }["model"] readonly location?: { readonly id?: string | null @@ -2715,7 +2723,16 @@ export type SessionCreateInput = { readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + readonly metadata?: { readonly [x: string]: JsonValue } | null }["location"] + readonly metadata?: { + readonly id?: string | null + readonly title?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string; readonly workspaceID?: string } | null + readonly metadata?: { readonly [x: string]: JsonValue } | null + }["metadata"] } export type SessionCreateOutput = { data: SessionInfo }["data"] @@ -2752,6 +2769,7 @@ export type SessionImportInput = { readonly title?: string readonly location: { readonly directory: string; readonly workspaceID?: string } readonly subpath?: string + readonly metadata?: { readonly [x: string]: JsonValue } readonly revert?: { readonly messageID: string readonly partID?: string @@ -3028,6 +3046,7 @@ export type SessionImportInput = { readonly title?: string readonly location: { readonly directory: string; readonly workspaceID?: string } readonly subpath?: string + readonly metadata?: { readonly [x: string]: JsonValue } readonly revert?: { readonly messageID: string readonly partID?: string @@ -3304,6 +3323,7 @@ export type SessionImportInput = { readonly title?: string readonly location: { readonly directory: string; readonly workspaceID?: string } readonly subpath?: string + readonly metadata?: { readonly [x: string]: JsonValue } readonly revert?: { readonly messageID: string readonly partID?: string diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index a5478881c30..ba2f1b0fa4d 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -98,6 +98,7 @@ type CreateBaseInput = { title?: string agent?: Agent.ID model?: Model.Ref + metadata?: SessionSchema.Metadata } type CreateInput = CreateBaseInput & ({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never }) @@ -409,6 +410,9 @@ const layer = Layer.effect( subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")), title: input.title, agent: input.agent, + // Children inherit metadata the way they inherit location, so + // host policies that read it treat the family uniformly. + metadata: input.metadata ?? parent?.metadata, model: input.model ? { id: Model.ID.make(input.model.id), diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 58878ab3cf8..06145b76b8f 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -49,6 +49,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In workspaceID: row.workspace_id ? Workspace.ID.make(row.workspace_id) : undefined, }), subpath: row.path ? RelativePath.make(row.path) : undefined, + metadata: row.metadata ?? undefined, revert: row.revert ? decodeRevert(row.revert) : undefined, outcome: row.idle_outcome ?? undefined, time: { diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index b2ea8b920b6..652361179cf 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -157,6 +157,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( title: forkTitle(parent.title ?? undefined), agent: parent.agent, model: parent.model, + metadata: parent.metadata, version: parent.version, cost: 0, tokens_input: 0, @@ -446,6 +447,7 @@ const layer = Layer.effectDiscard( title: event.data.title, agent: event.data.agent, model: event.data.model, + metadata: event.data.metadata, version: event.data.version, time_created: event.created, time_updated: event.created, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 6f5083b5b14..04ada2ffb29 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -41,7 +41,7 @@ export const SessionTable = sqliteTable( summary_deletions: integer(), summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), - metadata: text({ mode: "json" }).$type>(), + metadata: text({ mode: "json" }).$type(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), tokens_output: integer().notNull().default(0), diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index 6a88e83077a..4a9b11b6f8b 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -100,6 +100,7 @@ const layer = Layer.effect( title: input.data.info.title, agent: input.data.info.agent, model: input.data.info.model, + metadata: input.data.info.metadata, }, { location: input.location, @@ -178,6 +179,10 @@ function sanitize(data: Data): Data { info: { ...data.info, title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title), + metadata: + data.info.metadata && Object.keys(data.info.metadata).length > 0 + ? { redacted: `session-metadata:${data.info.id}` } + : data.info.metadata, location: { ...data.info.location, directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index b86f30d31b6..0f213e7382a 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -343,6 +343,40 @@ describe("Session.create", () => { }), ) + it.effect("stores creation metadata and inherits it through children and forks", () => + Effect.gen(function* () { + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const metadata = { thread: "C123/1699999999.123", labels: ["support", 2] } + + const created = yield* session.create({ location, metadata }) + expect(created.metadata).toEqual(metadata) + // The annotations are a durable creation fact, not just projected state. + expect( + yield* db + .select({ data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, created.id)) + .get() + .pipe(Effect.orDie), + ).toMatchObject({ data: { metadata } }) + + const inherited = yield* session.create({ parentID: created.id }) + expect(inherited.metadata).toEqual(metadata) + const overridden = yield* session.create({ parentID: created.id, metadata: { thread: "other" } }) + expect(overridden.metadata).toEqual({ thread: "other" }) + + yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false }) + yield* SessionInbox.promote(db, bus, created.id, "steer") + const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } }) + expect(forked.metadata).toEqual(metadata) + + // Absent stays absent: no empty-object normalization. + expect((yield* session.create({ location })).metadata).toBeUndefined() + }), + ) + it.effect("inherits location from an existing parent when omitted", () => Effect.gen(function* () { const session = yield* Session.Service @@ -1280,7 +1314,7 @@ describe("SessionTransfer", () => { const transfer = yield* SessionTransfer.Service const bus = yield* Bus.Service const { db } = yield* Database.Service - const template = yield* session.create({ location, title: "Exported" }) + const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } }) const sessionID = Session.ID.create() const sourceMessageID = SessionMessage.ID.create() const errorMessageID = SessionMessage.ID.create() @@ -1324,7 +1358,7 @@ describe("SessionTransfer", () => { }) const messages = yield* session.messages({ sessionID, order: "asc" }) - expect(imported).toMatchObject({ id: sessionID, title: "Exported", location }) + expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } }) expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) }) expect(messages).toMatchObject([ { id: sourceMessageID, ...Expected.user("Imported message") }, @@ -1336,6 +1370,7 @@ describe("SessionTransfer", () => { expect(exported.messages).toEqual(messages) const sanitized = yield* transfer.export({ sessionID, sanitize: true }) expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) }) + expect(sanitized.info.metadata).toEqual({ redacted: `session-metadata:${sessionID}` }) expect(sanitized.messages).toMatchObject([ { id: sourceMessageID, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 37610ba34eb..442d9eafadc 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -174,6 +174,7 @@ export const makeSessionGroup = (sessionLo agent: Agent.ID.pipe(Schema.optional), model: Model.Ref.pipe(Schema.optional), location: Location.Ref.pipe(Schema.optional), + metadata: Session.Metadata.pipe(Schema.optional), }), success: Schema.Struct({ data: Session.Info }), }).annotateMerge( diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index ce1e5b91ef1..c627366452f 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -9,6 +9,7 @@ import { Model } from "./model.js" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js" import { FileAttachment } from "./prompt.js" import { SessionID } from "./session-id.js" +import { SessionMetadata } from "./session-metadata.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" import { Revert } from "./session-revert.js" @@ -59,6 +60,8 @@ export const Created = Event.durable({ title: Schema.String.pipe(optional), agent: Agent.ID.pipe(optional), model: Model.Ref.pipe(optional), + /** Host-supplied annotations resolved at creation, including any inherited from a parent. */ + metadata: SessionMetadata.pipe(optional), version: Schema.String, }, }) diff --git a/packages/schema/src/session-metadata.ts b/packages/schema/src/session-metadata.ts new file mode 100644 index 00000000000..8721b6704c9 --- /dev/null +++ b/packages/schema/src/session-metadata.ts @@ -0,0 +1,11 @@ +import { Schema } from "effect" + +/** + * Host-supplied session annotations, durable from creation and opaque to + * core. Keys are arbitrary; values must be JSON-serializable. Children and + * forks inherit the parent's metadata unless the creator supplies its own. + */ +export const SessionMetadata = Schema.Record(Schema.String, Schema.Json).annotate({ + identifier: "Session.Metadata", +}) +export type SessionMetadata = typeof SessionMetadata.Type diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index ab009101915..530b1ac9be3 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -8,6 +8,7 @@ import { Project } from "./project.js" import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js" import { SessionEvent } from "./session-event.js" import { SessionID } from "./session-id.js" +import { SessionMetadata } from "./session-metadata.js" import { Money } from "./money.js" import { TokenUsage } from "./token-usage.js" import { Revert } from "./session-revert.js" @@ -16,6 +17,9 @@ import { SessionFork } from "./session-fork.js" export const ID = SessionID export type ID = SessionID +export const Metadata = SessionMetadata +export type Metadata = SessionMetadata + export const Event = SessionEvent export { Revert } @@ -49,6 +53,7 @@ export const Info = Schema.Struct({ title: Schema.String.pipe(optional), location: Location.Ref, subpath: RelativePath.pipe(optional), + metadata: Metadata.pipe(optional), revert: Revert.pipe(optional), }).annotate({ identifier: "Session.Info" }) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 3f0c24dab21..a8e8db9ba31 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -119,6 +119,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl title: ctx.payload.title, agent: ctx.payload.agent, model: ctx.payload.model, + metadata: ctx.payload.metadata, location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, }) .pipe(Effect.orDie),