From fc11ed38388b37e05b7262a9160248e13f19d66d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 09:49:35 -0400 Subject: [PATCH] feat(session): define explicit fork boundaries --- packages/app/src/components/dialog-fork.tsx | 2 +- packages/cli/src/acp/service.ts | 5 +- packages/cli/src/session-target.ts | 10 +- packages/client/src/effect/api/api.ts | 8 +- .../client/src/effect/generated/client.ts | 2 +- .../client/src/promise/generated/client.ts | 2 +- .../client/src/promise/generated/types.ts | 10 +- packages/core/schema.json | 16 +- packages/core/src/database/migration.gen.ts | 1 + .../20260729022634_session_fork_boundary.ts | 13 + packages/core/src/database/schema.gen.ts | 3 +- packages/core/src/session.ts | 47 ++-- packages/core/src/session/error.ts | 8 + packages/core/src/session/info.ts | 14 +- .../core/src/session/instruction-state.ts | 103 ++++--- packages/core/src/session/projector.ts | 39 +-- packages/core/src/session/sql.ts | 3 +- packages/core/test/session-create.test.ts | 34 ++- packages/core/test/session-runner.test.ts | 20 +- packages/protocol/openapi.json | 262 +++++++++++++----- packages/protocol/src/groups/session.ts | 7 +- packages/schema/src/session-event.ts | 5 +- packages/schema/src/session-fork.ts | 16 ++ packages/schema/src/session.ts | 9 +- packages/server/src/handlers/session.ts | 6 +- packages/tui/src/app.tsx | 4 +- .../tui/src/routes/session/dialog-fork.tsx | 34 +-- packages/www/openapi.json | 262 +++++++++++++----- packages/www/public/openapi.json | 262 +++++++++++++----- 29 files changed, 821 insertions(+), 386 deletions(-) create mode 100644 packages/core/src/database/migration/20260729022634_session_fork_boundary.ts create mode 100644 packages/schema/src/session-fork.ts diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 5187d980ea..91d3490732 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,7 +69,7 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .api.session.fork({ sessionID, messageID: item.id }) + .api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .then((forked) => { dialog.close() prompt.set(restored, undefined, { dir, id: forked.id }) diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index 963b5c3ed4..897d88c8d2 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -244,7 +244,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti return {} }, forkSession: async (params) => { - const forked = await input.client.session.fork({ sessionID: params.sessionId }) + const forked = await input.client.session.fork({ + sessionID: params.sessionId, + boundary: { type: "through" }, + }) const state = await attach(forked, forked.location.directory, params.mcpServers ?? []) await replay(state) return { sessionId: state.id, configOptions: configOptions(state) } diff --git a/packages/cli/src/session-target.ts b/packages/cli/src/session-target.ts index 975f4829b2..4bd670a85d 100644 --- a/packages/cli/src/session-target.ts +++ b/packages/cli/src/session-target.ts @@ -105,7 +105,7 @@ async function selectSession(input: { return { session: input.fork ? await input.client.session - .fork({ sessionID: explicit.id }, ...requestOptions(input.signal)) + .fork({ sessionID: explicit.id, boundary: { type: "through" } }, ...requestOptions(input.signal)) .catch((error) => { throw new SessionTargetMutationError(error) }) @@ -118,9 +118,11 @@ async function selectSession(input: { if (!selected) return { session: undefined, location } return { session: input.fork - ? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => { - throw new SessionTargetMutationError(error) - }) + ? await input.client.session + .fork({ sessionID: selected.id, boundary: { type: "through" } }, ...requestOptions(input.signal)) + .catch((error) => { + throw new SessionTargetMutationError(error) + }) : selected, } } diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 3bb65cc007..80df5a87e4 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -136,7 +136,7 @@ export type Endpoint5_4Input = { readonly sessionID: Session.ID } export type Endpoint5_4Output = void export type SessionRemoveOperation = (input: Endpoint5_4Input) => Effect.Effect -export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly messageID?: SessionMessage.ID | undefined } +export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary } export type Endpoint5_5Output = Session.Info export type SessionForkOperation = (input: Endpoint5_5Input) => Effect.Effect @@ -342,8 +342,10 @@ export type Endpoint5_26Output = readonly data: { readonly sessionID: Session.ID readonly parentID: Session.ID - readonly parentSeq: number - readonly from?: SessionMessage.ID | undefined + readonly boundary: Session.ForkBoundary + readonly instructions?: + | { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> } + | undefined } } | { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 8b457db56f..7706c9aa9b 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -329,7 +329,7 @@ const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Inp const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) => preserveEffect()( - raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe( + raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index e6edc8a856..aa0563f948 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -510,7 +510,7 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`, - body: { messageID: input["messageID"] }, + body: { boundary: input["boundary"] }, successStatus: 200, declaredStatuses: [404, 400, 401], empty: false, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 4f6dad595b..9868099438 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -14,6 +14,8 @@ export type PermissionEffect = "allow" | "deny" | "ask" export type PluginInfo = { id: string } +export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string } + export type MoneyUSD = number export type TokenUsageInfo = { @@ -622,7 +624,7 @@ export type SessionForked = { type: "session.forked" durable: { aggregateID: string; seq: number; version: 2 } location?: LocationRef - data: { sessionID: string; parentID: string; parentSeq: number; from?: string } + data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } } } export type SessionInputPromoted = { @@ -1748,7 +1750,7 @@ export type PermissionRuleset = Array export type SessionInfo = { id: string parentID?: string - fork?: { sessionID: string; messageID?: string } + fork?: { sessionID: string; boundary: SessionForkBoundary } projectID: string agent?: string model?: ModelRef @@ -2694,7 +2696,9 @@ export type SessionRemoveOutput = void export type SessionForkInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly messageID?: { readonly messageID?: string | undefined }["messageID"] + readonly boundary: { + readonly boundary: { readonly type: "before"; readonly messageID: string } | { readonly type: "through" } + }["boundary"] } export type SessionForkOutput = { data: SessionInfo }["data"] diff --git a/packages/core/schema.json b/packages/core/schema.json index 2ef980ade3..7ed6583e21 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "a4ba73b4-21bc-41ab-a415-94e2ca38d798", + "id": "db37a97f-9b5e-4c87-be8b-4feace35136c", "prevIds": [ - "5f0a1db8-d4bf-42c3-becb-96b46fe66bed" + "a4ba73b4-21bc-41ab-a415-94e2ca38d798" ], "ddl": [ { @@ -1266,17 +1266,7 @@ "autoincrement": false, "default": null, "generated": null, - "name": "fork_message_id", - "entityType": "columns", - "table": "session" - }, - { - "type": "integer", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "fork_seq", + "name": "fork_boundary", "entityType": "columns", "table": "session" }, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index a7eaed9b73..ee88d30f17 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -57,5 +57,6 @@ export const migrations = ( import("./migration/20260716020354_kv"), import("./migration/20260722011141_delete_tool_progress_events"), import("./migration/20260722170000_canonical_tool_results"), + import("./migration/20260729022634_session_fork_boundary"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts b/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts new file mode 100644 index 0000000000..bf574b4807 --- /dev/null +++ b/packages/core/src/database/migration/20260729022634_session_fork_boundary.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260729022634_session_fork_boundary", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_boundary\` text;`) + yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_message_id\`;`) + yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_seq\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index b92b75e47b..05c63f435d 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -213,8 +213,7 @@ export default { \`workspace_id\` text, \`parent_id\` text, \`fork_session_id\` text, - \`fork_message_id\` text, - \`fork_seq\` integer, + \`fork_boundary\` text, \`slug\` text NOT NULL, \`directory\` text NOT NULL, \`path\` text, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index c2cdbb3ffc..0ac1b5cc62 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -28,11 +28,12 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" -import { MessageDecodeError, NotFoundError } from "./session/error" +import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { LocationServiceMap } from "./location-service-map" import { SessionEvent } from "./session/event" import { SessionPending } from "./session/pending" +import { InstructionState } from "./session/instruction-state" import { SessionGenerate } from "./session/generate" import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" @@ -106,7 +107,7 @@ type CompactInput = { type ForkInput = { sessionID: SessionSchema.ID - messageID?: SessionMessage.ID + boundary: Session.ForkRequestBoundary } export class OperationUnavailableError extends Schema.TaggedErrorClass()( @@ -181,7 +182,9 @@ export interface Interface { readonly data: SessionSchema.Info[] }> readonly create: (input: CreateInput) => Effect.Effect - readonly fork: (input: ForkInput) => Effect.Effect + readonly fork: ( + input: ForkInput, + ) => Effect.Effect readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect readonly messages: (input: { @@ -395,25 +398,33 @@ const layer = Layer.effect( }), fork: Effect.fn("Session.fork")(function* (input) { const parent = yield* result.get(input.sessionID) - const boundary = input.messageID - ? yield* db - .select({ seq: SessionMessageTable.seq }) - .from(SessionMessageTable) - .where( - and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)), - ) - .get() - .pipe(Effect.orDie) - : undefined - if (input.messageID && !boundary) - return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID }) + const boundary = yield* db + .select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, input.sessionID), + input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined, + ), + ) + .orderBy(desc(SessionMessageTable.seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + if (!boundary && input.boundary.type === "before") + return yield* new MessageNotFoundError({ + sessionID: input.sessionID, + messageID: input.boundary.messageID, + }) + if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID }) const sessionID = SessionSchema.ID.create() - const parentSeq = boundary ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id) + const instructionThrough = + input.boundary.type === "before" ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id) yield* bus.publish(SessionEvent.Forked, { sessionID, parentID: parent.id, - parentSeq, - from: input.messageID, + boundary: { ...input.boundary, messageID: boundary.id }, + instructions: yield* InstructionState.valuesAt(db, parent.id, instructionThrough), }) return yield* result.get(sessionID).pipe(Effect.orDie) }), diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 42a71500ee..940f74cca5 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -10,6 +10,14 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses sessionID: SessionSchema.ID, }) {} +export class ForkEmptyError extends Schema.TaggedErrorClass()("Session.ForkEmptyError", { + sessionID: SessionSchema.ID, +}) { + override get message() { + return `Cannot fork empty session: ${this.sessionID}` + } +} + export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { sessionID: SessionSchema.ID, messageID: SessionMessage.ID, diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 83d0297bd2..ed13a4d317 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -8,7 +8,6 @@ import { AbsolutePath, RelativePath } from "../schema" import { Workspace } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" -import { SessionMessage } from "./message" import { PersistedRevert } from "@opencode-ai/schema/session-revert" import { Money } from "@opencode-ai/schema/money" @@ -20,12 +19,13 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In projectID: Project.ID.make(row.project_id), title: row.title, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, - fork: row.fork_session_id - ? { - sessionID: SessionSchema.ID.make(row.fork_session_id), - messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined, - } - : undefined, + fork: + row.fork_session_id && row.fork_boundary + ? { + sessionID: SessionSchema.ID.make(row.fork_session_id), + boundary: row.fork_boundary, + } + : undefined, agent: row.agent ? Agent.ID.make(row.agent) : undefined, model: row.model ? { diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts index 364dfba8a5..119bff62ca 100644 --- a/packages/core/src/session/instruction-state.ts +++ b/packages/core/src/session/instruction-state.ts @@ -10,11 +10,12 @@ import { SessionEvent } from "./event" import { SessionMessage } from "./message" import { Event } from "@opencode-ai/schema/event" import { SessionSchema } from "./schema" -import { InstructionBlobTable, InstructionStateTable, SessionTable } from "./sql" +import { InstructionBlobTable, InstructionStateTable } from "./sql" type DatabaseService = Database.Interface["db"] const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data) +const decodeForked = Schema.decodeUnknownSync(SessionEvent.Forked.data) export interface Observation extends Instructions.Admission { readonly sessionID: SessionSchema.ID @@ -94,6 +95,26 @@ export const apply = Effect.fn("InstructionState.apply")(function* ( .pipe(Effect.orDie) }) +export const initialize = Effect.fn("InstructionState.initialize")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + seq: number, + values: Instructions.Values, +) { + yield* db + .insert(InstructionStateTable) + .values({ + session_id: sessionID, + epoch_start: seq, + through_seq: seq, + initial_values: values, + current_values: values, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) +}) + export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, @@ -255,6 +276,14 @@ const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessio return folded ? foldedState(sessionID, folded) : undefined }) +export const valuesAt = Effect.fn("InstructionState.valuesAt")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + through: number, +) { + return fold(yield* instructionEvents(db, sessionID, through))?.current +}) + const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* db .select({ seq: EventTable.seq }) @@ -324,15 +353,23 @@ const revertedEventType = Bus.versionedType( SessionEvent.RevertEvent.Committed.type, SessionEvent.RevertEvent.Committed.durable.version, ) -const relevantEventTypes = [instructionEventType, compactionEventType, movedEventType, revertedEventType] +const forkedEventType = Bus.versionedType(SessionEvent.Forked.type, SessionEvent.Forked.durable.version) +const relevantEventTypes = [ + forkedEventType, + instructionEventType, + compactionEventType, + movedEventType, + revertedEventType, +] type InstructionEventRow = typeof EventTable.$inferSelect const instructionEvents = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, + through?: number, ): Effect.fn.Return> { - return yield* eventRows(db, sessionID, relevantEventTypes) + return yield* eventRows(db, sessionID, relevantEventTypes, undefined, through) }) const instructionUpdatesAfter = Effect.fnUntraced(function* ( @@ -348,48 +385,22 @@ const eventRows = Effect.fnUntraced(function* ( sessionID: SessionSchema.ID, types: ReadonlyArray, after?: number, -): Effect.fn.Return> { - const segments = (yield* lineage(db, sessionID)).filter( - (segment) => after === undefined || segment.through === undefined || segment.through > after, - ) - return (yield* Effect.forEach(segments, (segment) => - db - .select() - .from(EventTable) - .where( - and( - eq(EventTable.aggregate_id, segment.sessionID), - inArray(EventTable.type, types), - segment.through === undefined ? undefined : lte(EventTable.seq, segment.through), - after === undefined ? undefined : gt(EventTable.seq, after), - ), - ) - .orderBy(asc(EventTable.seq)) - .all() - .pipe(Effect.orDie), - )).flat() -}) - -const lineage = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, through?: number, -): Effect.fn.Return> { - const session = yield* db - .select({ parentID: SessionTable.fork_session_id, forkSeq: SessionTable.fork_seq }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() +): Effect.fn.Return> { + return yield* db + .select() + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, sessionID), + inArray(EventTable.type, types), + after === undefined ? undefined : gt(EventTable.seq, after), + through === undefined ? undefined : lte(EventTable.seq, through), + ), + ) + .orderBy(asc(EventTable.seq)) + .all() .pipe(Effect.orDie) - const inherited = - session?.parentID && session.forkSeq !== null - ? yield* lineage( - db, - session.parentID, - through === undefined ? session.forkSeq : Math.min(session.forkSeq, through), - ) - : [] - return [...inherited, { sessionID, ...(through === undefined ? {} : { through }) }] }) function fold(rows: ReadonlyArray) { @@ -402,6 +413,12 @@ function fold(rows: ReadonlyArray) { } | undefined >((state, row) => { + if (row.type === forkedEventType) { + const instructions = decodeForked(row.data).instructions + return instructions + ? { epochStart: row.seq, throughSeq: row.seq, initial: instructions, current: instructions } + : undefined + } if (row.type === movedEventType || row.type === revertedEventType) return undefined if (row.type === compactionEventType) return state diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 5c88813762..9d0062fee6 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,6 +1,6 @@ export * as SessionProjector from "./projector" -import { and, asc, desc, eq, gt, gte, inArray, lt, sql } from "drizzle-orm" +import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema, Stream } from "effect" import { Database } from "../database/database" import { Bus } from "../bus" @@ -174,25 +174,28 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .get() .pipe(Effect.orDie) if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`)) - const boundary = event.data.from - ? yield* db - .select({ seq: SessionMessageTable.seq }) - .from(SessionMessageTable) - .where( - and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.from)), - ) - .get() - .pipe(Effect.orDie) - : undefined - if (event.data.from && !boundary) - return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`)) + const boundary = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, event.data.parentID), + eq(SessionMessageTable.id, event.data.boundary.messageID), + ), + ) + .get() + .pipe(Effect.orDie) + if (!boundary) + return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.boundary.messageID}`)) const copied = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where( and( eq(SessionMessageTable.session_id, event.data.parentID), - boundary === undefined ? undefined : lt(SessionMessageTable.seq, boundary.seq), + event.data.boundary.type === "before" + ? lt(SessionMessageTable.seq, boundary.seq) + : lte(SessionMessageTable.seq, boundary.seq), ), ) .orderBy(desc(SessionMessageTable.seq)) @@ -207,8 +210,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( id: event.data.sessionID, parent_id: null, fork_session_id: event.data.parentID, - fork_message_id: event.data.from, - fork_seq: event.data.parentSeq, + fork_boundary: event.data.boundary, project_id: parent.project_id, workspace_id: parent.workspace_id, slug: Slug.create(), @@ -314,8 +316,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( cursor = rows.at(-1)!.seq } - yield* Bus.reserveSequence(db, event.data.sessionID, event.data.parentSeq) - yield* InstructionState.rebuild(db, event.data.sessionID) + if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq) + if (event.data.instructions) + yield* InstructionState.initialize(db, event.data.sessionID, event.durable.seq, event.data.instructions) }) function run(db: DatabaseService, event: MessageEvent) { diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 17654df1b3..9fbe678362 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -32,8 +32,7 @@ export const SessionTable = sqliteTable( workspace_id: text().$type(), parent_id: text().$type(), fork_session_id: text().$type(), - fork_message_id: text().$type(), - fork_seq: integer(), + fork_boundary: text({ mode: "json" }).$type(), slug: text().notNull(), directory: directoryColumn().notNull(), path: pathColumn(), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 968cb94713..1bda9a5488 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -199,7 +199,7 @@ describe("Session.create", () => { yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false }) yield* SessionPending.promote(db, bus, parent.id, "steer") - const forked = yield* session.fork({ sessionID: parent.id }) + const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }) const parentContext = yield* session.context(parent.id) const forkContext = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) @@ -252,6 +252,17 @@ describe("Session.create", () => { }), ) + it.effect("rejects forking an empty session", () => + Effect.gen(function* () { + const session = yield* Session.Service + const parent = yield* session.create({ location }) + + expect( + yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }).pipe(Effect.flip), + ).toMatchObject({ _tag: "Session.ForkEmptyError", sessionID: parent.id }) + }), + ) + it.effect("forks before the selected boundary message", () => Effect.gen(function* () { const session = yield* Session.Service @@ -286,16 +297,27 @@ describe("Session.create", () => { tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, }) - const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id }) - const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id }) - const complete = yield* session.fork({ sessionID: parent.id }) + const forked = yield* session.fork({ + sessionID: parent.id, + boundary: { type: "before", messageID: second.id }, + }) + const beforeFirst = yield* session.fork({ + sessionID: parent.id, + boundary: { type: "before", messageID: first.id }, + }) + const complete = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }) const context = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) - expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id }) + expect(forked.fork).toEqual({ + sessionID: parent.id, + boundary: { type: "before", messageID: second.id }, + }) expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) - expect(history[0]).toMatchObject({ data: { from: second.id } }) + expect(history[0]).toMatchObject({ + data: { boundary: { type: "before", messageID: second.id } }, + }) expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) expect(yield* session.context(beforeFirst.id)).toEqual([]) expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 3fc569c2f9..c6b7d7b9c7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1107,7 +1107,7 @@ describe("SessionRunnerLLM", () => { systemBaseline = "Latest context" yield* runPrompt(session, "Third") - const forked = yield* session.fork({ sessionID, messageID: second.id }) + const forked = yield* session.fork({ sessionID, boundary: { type: "before", messageID: second.id } }) expect( yield* (yield* Database.Service).db .select() @@ -1115,14 +1115,13 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionStateTable.session_id, forked.id)) .get(), ).toMatchObject({ - initial_values: { "test/context": Instructions.hash("Initial context") }, + initial_values: { "test/context": Instructions.hash("Changed context") }, current_values: { "test/context": Instructions.hash("Changed context") }, }) yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false }) yield* session.resume(forked.id) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) - expect(systemTexts(requests.at(-1)!)).toContain("Changed context") + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"]) expect(systemTexts(requests.at(-1)!)).toContain("Latest context") const { db } = yield* Database.Service @@ -1151,19 +1150,22 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("caps nested fork instruction ancestry at the selected message", () => + it.effect("keeps nested forks self-contained", () => Effect.gen(function* () { const session = yield* setup yield* runPrompt(session, "First") systemBaseline = "Changed context" const second = yield* runPrompt(session, "Second") - const child = yield* session.fork({ sessionID, messageID: second.id }) + const child = yield* session.fork({ sessionID, boundary: { type: "before", messageID: second.id } }) const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find( (message) => message.type === "user" && message.text === "First", ) if (!inheritedFirst) return yield* Effect.die(new Error("Nested fork boundary message not found")) - const grandchild = yield* session.fork({ sessionID: child.id, messageID: inheritedFirst.id }) + const grandchild = yield* session.fork({ + sessionID: child.id, + boundary: { type: "before", messageID: inheritedFirst.id }, + }) expect( yield* (yield* Database.Service).db @@ -1172,8 +1174,8 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionStateTable.session_id, grandchild.id)) .get(), ).toMatchObject({ - initial_values: { "test/context": Instructions.hash("Initial context") }, - current_values: { "test/context": Instructions.hash("Initial context") }, + initial_values: { "test/context": Instructions.hash("Changed context") }, + current_values: { "test/context": Instructions.hash("Changed context") }, }) return undefined }), diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index b7cc24d709..011fa3d928 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -1145,7 +1145,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1181,7 +1188,7 @@ } } }, - "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "description": "Create a child session by copying projected history through or before a message boundary.", "summary": "Fork session", "requestBody": { "content": { @@ -1189,22 +1196,13 @@ "schema": { "type": "object", "properties": { - "messageID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - { - "type": "null" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkRequestBoundary" } }, + "required": [ + "boundary" + ], "additionalProperties": false } } @@ -12223,6 +12221,58 @@ ], "additionalProperties": false }, + "Session.ForkBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + } + ] + }, "Money.USD": { "type": "number" }, @@ -12385,17 +12435,13 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" } }, "required": [ - "sessionID" + "sessionID", + "boundary" ], "additionalProperties": false }, @@ -12595,6 +12641,49 @@ ], "additionalProperties": false }, + "Session.ForkRequestBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, "MessageNotFoundError": { "type": "object", "properties": { @@ -12783,14 +12872,6 @@ "SessionPending.User": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -12828,7 +12909,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -12957,14 +13037,6 @@ "SessionPending.Synthetic": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13002,7 +13074,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13015,14 +13086,6 @@ "SessionPending.Compaction": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13050,7 +13113,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13705,7 +13767,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13784,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13810,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13864,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -14829,27 +14898,27 @@ } ] }, - "parentSeq": { - "type": "integer", - "allOf": [ - { - "minimum": -1 - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" }, - "from": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" + "instructions": { + "type": "object", + "patternProperties": { + "^[a-z0-9][a-z0-9._-]*\\/[a-z0-9][a-z0-9._/-]*$": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-f0-9]{64}$" + } + ] } - ] + } } }, "required": [ "sessionID", "parentID", - "parentSeq" + "boundary" ], "additionalProperties": false } @@ -17114,6 +17183,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17309,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17432,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 3e9a4a5015..be963b8a69 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -207,17 +207,16 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", { params: { sessionID: Session.ID }, - payload: Schema.Struct({ messageID: SessionMessage.ID.pipe(Schema.optional) }), + payload: Schema.Struct({ boundary: Session.ForkRequestBoundary }), success: Schema.Struct({ data: Session.Info }), - error: [SessionNotFoundError, MessageNotFoundError], + error: [SessionNotFoundError, MessageNotFoundError, InvalidRequestError], }) .middleware(sessionLocationMiddleware) .annotateMerge( OpenApi.annotations({ identifier: "v2.session.fork", summary: "Fork session", - description: - "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + description: "Create a child session by copying projected history through or before a message boundary.", }), ), ) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 6a9258a549..4a00ae4b4b 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -22,6 +22,7 @@ import { Snapshot } from "./snapshot.js" import { TokenUsage } from "./token-usage.js" import { SessionPending } from "./session-pending.js" import { Project } from "./project.js" +import { SessionFork } from "./session-fork.js" export { FileAttachment } @@ -127,8 +128,8 @@ export const Forked = Event.durable({ schema: { ...Base, parentID: SessionID, - parentSeq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)), - from: SessionMessage.ID.pipe(optional), + boundary: SessionFork.Boundary, + instructions: Instruction.Values.pipe(optional), }, }) export type Forked = typeof Forked.Type diff --git a/packages/schema/src/session-fork.ts b/packages/schema/src/session-fork.ts new file mode 100644 index 0000000000..d00df38c17 --- /dev/null +++ b/packages/schema/src/session-fork.ts @@ -0,0 +1,16 @@ +export * as SessionFork from "./session-fork.js" + +import { Schema } from "effect" +import { SessionMessage } from "./session-message.js" + +export const Boundary = Schema.Union([ + Schema.Struct({ type: Schema.Literal("before"), messageID: SessionMessage.ID }), + Schema.Struct({ type: Schema.Literal("through"), messageID: SessionMessage.ID }), +]).annotate({ identifier: "Session.ForkBoundary" }) +export type Boundary = typeof Boundary.Type + +export const RequestBoundary = Schema.Union([ + Schema.Struct({ type: Schema.Literal("before"), messageID: SessionMessage.ID }), + Schema.Struct({ type: Schema.Literal("through") }), +]).annotate({ identifier: "Session.ForkRequestBoundary" }) +export type RequestBoundary = typeof RequestBoundary.Type diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index fffb4b7b3d..e7e9525b24 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -8,10 +8,10 @@ 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 { SessionMessage } from "./session-message.js" import { Money } from "./money.js" import { TokenUsage } from "./token-usage.js" import { Revert } from "./session-revert.js" +import { SessionFork } from "./session-fork.js" export const ID = SessionID export type ID = SessionID @@ -19,6 +19,10 @@ export type ID = SessionID export const Event = SessionEvent export { Revert } +export const ForkBoundary = SessionFork.Boundary +export type ForkBoundary = SessionFork.Boundary +export const ForkRequestBoundary = SessionFork.RequestBoundary +export type ForkRequestBoundary = SessionFork.RequestBoundary export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ @@ -26,8 +30,7 @@ export const Info = Schema.Struct({ parentID: ID.pipe(optional), fork: Schema.Struct({ sessionID: ID, - /** Messages before this exclusive boundary are copied into the fork. */ - messageID: SessionMessage.ID.pipe(optional), + boundary: ForkBoundary, }).pipe(optional), projectID: Project.ID, agent: Agent.ID.pipe(optional), diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 796116da01..1f022483c3 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -131,7 +131,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl "session.fork", Effect.fn(function* (ctx) { return { - data: yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }).pipe( + data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe( Effect.catchTag( "Session.NotFoundError", (error) => @@ -149,6 +149,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl message: `Message not found: ${error.messageID}`, }), ), + Effect.catchTag( + "Session.ForkEmptyError", + (error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }), + ), ), } }), diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 19dc5cedec..a0f062e13b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -583,7 +583,7 @@ function App(props: { pair?: DialogPairCredentials }) { return } void client.api.session - .fork({ sessionID: match }) + .fork({ sessionID: match, boundary: { type: "through" } }) .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) @@ -596,7 +596,7 @@ function App(props: { pair?: DialogPairCredentials }) { if (forked || !args.sessionID || !args.fork) return forked = true void client.api.session - .fork({ sessionID: args.sessionID }) + .fork({ sessionID: args.sessionID, boundary: { type: "through" } }) .then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt })) .catch(toast.error) }) diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx index 4a8ac9ed8f..b99584d984 100644 --- a/packages/tui/src/routes/session/dialog-fork.tsx +++ b/packages/tui/src/routes/session/dialog-fork.tsx @@ -20,24 +20,28 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov const fork = async (messageID?: string) => { setPending(true) - const result = await client.api.session.fork({ sessionID: props.sessionID, messageID }).catch((error) => { - toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) - return undefined - }) + const result = await client.api.session + .fork({ + sessionID: props.sessionID, + boundary: messageID ? { type: "before", messageID } : { type: "through" }, + }) + .catch((error) => { + toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) + return undefined + }) if (!result) return dialog.clear() const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined const prompt = message?.type === "user" ? projectedPromptInput(message) : undefined route.navigate({ sessionID: result.id, type: "session", - prompt: - prompt - ? { - ...prompt, - agents: prompt.agents ?? [], - pasted: [], - } - : undefined, + prompt: prompt + ? { + ...prompt, + agents: prompt.agents ?? [], + pasted: [], + } + : undefined, }) dialog.clear() toast.show({ message: "Forked session", variant: "success", duration: 4000 }) @@ -75,11 +79,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov } > - props.onMove?.(option.value)} - title="Fork session" - options={options()} - /> + props.onMove?.(option.value)} title="Fork session" options={options()} /> ) } diff --git a/packages/www/openapi.json b/packages/www/openapi.json index b7cc24d709..011fa3d928 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -1145,7 +1145,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1181,7 +1188,7 @@ } } }, - "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "description": "Create a child session by copying projected history through or before a message boundary.", "summary": "Fork session", "requestBody": { "content": { @@ -1189,22 +1196,13 @@ "schema": { "type": "object", "properties": { - "messageID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - { - "type": "null" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkRequestBoundary" } }, + "required": [ + "boundary" + ], "additionalProperties": false } } @@ -12223,6 +12221,58 @@ ], "additionalProperties": false }, + "Session.ForkBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + } + ] + }, "Money.USD": { "type": "number" }, @@ -12385,17 +12435,13 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" } }, "required": [ - "sessionID" + "sessionID", + "boundary" ], "additionalProperties": false }, @@ -12595,6 +12641,49 @@ ], "additionalProperties": false }, + "Session.ForkRequestBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, "MessageNotFoundError": { "type": "object", "properties": { @@ -12783,14 +12872,6 @@ "SessionPending.User": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -12828,7 +12909,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -12957,14 +13037,6 @@ "SessionPending.Synthetic": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13002,7 +13074,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13015,14 +13086,6 @@ "SessionPending.Compaction": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13050,7 +13113,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13705,7 +13767,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13784,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13810,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13864,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -14829,27 +14898,27 @@ } ] }, - "parentSeq": { - "type": "integer", - "allOf": [ - { - "minimum": -1 - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" }, - "from": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" + "instructions": { + "type": "object", + "patternProperties": { + "^[a-z0-9][a-z0-9._-]*\\/[a-z0-9][a-z0-9._/-]*$": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-f0-9]{64}$" + } + ] } - ] + } } }, "required": [ "sessionID", "parentID", - "parentSeq" + "boundary" ], "additionalProperties": false } @@ -17114,6 +17183,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17309,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17432,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index b7cc24d709..011fa3d928 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -1145,7 +1145,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1181,7 +1188,7 @@ } } }, - "description": "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + "description": "Create a child session by copying projected history through or before a message boundary.", "summary": "Fork session", "requestBody": { "content": { @@ -1189,22 +1196,13 @@ "schema": { "type": "object", "properties": { - "messageID": { - "anyOf": [ - { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - { - "type": "null" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkRequestBoundary" } }, + "required": [ + "boundary" + ], "additionalProperties": false } } @@ -12223,6 +12221,58 @@ ], "additionalProperties": false }, + "Session.ForkBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + } + ] + }, "Money.USD": { "type": "number" }, @@ -12385,17 +12435,13 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" } }, "required": [ - "sessionID" + "sessionID", + "boundary" ], "additionalProperties": false }, @@ -12595,6 +12641,49 @@ ], "additionalProperties": false }, + "Session.ForkRequestBoundary": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "before" + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "type", + "messageID" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "through" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, "MessageNotFoundError": { "type": "object", "properties": { @@ -12783,14 +12872,6 @@ "SessionPending.User": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -12828,7 +12909,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -12957,14 +13037,6 @@ "SessionPending.Synthetic": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13002,7 +13074,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13015,14 +13086,6 @@ "SessionPending.Compaction": { "type": "object", "properties": { - "admittedSeq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "id": { "type": "string", "allOf": [ @@ -13050,7 +13113,6 @@ } }, "required": [ - "admittedSeq", "id", "sessionID", "timeCreated", @@ -13705,7 +13767,14 @@ "type": "string" }, "name": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -13715,7 +13784,7 @@ ], "additionalProperties": false }, - "LLM.ToolContent": { + "Tool.Content": { "anyOf": [ { "$ref": "#/components/schemas/Tool.TextContent" @@ -13741,12 +13810,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -13795,12 +13864,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content" } }, "metadata": { @@ -14829,27 +14898,27 @@ } ] }, - "parentSeq": { - "type": "integer", - "allOf": [ - { - "minimum": -1 - } - ] + "boundary": { + "$ref": "#/components/schemas/Session.ForkBoundary" }, - "from": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" + "instructions": { + "type": "object", + "patternProperties": { + "^[a-z0-9][a-z0-9._-]*\\/[a-z0-9][a-z0-9._/-]*$": { + "type": "string", + "allOf": [ + { + "pattern": "^[a-f0-9]{64}$" + } + ] } - ] + } } }, "required": [ "sessionID", "parentID", - "parentSeq" + "boundary" ], "additionalProperties": false } @@ -17114,6 +17183,49 @@ ], "additionalProperties": false }, + "Tool.FileContent1": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "uri", + "mime" + ], + "additionalProperties": false + }, + "Tool.Content1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent1" + } + ] + }, "Session.Message.ProviderState8": { "type": "object" }, @@ -17197,12 +17309,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": { @@ -17320,12 +17432,12 @@ "type": "array", "prefixItems": [ { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } ], "minItems": 1, "items": { - "$ref": "#/components/schemas/LLM.ToolContent" + "$ref": "#/components/schemas/Tool.Content1" } }, "metadata": {