diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts index 27064fe60bb..0e040017393 100644 --- a/packages/core/src/session/instruction-state.ts +++ b/packages/core/src/session/instruction-state.ts @@ -1,6 +1,6 @@ export * as InstructionState from "./instruction-state.js" -import { eq, inArray, sql } from "drizzle-orm" +import { and, eq, gte, inArray, sql } from "drizzle-orm" import { Effect, Option, Schema } from "effect" import type { Database } from "../database/database.js" import type { Bus } from "../bus.js" @@ -153,10 +153,18 @@ export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function* .pipe(Effect.orDie) }) -export const reset = Effect.fn("InstructionState.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { +/** + * Clears the fold only when a committed revert removed an update or the epoch start. Otherwise the + * fold still matches the surviving history, and keeping its baseline keeps the prompt cache. + */ +export const rewind = Effect.fn("InstructionState.rewind")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + boundary: number, +) { yield* db .delete(InstructionStateTable) - .where(eq(InstructionStateTable.session_id, sessionID)) + .where(and(eq(InstructionStateTable.session_id, sessionID), gte(InstructionStateTable.through_seq, boundary))) .run() .pipe(Effect.orDie) }) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 65b3053c866..6a4f25c2dcd 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -806,7 +806,7 @@ const layer = Layer.effectDiscard( .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - yield* InstructionState.reset(db, event.data.sessionID) + yield* InstructionState.rewind(db, event.data.sessionID, boundary.seq) }), ) yield* bus.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed, SessionEvent.UsageRecorded]).pipe( diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index a0e4e5be816..df5cc4c9dcf 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -202,8 +202,8 @@ describe("SessionProjector", () => { tokens_cache_read: 3, tokens_cache_write: 1, }) - // A committed revert resets the fold cache so the next boundary establishes a new epoch. - expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined() + // No instruction update followed the boundary, so the fold survives. + expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({ through_seq: 0 }) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 4c8f566ea01..d1ee908049d 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1739,6 +1739,34 @@ describe("SessionRunnerLLM", () => { expect(yield* s.messages).toHaveLength(3) }) + scenario("keeps the system prompt stable across a committed revert", function* (s) { + yield* s.runPrompt("First") + s.systemBaseline = "Changed context" + yield* s.runPrompt("Second") + const third = yield* s.runPrompt("Third") + const before = s.requests.at(-1)?.system.map((part) => part.text) + expect(before).toEqual([defaultSystem, fakeIdentity, "Initial context"]) + + yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: third.id }) + yield* s.runPrompt("Fourth") + + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(before) + }) + + scenario("resyncs the instruction state when a committed revert removes an update", function* (s) { + yield* s.runPrompt("First") + s.systemBaseline = "Changed context" + const second = yield* s.runPrompt("Second") + s.systemBaseline = "Latest context" + yield* s.runPrompt("Third") + + // The "Latest context" update is delivered with "Third", after the boundary. + yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: second.id }) + yield* s.runPrompt("Fourth") + + expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, fakeIdentity, "Latest context"]) + }) + scenario("uses the selected model family prompt when the agent does not override it", function* (s) { s.currentModel = LanguageModel.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) yield* s.admit("First") diff --git a/specs/v2/session.md b/specs/v2/session.md index 2774863477e..8a320616f4b 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -93,7 +93,7 @@ Instruction sync persists content-addressed values and may freeze rendered chron Before each Physical Attempt that reaches model execution, the runner reads every source concurrently exactly once, hashes encoded values, and admits one delta atomically with its new blobs before input delivery. The initial delta must be complete; it carries no update text. An unavailable source blocks only that initial delta and otherwise silently retains the stored value. Request assembly renders the epoch baseline from stored values. Later changes render once at admission, freeze optional `text` in the durable event, and project that text as a chronological System message; clients display changed keys rather than privileged prose. -An instruction epoch spans completed compactions. `session.compaction.ended` moves the epoch start to its exact sequence, making current values initial, without reading sources or authoring an instruction event. Session movement retains state so destination changes become chronological updates; committed revert clears state so the next boundary establishes a fresh baseline. A fork copies messages only through its selected boundary but adopts the parent's newest instruction values as its baseline. Model selection affects request assembly but is not itself an instruction source. +An instruction epoch spans completed compactions. `session.compaction.ended` moves the epoch start to its exact sequence, making current values initial, without reading sources or authoring an instruction event. Session movement retains state so destination changes become chronological updates; committed revert retains state unless it removed an instruction update, in which case it clears state so the next boundary establishes a fresh baseline. A fork copies messages only through its selected boundary but adopts the parent's newest instruction values as its baseline. Model selection affects request assembly but is not itself an instruction source. ## Compaction Rebuilds Active History