mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 09:52:23 +00:00
feat(core): durable session metadata at creation (#45805)
This commit is contained in:
parent
e0bad10065
commit
762291b2a8
15 changed files with 95 additions and 3 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)}`),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
11
packages/schema/src/session-metadata.ts
Normal file
11
packages/schema/src/session-metadata.ts
Normal 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
|
||||
|
|
@ -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" })
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue