Przeglądaj źródła

feat(session): define explicit fork boundaries

Dax Raad 2 tygodni temu
rodzic
commit
fc11ed3838

+ 1 - 1
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 })

+ 4 - 1
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) }

+ 6 - 4
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,
   }
 }

+ 5 - 3
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<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
 
-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<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
 
@@ -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
           }
         }
       | {

+ 1 - 1
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<Endpoint5_5Output>()(
-    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),
     ),

+ 1 - 1
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,

+ 7 - 3
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<PermissionRule>
 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"]

+ 3 - 13
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"
     },

+ 1 - 0
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[]

+ 13 - 0
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

+ 1 - 2
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,

+ 29 - 18
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<OperationUnavailableError>()(
@@ -181,7 +182,9 @@ export interface Interface {
     readonly data: SessionSchema.Info[]
   }>
   readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
-  readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
+  readonly fork: (
+    input: ForkInput,
+  ) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
   readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
   readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
   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)
       }),

+ 8 - 0
packages/core/src/session/error.ts

@@ -10,6 +10,14 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
   sessionID: SessionSchema.ID,
 }) {}
 
+export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("Session.ForkEmptyError", {
+  sessionID: SessionSchema.ID,
+}) {
+  override get message() {
+    return `Cannot fork empty session: ${this.sessionID}`
+  }
+}
+
 export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
   sessionID: SessionSchema.ID,
   messageID: SessionMessage.ID,

+ 7 - 7
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
       ? {

+ 60 - 43
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<ReadonlyArray<InstructionEventRow>> {
-  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<string>,
   after?: number,
-): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
-  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<ReadonlyArray<{ readonly sessionID: SessionSchema.ID; readonly through?: number }>> {
-  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<ReadonlyArray<InstructionEventRow>> {
+  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<InstructionEventRow>) {
@@ -402,6 +413,12 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
       }
     | 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

+ 21 - 18
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) {

+ 1 - 2
packages/core/src/session/sql.ts

@@ -32,8 +32,7 @@ export const SessionTable = sqliteTable(
     workspace_id: text().$type<Workspace.ID>(),
     parent_id: text().$type<SessionSchema.ID>(),
     fork_session_id: text().$type<SessionSchema.ID>(),
-    fork_message_id: text().$type<SessionMessage.ID>(),
-    fork_seq: integer(),
+    fork_boundary: text({ mode: "json" }).$type<Session.ForkBoundary>(),
     slug: text().notNull(),
     directory: directoryColumn().notNull(),
     path: pathColumn(),

+ 28 - 6
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 } })

+ 11 - 9
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
     }),

+ 187 - 75
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": {

+ 3 - 4
packages/protocol/src/groups/session.ts

@@ -207,17 +207,16 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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.",
           }),
         ),
     )

+ 3 - 2
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

+ 16 - 0
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

+ 6 - 3
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<typeof Info> {}
 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),

+ 5 - 1
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" }),
+              ),
             ),
           }
         }),

+ 2 - 2
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)
   })

+ 17 - 17
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
         </box>
       }
     >
-      <DialogSelect
-        onMove={(option) => props.onMove?.(option.value)}
-        title="Fork session"
-        options={options()}
-      />
+      <DialogSelect onMove={(option) => props.onMove?.(option.value)} title="Fork session" options={options()} />
     </Show>
   )
 }

+ 187 - 75
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": {

+ 187 - 75
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": {