diff --git a/CONTEXT.md b/CONTEXT.md index d5ccd6de6d..2a0bc8df92 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -60,6 +60,7 @@ The point immediately before a provider call, after durable input promotion and - Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**. - Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**. - Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location. +- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote. - Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values. - The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn Boundary**. - Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam. @@ -74,6 +75,7 @@ The point immediately before a provider call, after durable input promotion and - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. +- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. ## Example dialogue diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index be258e2a1c..db15ebd4a3 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -1,8 +1,9 @@ export * as InstructionContext from "./instruction-context" import { Array, Effect, Layer, Schema } from "effect" -import { join } from "path" +import { isAbsolute, join, relative, sep } from "path" import { FSUtil } from "./fs-util" +import { Flag } from "./flag/flag" import { Global } from "./global" import { Location } from "./location" import { AbsolutePath } from "./schema" @@ -30,15 +31,25 @@ export const layer = Layer.effectDiscard( codec: Schema.toCodecJson(Files), load: Effect.succeed(value), baseline: render, - update: (_previous, current) => render(current), + update: (_previous, current) => `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`, removed: () => "Previously loaded instructions no longer apply.", }) const observe = Effect.fn("InstructionContext.observe")(function* () { + const start = FSUtil.resolve(location.directory) + const stop = FSUtil.resolve(location.project.directory) + const fromProject = relative(stop, start) + const insideProject = + fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) const discovered = new Set( - (yield* fs.up({ targets: ["AGENTS.md"], start: location.directory, stop: location.project.directory })).map( - FSUtil.resolve, - ), + (Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject + ? [] + : yield* fs.up({ + targets: ["AGENTS.md"], + start, + stop, + }) + ).map(FSUtil.resolve), ) const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered]) const files = yield* Effect.forEach( diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 06465771ef..6781bd533d 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -135,6 +135,10 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem .pipe(Effect.orDie) }) +export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + yield* db.delete(SessionContextEpochTable).where(eq(SessionContextEpochTable.session_id, sessionID)).run().pipe(Effect.orDie) +}) + const insert = Effect.fnUntraced(function* ( db: DatabaseService, sessionID: SessionSchema.ID, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 4d7e07c90b..42053ce733 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -260,17 +260,20 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie), ) yield* events.project(SessionEvent.Moved, (event) => - db - .update(SessionTable) - .set({ - directory: event.data.location.directory, - path: event.data.subdirectory, - workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, - time_updated: DateTime.toEpochMillis(event.data.timestamp), - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie), + Effect.gen(function* () { + yield* db + .update(SessionTable) + .set({ + directory: event.data.location.directory, + path: event.data.subdirectory, + workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, + time_updated: DateTime.toEpochMillis(event.data.timestamp), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + yield* SessionContextEpoch.reset(db, event.data.sessionID) + }), ) yield* events.project(SessionV1.Event.Deleted, (event) => db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index 9b1657caac..88f97ed2bd 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -73,7 +73,19 @@ describe("InstructionContext", () => { text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`), }) - yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(packageFile), fs.rm(projectFile)])) + yield* Effect.promise(() => fs.rm(packageFile)) + const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot) + expect(partial).toEqual({ + _tag: "Updated", + text: [ + "These instructions replace all previously loaded ambient instructions.", + `Instructions from: ${globalFile}\nglobal`, + `Instructions from: ${projectFile}\nproject`, + ].join("\n\n"), + snapshot: expect.any(Object), + }) + + yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)])) expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({ _tag: "Updated", text: "Previously loaded instructions no longer apply.", @@ -178,4 +190,110 @@ describe("InstructionContext", () => { ).toEqual({ _tag: "Unchanged" }) }), ) + + it.effect("canonicalizes upward discovery boundaries", () => + Effect.gen(function* () { + let observed: { targets: string[]; start: string; stop?: string } | undefined + const observingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + up: (options) => + Effect.sync(() => { + observed = options + return [] + }), + }), + ), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)) + + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide(observingFS), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make("/repo/") }, + { projectDirectory: AbsolutePath.make("/repo") }, + ), + ), + ), + ), + ) + + expect(observed).toEqual({ targets: ["AGENTS.md"], start: FSUtil.resolve("/repo"), stop: FSUtil.resolve("/repo") }) + }), + ) + + it.effect("honors the project instruction opt-out", () => + Effect.gen(function* () { + const previous = process.env.OPENCODE_DISABLE_PROJECT_CONFIG + let scanned = false + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" + + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide( + Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)), + ), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + ), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG + else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previous + }), + ), + ) + + expect(scanned).toBe(false) + }), + ) + + it.effect("does not discover project instructions outside the canonical project root", () => + Effect.gen(function* () { + let scanned = false + yield* SystemContextRegistry.Service.pipe( + Effect.flatMap((service) => service.load()), + Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), + Effect.provide( + Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(FSUtil.defaultLayer)), + ), + Effect.provide(Global.layerWith({ config: "/global" })), + Effect.provide( + Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make("/outside") }, + { projectDirectory: AbsolutePath.make("/repo") }, + ), + ), + ), + ), + ) + + expect(scanned).toBe(false) + }), + ) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1457daecca..fe60aece82 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -596,6 +596,41 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("requires a complete new baseline after a Session moves", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + requests.length = 0 + response = [] + yield* session.resume(sessionID) + + yield* events.publish(SessionEvent.Moved, { + sessionID, + timestamp: DateTime.makeUnsafe(1), + location: { directory: AbsolutePath.make("/moved") }, + }) + expect( + yield* db + .select() + .from(SessionContextEpochTable) + .where(eq(SessionContextEpochTable.session_id, sessionID)) + .get(), + ).toBeUndefined() + + systemUnavailable = true + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + const exit = yield* session.resume(sessionID).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked) + expect(requests).toHaveLength(1) + expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true) + }), + ) + it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/system-context-builtins.test.ts b/packages/core/test/system-context-builtins.test.ts index ed854abcd8..6ec9ca41ee 100644 --- a/packages/core/test/system-context-builtins.test.ts +++ b/packages/core/test/system-context-builtins.test.ts @@ -11,14 +11,18 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry import { location } from "./fixture/location" import { testEffect } from "./lib/effect" -const directory = AbsolutePath.make("/repo/packages/core") -const projectDirectory = AbsolutePath.make("/repo") +const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core")) +const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo")) +const instructionFile = FSUtil.resolve("/repo/AGENTS.md") const timestamp = Date.parse("2026-06-03T12:00:00.000Z") const localDate = (time: number) => new Date(time).toDateString() const locationLayer = Layer.succeed( Location.Service, Location.Service.of( - location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }), + location( + { directory }, + { projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } }, + ), ), ) const it = testEffect( @@ -34,8 +38,8 @@ const instructionFS = Layer.effect( Effect.map((fs) => FSUtil.Service.of({ ...fs, - up: () => Effect.succeed(["/repo/AGENTS.md"]), - readFileStringSafe: (path) => Effect.succeed(path === "/repo/AGENTS.md" ? "Be precise." : undefined), + up: () => Effect.succeed([instructionFile]), + readFileStringSafe: (path) => Effect.succeed(path === instructionFile ? "Be precise." : undefined), }), ), ), @@ -115,7 +119,7 @@ describe("SystemContextBuiltIns", () => { "", `Today's date: ${localDate(timestamp)}`, "", - "Instructions from: /repo/AGENTS.md\nBe precise.", + `Instructions from: ${instructionFile}\nBe precise.`, ].join("\n"), ) }), diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 39b00e5eb8..4f19607d49 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -761,6 +761,8 @@ Change: - Directly discover and read global plus upward project `AGENTS.md` files at each safe provider-turn boundary. - Preserve admitted instructions across transient scan/read failures and block first-epoch initialization while any context source is unavailable. - Retry Context Epoch preparation until stable after optimistic revision mismatches. +- Clear the active Context Epoch when a Session moves so the destination initializes a complete baseline before promoting more input. +- Canonicalize ambient instruction traversal boundaries, honor `OPENCODE_DISABLE_PROJECT_CONFIG`, and make non-empty aggregate updates explicitly supersede previously loaded instructions. Compatibility: