feat(core): durable session metadata at creation (#45805)

This commit is contained in:
Kit Langton 2026-08-27 22:30:55 -04:00 committed by GitHub
parent e0bad10065
commit 762291b2a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 95 additions and 3 deletions

View file

@ -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<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@ -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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,7 +41,7 @@ export const SessionTable = sqliteTable(
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<FileDiff.LegacyInfo[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
metadata: text({ mode: "json" }).$type<Session.Metadata>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
tokens_output: integer().notNull().default(0),

View file

@ -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)}`),

View file

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

View file

@ -174,6 +174,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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(

View file

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

View file

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

View file

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

View file

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