diff --git a/.changeset/calm-moves-cache.md b/.changeset/calm-moves-cache.md new file mode 100644 index 0000000000..eb408e642a --- /dev/null +++ b/.changeset/calm-moves-cache.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Preserve prompt cache prefixes when sessions move between locations with unchanged instructions. diff --git a/AGENTS.md b/AGENTS.md index be191e23dd..8b7fb11564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,4 +171,4 @@ const table = sqliteTable("session", { - One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry. -- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta. +- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta. diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 1393536df5..05b7f4561b 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -436,7 +436,6 @@ const layer = Layer.effectDiscard( .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - yield* InstructionState.reset(db, event.data.sessionID) }), ) // Sessions whose ownership came from the directory's previous resolution diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index a5cdbe1efc..f7531621e5 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1204,12 +1204,17 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("interrupts a source Location runner after a Session moves", () => + it.effect("preserves instruction state and interrupts the source Location runner after a Session moves", () => Effect.gen(function* () { const session = yield* setup const bus = yield* Bus.Service const { db } = yield* Database.Service yield* runPrompt(session, "First") + const instructionState = yield* db + .select() + .from(InstructionStateTable) + .where(eq(InstructionStateTable.session_id, sessionID)) + .get() yield* bus.publish(SessionEvent.Moved, { sessionID, @@ -1218,7 +1223,7 @@ describe("SessionRunnerLLM", () => { }) expect( yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(), - ).toBeUndefined() + ).toEqual(instructionState) yield* admit(session, "Second") const exit = yield* session.resume(sessionID).pipe(Effect.exit)