From aa2c1472faa1988745b35c7db5c2d5ee979f3200 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 00:33:07 -0400 Subject: [PATCH] fix(core): copy the context checkpoint when forking a session Copied fork messages keep their parent seqs, but a fresh checkpoint's baseline_seq starts near zero, so nothing folded: forks received a new full baseline plus every stale context-update message from the parent, and copied compaction rows triggered a spurious rebaseline. Inheriting the parent row keeps the fold horizon consistent with the copied transcript. Also asserts the checkpoint reset on committed reverts and deletes the unused SessionStore.runnerContext / SessionHistory.loadForRunner surface. --- packages/core/src/session/history.ts | 8 ------ packages/core/src/session/projector.ts | 26 +++++++++++++++++- packages/core/src/session/store.ts | 7 ----- packages/core/test/session-projector.test.ts | 13 ++++++++- packages/core/test/session-runner.test.ts | 29 ++++++++++++++++++++ 5 files changed, 66 insertions(+), 17 deletions(-) diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 9d84859231..a704a631f7 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -82,14 +82,6 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow) }) -export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - baselineSeq: number, -) { - return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message) -}) - export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 37e931efb1..8f4cc4e5df 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -13,7 +13,14 @@ import { SessionMessageUpdater } from "./message-updater" import { SessionInput } from "./input" import { WorkspaceV2 } from "../workspace" import { SessionContextCheckpoint } from "./context-checkpoint" -import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql" +import { + MessageTable, + PartTable, + SessionContextCheckpointTable, + SessionInputTable, + SessionMessageTable, + SessionTable, +} from "./sql" import type { DeepMutable } from "../schema" import { Slug } from "../util/slug" @@ -210,6 +217,23 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) if (!stored) return yield* Effect.die(new SessionAlreadyProjected()) + // The fork inherits the parent's transcript, so it inherits the context + // checkpoint that transcript was built against: copied message seqs keep + // folding at the same baseline horizon. + const checkpoint = yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, event.data.parentID)) + .get() + .pipe(Effect.orDie) + if (checkpoint) { + yield* db + .insert(SessionContextCheckpointTable) + .values({ ...checkpoint, session_id: event.data.sessionID }) + .run() + .pipe(Effect.orDie) + } + const usage = emptyUsage() let cursor = -1 while (true) { diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 273444d25c..c3ffeedc3c 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -14,10 +14,6 @@ import { fromRow } from "./info" export interface Interface { readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly context: (sessionID: SessionSchema.ID) => Effect.Effect - readonly runnerContext: ( - sessionID: SessionSchema.ID, - baselineSeq: number, - ) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, ) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined> @@ -39,9 +35,6 @@ const layer = Layer.effect( context: Effect.fn("SessionStore.context")(function* (sessionID) { return yield* SessionHistory.load(db, sessionID) }), - runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) { - return yield* SessionHistory.loadForRunner(db, sessionID, baselineSeq) - }), message: Effect.fn("SessionStore.message")(function* (messageID) { const row = yield* db .select() diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 64e037987b..4191a58715 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -19,7 +19,12 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" -import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { + SessionContextCheckpointTable, + SessionInputTable, + SessionMessageTable, + SessionTable, +} from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" import { Snapshot } from "@opencode-ai/core/snapshot" @@ -67,6 +72,10 @@ describe("SessionProjector", () => { .insert(SessionMessageTable) .values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)]) .run() + yield* db + .insert(SessionContextCheckpointTable) + .values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 }) + .run() const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, @@ -93,6 +102,8 @@ describe("SessionProjector", () => { expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), ).toEqual([boundary]) + // A committed revert resets the context checkpoint so the next turn re-initializes. + expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined() }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 5830d4c699..5f5e64ec82 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -751,6 +751,35 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("copies the context checkpoint to a fork", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + + const forked = yield* session.fork({ sessionID }) + + const parent = yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + expect(parent).toBeDefined() + expect( + yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, forked.id)) + .get() + .pipe(Effect.orDie), + ).toEqual({ ...parent!, session_id: forked.id }) + }), + ) + it.effect("heals an undecodable stored applied record by re-announcing context", () => Effect.gen(function* () { yield* setup