feat(core): add session metadata support (#23068)

Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
Shantur Rathore
2026-05-30 16:58:11 -05:00
committed by GitHub
co-authored by Aiden Cline
parent ac8e686f33
commit ddc30cd151
14 changed files with 1707 additions and 1 deletions
@@ -0,0 +1 @@
ALTER TABLE `session` ADD `metadata` text;
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,7 @@ export const MessagesQuery = Schema.Struct({
export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({ export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String), title: Schema.optional(Schema.String),
metadata: Schema.optional(Session.Metadata),
permission: Schema.optional(Permission.Ruleset), permission: Schema.optional(Permission.Ruleset),
time: Schema.optional( time: Schema.optional(
Schema.Struct({ Schema.Struct({
@@ -185,6 +185,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
if (ctx.payload.title !== undefined) { if (ctx.payload.title !== undefined) {
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
} }
if (ctx.payload.metadata !== undefined) {
yield* session.setMetadata({ sessionID: ctx.params.sessionID, metadata: ctx.payload.metadata })
}
if (ctx.payload.permission !== undefined) { if (ctx.payload.permission !== undefined) {
yield* session.setPermission({ yield* session.setPermission({
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
@@ -202,7 +205,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
payload?: typeof ForkPayload.Type payload?: typeof ForkPayload.Type
}) { }) {
return yield* SessionError.mapStorageNotFound( return yield* SessionError.mapStorageNotFound(
session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload?.messageID }), session.fork({
sessionID: ctx.params.sessionID,
messageID: ctx.payload?.messageID,
}),
) )
}) })
@@ -79,6 +79,7 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")), summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
summary_files: grab(info, "summary", (v) => grab(v, "files")), summary_files: grab(info, "summary", (v) => grab(v, "files")),
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
metadata: grab(info, "metadata"),
cost: grab(info, "cost"), cost: grab(info, "cost"),
tokens_input: grab(info, "tokens", (v) => grab(v, "input")), tokens_input: grab(info, "tokens", (v) => grab(v, "input")),
tokens_output: grab(info, "tokens", (v) => grab(v, "output")), tokens_output: grab(info, "tokens", (v) => grab(v, "output")),
@@ -33,6 +33,7 @@ export const SessionTable = sqliteTable(
summary_deletions: integer(), summary_deletions: integer(),
summary_files: integer(), summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(), summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
cost: real().notNull().default(0), cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0), tokens_input: integer().notNull().default(0),
tokens_output: integer().notNull().default(0), tokens_output: integer().notNull().default(0),
+23
View File
@@ -100,6 +100,7 @@ export function fromRow(row: SessionRow): Info {
}, },
}, },
share, share,
metadata: row.metadata ?? undefined,
revert, revert,
permission: row.permission ? [...row.permission] : undefined, permission: row.permission ? [...row.permission] : undefined,
time: { time: {
@@ -129,6 +130,7 @@ export function toRow(info: Info) {
summary_deletions: info.summary?.deletions, summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files, summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs, summary_diffs: info.summary?.diffs,
metadata: info.metadata,
cost: info.cost ?? 0, cost: info.cost ?? 0,
tokens_input: (info.tokens ?? EmptyTokens).input, tokens_input: (info.tokens ?? EmptyTokens).input,
tokens_output: (info.tokens ?? EmptyTokens).output, tokens_output: (info.tokens ?? EmptyTokens).output,
@@ -205,6 +207,8 @@ const Model = Schema.Struct({
variant: optionalOmitUndefined(Schema.String), variant: optionalOmitUndefined(Schema.String),
}) })
export const Metadata = Schema.Record(Schema.String, Schema.Any)
export const Info = Schema.Struct({ export const Info = Schema.Struct({
id: SessionID, id: SessionID,
slug: Schema.String, slug: Schema.String,
@@ -221,6 +225,7 @@ export const Info = Schema.Struct({
agent: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String),
model: optionalOmitUndefined(Model), model: optionalOmitUndefined(Model),
version: Schema.String, version: Schema.String,
metadata: optionalOmitUndefined(Metadata),
time: Time, time: Time,
permission: optionalOmitUndefined(Permission.Ruleset), permission: optionalOmitUndefined(Permission.Ruleset),
revert: optionalOmitUndefined(Revert), revert: optionalOmitUndefined(Revert),
@@ -246,6 +251,7 @@ export const CreateInput = Schema.optional(
title: Schema.optional(Schema.String), title: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String), agent: Schema.optional(Schema.String),
model: Schema.optional(Model), model: Schema.optional(Model),
metadata: Schema.optional(Metadata),
permission: Schema.optional(Permission.Ruleset), permission: Schema.optional(Permission.Ruleset),
workspaceID: Schema.optional(WorkspaceID), workspaceID: Schema.optional(WorkspaceID),
}), }),
@@ -264,6 +270,10 @@ export const SetArchivedInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
time: Schema.optional(ArchivedTimestamp), time: Schema.optional(ArchivedTimestamp),
}) })
export const SetMetadataInput = Schema.Struct({
sessionID: SessionID,
metadata: Metadata,
})
export const SetPermissionInput = Schema.Struct({ export const SetPermissionInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
permission: Permission.Ruleset, permission: Permission.Ruleset,
@@ -320,6 +330,7 @@ const UpdatedInfo = Schema.Struct({
agent: Schema.optional(Schema.NullOr(Schema.String)), agent: Schema.optional(Schema.NullOr(Schema.String)),
model: Schema.optional(Schema.NullOr(Model)), model: Schema.optional(Schema.NullOr(Model)),
version: Schema.optional(Schema.NullOr(Schema.String)), version: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(Schema.NullOr(Metadata)),
time: Schema.optional(UpdatedTime), time: Schema.optional(UpdatedTime),
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
revert: Schema.optional(Schema.NullOr(Revert)), revert: Schema.optional(Schema.NullOr(Revert)),
@@ -455,6 +466,7 @@ export interface Interface {
title?: string title?: string
agent?: string agent?: string
model?: Schema.Schema.Type<typeof Model> model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset permission?: Permission.Ruleset
workspaceID?: WorkspaceID workspaceID?: WorkspaceID
}) => Effect.Effect<Info> }) => Effect.Effect<Info>
@@ -463,6 +475,7 @@ export interface Interface {
readonly get: (id: SessionID) => Effect.Effect<Info, NotFound> readonly get: (id: SessionID) => Effect.Effect<Info, NotFound>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void> readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void> readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void> readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
readonly setRevert: (input: { readonly setRevert: (input: {
sessionID: SessionID sessionID: SessionID
@@ -529,6 +542,7 @@ export const layer: Layer.Layer<
workspaceID?: WorkspaceID workspaceID?: WorkspaceID
directory: string directory: string
path?: string path?: string
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset permission?: Permission.Ruleset
}) { }) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
@@ -544,6 +558,7 @@ export const layer: Layer.Layer<
title: input.title ?? createDefaultTitle(!!input.parentID), title: input.title ?? createDefaultTitle(!!input.parentID),
agent: input.agent, agent: input.agent,
model: input.model, model: input.model,
metadata: input.metadata,
permission: input.permission ? [...input.permission] : undefined, permission: input.permission ? [...input.permission] : undefined,
cost: 0, cost: 0,
tokens: EmptyTokens, tokens: EmptyTokens,
@@ -659,6 +674,7 @@ export const layer: Layer.Layer<
title?: string title?: string
agent?: string agent?: string
model?: Schema.Schema.Type<typeof Model> model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset permission?: Permission.Ruleset
workspaceID?: WorkspaceID workspaceID?: WorkspaceID
}) { }) {
@@ -671,6 +687,7 @@ export const layer: Layer.Layer<
title: input?.title, title: input?.title,
agent: input?.agent, agent: input?.agent,
model: input?.model, model: input?.model,
metadata: input?.metadata,
permission: input?.permission, permission: input?.permission,
workspaceID: input?.workspaceID ?? workspace, workspaceID: input?.workspaceID ?? workspace,
}) })
@@ -685,6 +702,7 @@ export const layer: Layer.Layer<
path: sessionPath(ctx.worktree, ctx.directory), path: sessionPath(ctx.worktree, ctx.directory),
workspaceID: original.workspaceID, workspaceID: original.workspaceID,
title, title,
metadata: structuredClone(original.metadata),
}) })
const msgs = yield* messages({ sessionID: input.sessionID }) const msgs = yield* messages({ sessionID: input.sessionID })
const idMap = new Map<string, MessageID>() const idMap = new Map<string, MessageID>()
@@ -732,6 +750,10 @@ export const layer: Layer.Layer<
yield* patch(input.sessionID, { time: { archived: input.time } }) yield* patch(input.sessionID, { time: { archived: input.time } })
}) })
const setMetadata = Effect.fn("Session.setMetadata")(function* (input: typeof SetMetadataInput.Type) {
yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } })
})
const setPermission = Effect.fn("Session.setPermission")(function* (input: { const setPermission = Effect.fn("Session.setPermission")(function* (input: {
sessionID: SessionID sessionID: SessionID
permission: Permission.Ruleset permission: Permission.Ruleset
@@ -844,6 +866,7 @@ export const layer: Layer.Layer<
get, get,
setTitle, setTitle,
setArchived, setArchived,
setMetadata,
setPermission, setPermission,
setRevert, setRevert,
clearRevert, clearRevert,
@@ -16,6 +16,85 @@ afterEach(async () => {
}) })
describe("session action routes", () => { describe("session action routes", () => {
it.instance(
"session routes expose metadata on create, update, get, and fork",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const app = Server.Default().app
const headers = { "Content-Type": "application/json", "x-opencode-directory": test.directory }
const created = yield* Effect.promise(() =>
Promise.resolve(
app.request("/session", {
method: "POST",
headers,
body: JSON.stringify({
title: "meta-session",
metadata: { source: "sdk", trace: { id: "abc" } },
}),
}),
),
)
expect(created.status).toBe(200)
const session = (yield* Effect.promise(() => created.json())) as SessionNs.Info
expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } })
const updated = yield* Effect.promise(() =>
Promise.resolve(
app.request(`/session/${session.id}`, {
method: "PATCH",
headers,
body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }),
}),
),
)
expect(updated.status).toBe(200)
const next = (yield* Effect.promise(() => updated.json())) as SessionNs.Info
expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] })
const fetched = yield* Effect.promise(() =>
Promise.resolve(
app.request(`/session/${session.id}`, { headers: { "x-opencode-directory": test.directory } }),
),
)
expect(fetched.status).toBe(200)
expect(((yield* Effect.promise(() => fetched.json())) as SessionNs.Info).metadata).toEqual(next.metadata)
const forked = yield* Effect.promise(() =>
Promise.resolve(
app.request(`/session/${session.id}/fork`, {
method: "POST",
headers,
body: JSON.stringify({}),
}),
),
)
expect(forked.status).toBe(200)
const fork = (yield* Effect.promise(() => forked.json())) as SessionNs.Info
expect(fork.metadata).toEqual(next.metadata)
const reset = yield* Effect.promise(() =>
Promise.resolve(
app.request(`/session/${session.id}`, {
method: "PATCH",
headers,
body: JSON.stringify({ metadata: {} }),
}),
),
)
expect(reset.status).toBe(200)
expect(((yield* Effect.promise(() => reset.json())) as SessionNs.Info).metadata).toEqual({})
yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore))
yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore))
}),
{ git: true },
)
it.instance( it.instance(
"abort route returns success", "abort route returns success",
() => () =>
@@ -227,4 +227,20 @@ describe("session.list", () => {
}), }),
{ git: true }, { git: true },
) )
it.instance(
"includes metadata in listed sessions",
() =>
Effect.gen(function* () {
const meta = { source: "sdk", trace: { id: "abc" } }
const created = yield* withSession({ title: "meta-session", metadata: meta })
const listed = (yield* SessionNs.Service.use((session) => session.list({ search: "meta-session" }))).find(
(item) => item.id === created.id,
)
expect(listed?.metadata).toEqual(meta)
}),
{ git: true },
)
}) })
@@ -64,6 +64,7 @@ describe("Session.Info", () => {
share: { url: "https://share.example.com/s/1" }, share: { url: "https://share.example.com/s/1" },
title: "Full session", title: "Full session",
version: "1.0.0", version: "1.0.0",
metadata: { source: "test" },
time: { created: 100, updated: 200, compacting: 150, archived: 300 }, time: { created: 100, updated: 200, compacting: 150, archived: 300 },
permission: [{ action: "allow" as const, pattern: "*", permission: "read" }], permission: [{ action: "allow" as const, pattern: "*", permission: "read" }],
revert: { revert: {
@@ -157,6 +158,7 @@ describe("Session input schemas", () => {
const populated = { const populated = {
parentID: sessionID, parentID: sessionID,
title: "child", title: "child",
metadata: { source: "test" },
permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }], permission: [{ action: "ask" as const, pattern: "*", permission: "bash" }],
workspaceID, workspaceID,
} }
@@ -184,4 +184,35 @@ describe("Session", () => {
expect(Exit.isFailure(getExit)).toBe(true) expect(Exit.isFailure(getExit)).toBe(true)
}), }),
) )
it.instance("persists metadata and copies it on fork by default", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const meta = { source: "sdk", trace: { id: "abc" } }
const created = yield* Effect.acquireRelease(session.create({ title: "with-meta", metadata: meta }), (info) =>
session.remove(info.id).pipe(Effect.ignore),
)
const saved = yield* session.get(created.id)
const fork = yield* Effect.acquireRelease(session.fork({ sessionID: created.id }), (info) =>
session.remove(info.id).pipe(Effect.ignore),
)
expect(saved.metadata).toEqual(meta)
expect(fork.metadata).toEqual(meta)
expect(fork.metadata).not.toBe(meta)
}),
)
it.instance("omits metadata when not provided", () =>
Effect.gen(function* () {
const session = yield* SessionNs.Service
const created = yield* Effect.acquireRelease(session.create({ title: "empty-meta" }), (info) =>
session.remove(info.id).pipe(Effect.ignore),
)
const saved = yield* session.get(created.id)
expect(created.metadata).toBeUndefined()
expect(saved.metadata).toBeUndefined()
}),
)
}) })
+8
View File
@@ -3100,6 +3100,9 @@ export class Session2 extends HeyApiClient {
providerID: string providerID: string
variant?: string variant?: string
} }
metadata?: {
[key: string]: unknown
}
permission?: PermissionRuleset permission?: PermissionRuleset
workspaceID?: string workspaceID?: string
}, },
@@ -3116,6 +3119,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "title" }, { in: "body", key: "title" },
{ in: "body", key: "agent" }, { in: "body", key: "agent" },
{ in: "body", key: "model" }, { in: "body", key: "model" },
{ in: "body", key: "metadata" },
{ in: "body", key: "permission" }, { in: "body", key: "permission" },
{ in: "body", key: "workspaceID" }, { in: "body", key: "workspaceID" },
], ],
@@ -3239,6 +3243,9 @@ export class Session2 extends HeyApiClient {
directory?: string directory?: string
workspace?: string workspace?: string
title?: string title?: string
metadata?: {
[key: string]: unknown
}
permission?: PermissionRuleset permission?: PermissionRuleset
time?: { time?: {
archived?: number archived?: number
@@ -3255,6 +3262,7 @@ export class Session2 extends HeyApiClient {
{ in: "query", key: "directory" }, { in: "query", key: "directory" },
{ in: "query", key: "workspace" }, { in: "query", key: "workspace" },
{ in: "body", key: "title" }, { in: "body", key: "title" },
{ in: "body", key: "metadata" },
{ in: "body", key: "permission" }, { in: "body", key: "permission" },
{ in: "body", key: "time" }, { in: "body", key: "time" },
], ],
+15
View File
@@ -780,6 +780,9 @@ export type Session = {
variant?: string variant?: string
} }
version: string version: string
metadata?: {
[key: string]: unknown
}
time: { time: {
created: number created: number
updated: number updated: number
@@ -1516,6 +1519,9 @@ export type GlobalSession = {
variant?: string variant?: string
} }
version: string version: string
metadata?: {
[key: string]: unknown
}
time: { time: {
created: number created: number
updated: number updated: number
@@ -2086,6 +2092,9 @@ export type SyncEventSessionUpdated = {
variant?: string variant?: string
} | null } | null
version?: string | null version?: string | null
metadata?: {
[key: string]: unknown
} | null
time?: { time?: {
created?: number | null created?: number | null
updated?: number | null updated?: number | null
@@ -6093,6 +6102,9 @@ export type SessionCreateData = {
providerID: string providerID: string
variant?: string variant?: string
} }
metadata?: {
[key: string]: unknown
}
permission?: PermissionRuleset permission?: PermissionRuleset
workspaceID?: string workspaceID?: string
} }
@@ -6223,6 +6235,9 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]
export type SessionUpdateData = { export type SessionUpdateData = {
body?: { body?: {
title?: string title?: string
metadata?: {
[key: string]: unknown
}
permission?: PermissionRuleset permission?: PermissionRuleset
time?: { time?: {
archived?: number archived?: number
+22
View File
@@ -5185,6 +5185,9 @@
"required": ["id", "providerID"], "required": ["id", "providerID"],
"additionalProperties": false "additionalProperties": false
}, },
"metadata": {
"type": "object"
},
"permission": { "permission": {
"$ref": "#/components/schemas/PermissionRuleset" "$ref": "#/components/schemas/PermissionRuleset"
}, },
@@ -5509,6 +5512,9 @@
"title": { "title": {
"type": "string" "type": "string"
}, },
"metadata": {
"type": "object"
},
"permission": { "permission": {
"$ref": "#/components/schemas/PermissionRuleset" "$ref": "#/components/schemas/PermissionRuleset"
}, },
@@ -12819,6 +12825,9 @@
"version": { "version": {
"type": "string" "type": "string"
}, },
"metadata": {
"type": "object"
},
"time": { "time": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -14912,6 +14921,9 @@
"version": { "version": {
"type": "string" "type": "string"
}, },
"metadata": {
"type": "object"
},
"time": { "time": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -16758,6 +16770,16 @@
} }
] ]
}, },
"metadata": {
"anyOf": [
{
"type": "object"
},
{
"type": "null"
}
]
},
"time": { "time": {
"type": "object", "type": "object",
"properties": { "properties": {