diff --git a/packages/core/src/config/plugin/instruction.ts b/packages/core/src/config/plugin/instruction.ts new file mode 100644 index 000000000000..2b8ee8f19f7c --- /dev/null +++ b/packages/core/src/config/plugin/instruction.ts @@ -0,0 +1,122 @@ +export * as ConfigInstructionPlugin from "./instruction" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" +import { dirname, join } from "path" +import { Effect, PubSub, Semaphore, Stream } from "effect" +import { Watcher } from "../../filesystem/watcher" +import { InstructionDiscovery } from "../../instruction-discovery" +import { Instructions } from "../../instructions/index" +import { Location } from "../../location" +import { AbsolutePath } from "../../schema" + +type Loaded = + | { readonly type: "available"; readonly files: InstructionDiscovery.File[] } + | { readonly type: "unavailable" } + +export const Plugin = define({ + id: "opencode.config.instruction", + effect: Effect.fn(function* () { + const discovery = yield* InstructionDiscovery.Service + yield* Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service + const watcher = yield* Watcher.Service + const changes = yield* PubSub.sliding(1) + const lock = Semaphore.makeUnsafe(1) + const start = yield* fs.resolve(location.directory) + const stop = yield* fs.resolve(location.project.directory) + const project = discovery.project && FSUtil.contains(stop, start) + const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md")) + const loaded: { current: Loaded } = { current: { type: "available", files: [] } } + + const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid) + const candidates = [ + globalFile, + ...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []), + ] + for (const path of new Set(candidates)) { + const updates = yield* watcher.subscribe({ path, type: "file" }) + yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true })) + } + + const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) { + const content = yield* fs.readFileStringSafe(path) + if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content }) + yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" }) + }) + + const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () { + const file = yield* read(globalFile) + return file ? [file] : [] + }) + + const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () { + if (!project) return [] + const discovered = new Set( + yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve), + ) + const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" }) + if (files.some((file) => file === undefined)) return Instructions.unavailable + return files.filter((file): file is InstructionDiscovery.File => file !== undefined) + }) + + const isolate = (source: string, effect: Effect.Effect) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to load instruction source", { source, cause }).pipe( + Effect.as(Instructions.unavailable), + ), + ), + ) + + const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) { + yield* lock.withPermit( + Effect.gen(function* () { + const sources = yield* Effect.all({ + global: isolate("global", globalSource()), + project: isolate("project", projectSource()), + }) + loaded.current = + Array.isArray(sources.global) && Array.isArray(sources.project) + ? { type: "available", files: [...sources.global, ...sources.project] } + : { type: "unavailable" } + if (!file) return + yield* Effect.logDebug("instructions rescanned", { + file, + instructions: + loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable", + }) + }), + ) + }) + + yield* Stream.fromPubSub(changes).pipe( + Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh() + yield* discovery.transform((draft) => { + if (loaded.current.type === "unavailable") { + draft.unavailable() + return + } + for (const file of loaded.current.files) draft.add(file) + }) + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to activate instruction source", { cause }).pipe( + Effect.andThen(discovery.transform((draft) => draft.unavailable())), + Effect.asVoid, + ), + ), + ) + }), +}) + +function ancestorDirectories(start: string, stop: string): string[] { + if (start === stop) return [start] + return [start, ...ancestorDirectories(dirname(start), stop)] +} diff --git a/packages/core/src/instruction-discovery.ts b/packages/core/src/instruction-discovery.ts index 096d34fef4c3..b84de1e55470 100644 --- a/packages/core/src/instruction-discovery.ts +++ b/packages/core/src/instruction-discovery.ts @@ -1,15 +1,13 @@ export * as InstructionDiscovery from "./instruction-discovery" -import { Array, Context, Effect, Layer, Schema } from "effect" -import { isAbsolute, join, relative, sep } from "path" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Global } from "@opencode-ai/util/global" -import { Location } from "./location" -import { AbsolutePath } from "./schema" -import { Instructions } from "./instructions/index" +import { Context, Effect, Layer, Schema, Types } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Bus } from "./bus" +import { Instructions } from "./instructions/index" +import { AbsolutePath } from "./schema" +import { State } from "./state" -class File extends Schema.Class("InstructionDiscovery.File")({ +export class File extends Schema.Class("InstructionDiscovery.File")({ path: AbsolutePath, content: Schema.String, }) {} @@ -17,7 +15,30 @@ class File extends Schema.Class("InstructionDiscovery.File")({ const Files = Schema.Array(File) const key = Instructions.Key.make("core/instructions") -export interface Interface { +export const Event = { + Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }), +} + +export type Data = { + files: Map> + available: boolean +} + +export type Draft = { + list: () => readonly Types.DeepMutable[] + // Map insertion order is render order: config adds global then nearest-to-farthest project files; + // sibling contributors interleave by transform registration order. + add: (file: File) => void + update: (path: string, update: (file: Types.DeepMutable) => void) => void + remove: (path: string) => void + unavailable: () => void +} + +export interface Interface extends State.Transformable { + // Discovery policy lives here because internal plugins have no per-composition options channel. + // Move it into plugin config once plugins can consume their own options. + readonly project: boolean + readonly list: () => Effect.Effect readonly load: () => Effect.Effect } @@ -32,9 +53,26 @@ export const layer = (options?: Options) => Layer.effect( Service, Effect.gen(function* () { - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const location = yield* Location.Service + const bus = yield* Bus.Service + const state = State.create({ + name: "instruction-discovery", + initial: () => ({ files: new Map(), available: true }), + draft: (draft) => ({ + list: () => Array.from(draft.files.values()), + add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable), + update: (path, update) => { + const current = draft.files.get(AbsolutePath.make(path)) + if (!current) return + update(current) + current.path = AbsolutePath.make(path) + }, + remove: (path) => draft.files.delete(AbsolutePath.make(path)), + unavailable: () => { + draft.available = false + }, + }), + finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid), + }) const source = (value: ReadonlyArray | Instructions.Unavailable | Instructions.Removed) => Instructions.make>({ @@ -49,52 +87,22 @@ export const layer = (options?: Options) => }, }) - const observe = Effect.fn("InstructionDiscovery.observe")(function* () { - const start = yield* fs.resolve(location.directory) - const stop = yield* fs.resolve(location.project.directory) - const fromProject = relative(stop, start) - const insideProject = - fromProject === "" || - (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) - const discovered = new Set( - yield* Effect.forEach( - options?.project === false || !insideProject - ? [] - : yield* fs.up({ - targets: ["AGENTS.md"], - start, - stop, - }), - fs.resolve, - ), - ) - const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered]) - const files = yield* Effect.forEach( - paths, - (path) => - fs - .readFileStringSafe(path) - .pipe( - Effect.map((content) => - content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }), - ), - ), - { concurrency: "unbounded" }, - ) - if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) - return Instructions.unavailable - return files.filter((file): file is File => file !== undefined) + const list = Effect.fn("InstructionDiscovery.list")(function* () { + const current = state.get() + if (!current.available) return Instructions.unavailable + return Array.from(current.files.values()) }) return Service.of({ - load: () => - observe().pipe( - Effect.map((files) => - Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files), - ), - Effect.catch(() => Effect.succeed(source(Instructions.unavailable))), - Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))), - ), + project: options?.project !== false, + transform: state.transform, + reload: state.reload, + list, + load: Effect.fn("InstructionDiscovery.load")(function* () { + const files = yield* list() + if (!Array.isArray(files)) return source(files) + return source(files.length === 0 ? Instructions.removed : files) + }), }) }), ) @@ -103,7 +111,7 @@ export function configured(options?: Options) { return makeLocationNode({ service: Service, layer: layer(options), - deps: [FSUtil.node, Global.node, Location.node], + deps: [Bus.node], }) } diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index f674c0ca9e21..11547cdfceb3 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -12,6 +12,7 @@ import { Config } from "../config" import { Credential } from "../credential" import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" +import { ConfigInstructionPlugin } from "../config/plugin/instruction" import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigPolicyPlugin } from "../config/plugin/policy" import { ConfigReferencePlugin } from "../config/plugin/reference" @@ -26,6 +27,7 @@ import { FileSystem } from "../filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" import { Image } from "../image" +import { InstructionDiscovery } from "../instruction-discovery" import { Integration } from "../integration" import { KV } from "../kv" import { Location } from "../location" @@ -83,6 +85,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const global = yield* Global.Service const http = yield* HttpClient.HttpClient const image = yield* Image.Service + const instructionDiscovery = yield* InstructionDiscovery.Service const integration = yield* Integration.Service const kv = yield* KV.Service const location = yield* Location.Service @@ -118,6 +121,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Global.Service, global), Context.make(HttpClient.HttpClient, http), Context.make(Image.Service, image), + Context.make(InstructionDiscovery.Service, instructionDiscovery), Context.make(Integration.Service, integration), Context.make(KV.Service, kv), Context.make(Location.Service, location), @@ -160,6 +164,7 @@ export const requirements = LayerNode.group([ Global.node, httpClient, Image.node, + InstructionDiscovery.node, Integration.node, KV.node, Location.node, @@ -209,6 +214,7 @@ const pre = [ ] as const satisfies readonly InternalPlugin[] const post = [ + ConfigInstructionPlugin.Plugin, ConfigReferencePlugin.Plugin, ConfigAgentPlugin.Plugin, ConfigCommandPlugin.Plugin, diff --git a/packages/core/test/fixture/global.ts b/packages/core/test/fixture/global.ts new file mode 100644 index 000000000000..b6fbfe4b37c9 --- /dev/null +++ b/packages/core/test/fixture/global.ts @@ -0,0 +1,27 @@ +import path from "path" +import { Global } from "@opencode-ai/util/global" +import { Effect, Layer } from "effect" +import { tmpdir } from "./tmpdir" + +export const tempGlobalLayer = Layer.unwrap( + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.map((tmp) => { + const data = path.join(tmp.path, "data") + const cache = path.join(tmp.path, "cache") + return Global.layerWith({ + home: path.join(tmp.path, "home"), + data, + cache, + config: path.join(tmp.path, "config"), + state: path.join(tmp.path, "state"), + tmp: path.join(tmp.path, "tmp"), + bin: path.join(cache, "bin"), + log: path.join(data, "log"), + repos: path.join(data, "repos"), + }) + }), + ), +) diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index b4e8b08491f2..935ca0ebbaa7 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -1,51 +1,124 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import fs from "fs/promises" -import os from "os" import path from "path" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Global } from "@opencode-ai/util/global" +import { Bus } from "@opencode-ai/core/bus" +import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" +import { Instructions } from "@opencode-ai/core/instructions" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { tempGlobalLayer } from "./fixture/global" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" -import { testEffect } from "./lib/effect" import { readInitial, readUpdate, state } from "./lib/instructions" +import { testEffect } from "./lib/effect" +import { host } from "./plugin/host" const it = testEffect(Layer.empty) -const testConfig = path.join(os.tmpdir(), "opencode-instruction-discovery-test") const instructionLayer = (input: { - config: string + config?: string locationServiceLayer: Layer.Layer filesystemLayer?: Layer.Layer project?: boolean -}) => - AppNodeBuilder.build(InstructionDiscovery.node, [ - [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })], - [Global.node, Global.layerWith({ config: input.config })], - [Location.node, input.locationServiceLayer], - ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), - ]) +}) => { + const watcher = Watcher.testLayer + return Layer.mergeAll( + AppNodeBuilder.build( + LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]), + [ + [InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })], + [Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer], + [Location.node, input.locationServiceLayer], + [Watcher.node, watcher], + ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), + ], + ), + watcher, + ) +} + +const start = Effect.fnUntraced(function* () { + yield* ConfigInstructionPlugin.Plugin.effect(host()) + return yield* InstructionDiscovery.Service +}) + +const file = (path: string, content: string) => + new InstructionDiscovery.File({ path: AbsolutePath.make(path), content }) + +function emitAndWait(update: Watcher.Update) { + return Effect.gen(function* () { + const watcher = yield* Watcher.Test + const bus = yield* Bus.Service + const updated = yield* Deferred.make() + const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe( + Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)), + Effect.forkScoped, + ) + yield* Effect.yieldNow + yield* watcher.emit(update) + yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds")) + yield* Fiber.interrupt(fiber) + }) +} describe("InstructionDiscovery", () => { - it.live("loads global and upward project AGENTS.md files as one aggregate context", () => + it.effect("stores ordered values with last-write-wins precedence", () => + Effect.gen(function* () { + const discovery = yield* InstructionDiscovery.Service + yield* discovery.transform((draft) => { + draft.add(file("/repo/AGENTS.md", "first")) + draft.add(file("/repo/packages/AGENTS.md", "package")) + draft.add(file("/repo/AGENTS.md", "last")) + draft.update("/repo/packages/AGENTS.md", (current) => { + current.content = "updated" + current.path = AbsolutePath.make("/ignored") + }) + draft.remove("/missing") + }) + + expect(yield* discovery.list()).toEqual([ + file("/repo/AGENTS.md", "last"), + file("/repo/packages/AGENTS.md", "updated"), + ]) + }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))), + ) + + it.effect("preserves admitted values while the source is unavailable", () => + Effect.gen(function* () { + const discovery = yield* InstructionDiscovery.Service + yield* discovery.transform((draft) => draft.unavailable()) + expect( + (yield* readUpdate( + yield* discovery.load(), + state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }), + )).changed, + ).toBe(false) + }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))), + ) +}) + +describe("ConfigInstructionPlugin.Plugin", () => { + it.live("loads global and upward project files and rescans them on change", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ).pipe( - Effect.flatMap((tmp) => - Effect.gen(function* () { - const global = path.join(tmp.path, "global") - const project = path.join(tmp.path, "project") - const directory = path.join(project, "packages", "core") - const outside = path.join(tmp.path, "AGENTS.md") - const globalFile = path.join(global, "AGENTS.md") - const projectFile = path.join(project, "AGENTS.md") - const packageFile = path.join(directory, "AGENTS.md") + Effect.flatMap((tmp) => { + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const directory = path.join(project, "packages", "core") + const outside = path.join(tmp.path, "AGENTS.md") + const globalFile = path.join(global, "AGENTS.md") + const projectFile = path.join(project, "AGENTS.md") + const packageFile = path.join(directory, "AGENTS.md") + return Effect.gen(function* () { yield* Effect.promise(async () => { await fs.mkdir(global, { recursive: true }) await fs.mkdir(directory, { recursive: true }) @@ -55,25 +128,15 @@ describe("InstructionDiscovery", () => { await fs.writeFile(packageFile, "package") }) - const load = InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), - Effect.provide( - instructionLayer({ - config: global, - locationServiceLayer: Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(project) }, - ), - ), - ), - }), - ), - ) - - const initialized = yield* readInitial(yield* load) + const discovery = yield* start() + const watcher = yield* Watcher.Test + expect(yield* watcher.subscriptions()).toEqual([ + { path: globalFile, type: "file" }, + { path: packageFile, type: "file" }, + { path: path.join(project, "packages", "AGENTS.md"), type: "file" }, + { path: projectFile, type: "file" }, + ]) + const initialized = yield* readInitial(yield* discovery.load()) expect(initialized.text).toBe( [ `Instructions from: ${globalFile}\nglobal`, @@ -84,13 +147,14 @@ describe("InstructionDiscovery", () => { expect(initialized.text).not.toContain("outside") yield* Effect.promise(() => fs.writeFile(packageFile, "changed")) - expect((yield* readUpdate(yield* load, initialized)).text).toContain( + yield* emitAndWait({ type: "update", path: packageFile }) + expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain( `Instructions from: ${packageFile}\nchanged`, ) yield* Effect.promise(() => fs.rm(packageFile)) - const partial = yield* readUpdate(yield* load, initialized) - expect(partial.text).toBe( + yield* emitAndWait({ type: "delete", path: packageFile }) + expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe( [ "These instructions replace all previously loaded ambient instructions.", `Instructions from: ${globalFile}\nglobal`, @@ -98,12 +162,30 @@ describe("InstructionDiscovery", () => { ].join("\n\n"), ) - yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)])) - expect((yield* readUpdate(yield* load, initialized)).text).toBe( + yield* Effect.promise(() => fs.rm(globalFile)) + yield* emitAndWait({ type: "delete", path: globalFile }) + yield* Effect.promise(() => fs.rm(projectFile)) + yield* emitAndWait({ type: "delete", path: projectFile }) + expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe( "Previously loaded instructions no longer apply.", ) - }), - ), + }).pipe( + Effect.provide( + instructionLayer({ + config: global, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(project) }, + ), + ), + ), + }), + ), + ) + }), ), ) @@ -116,115 +198,150 @@ describe("InstructionDiscovery", () => { Effect.gen(function* () { const file = path.join(tmp.path, "AGENTS.md") yield* Effect.promise(() => fs.writeFile(file, "")) - const context = yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), - Effect.provide( - instructionLayer({ - config: path.join(tmp.path, "global"), - locationServiceLayer: Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), - ), - }), - ), - ) - - expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`) - }), + const discovery = yield* start() + expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`) + }).pipe( + Effect.provide( + instructionLayer({ + config: path.join(tmp.path, "global"), + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), + ), + }), + ), + ), ), ), ) - it.effect("preserves admitted instructions while observation is unavailable", () => - Effect.gen(function* () { - const failingFS = Layer.effect( - FSUtil.Service, - FSUtil.Service.pipe( - Effect.map((fs) => - FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }), - ), - ), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - const context = yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), - Effect.provide( - instructionLayer({ - config: testConfig, - filesystemLayer: failingFS, - locationServiceLayer: Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), - ), - }), - ), - ) + it.live("discovers a newly created instruction file in an intermediate directory", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => { + const project = path.join(tmp.path, "project") + const intermediate = path.join(project, "packages", "AGENTS.md") + const directory = path.join(project, "packages", "core") + const projectFile = path.join(project, "AGENTS.md") + return Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(directory, { recursive: true })) + yield* Effect.promise(() => fs.writeFile(projectFile, "project")) + const discovery = yield* start() + expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`) - expect( - (yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }))) - .changed, - ).toBe(false) - }), - ) + yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate")) + yield* emitAndWait({ type: "create", path: intermediate }) - it.effect("preserves admitted instructions when a discovered file disappears before read", () => - Effect.gen(function* () { - const file = AbsolutePath.make("/repo/AGENTS.md") - const racingFS = Layer.effect( - FSUtil.Service, - FSUtil.Service.pipe( - Effect.map((fs) => - FSUtil.Service.of({ - ...fs, - up: () => Effect.succeed([file]), - readFileStringSafe: () => Effect.succeed(undefined), + expect((yield* readInitial(yield* discovery.load())).text).toBe( + [`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join( + "\n\n", + ), + ) + }).pipe( + Effect.provide( + instructionLayer({ + config: path.join(tmp.path, "global"), + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(project) }, + ), + ), + ), }), ), + ) + }), + ), + ) + + it.effect("isolates source failure without failing activation", () => { + const failingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }), ), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - const context = yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), - Effect.provide( - instructionLayer({ - config: testConfig, - filesystemLayer: racingFS, - locationServiceLayer: Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), - ), + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + return Effect.gen(function* () { + const discovery = yield* start() + expect( + (yield* readUpdate( + yield* discovery.load(), + state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }), + )).changed, + ).toBe(false) + }).pipe( + Effect.provide( + instructionLayer({ + filesystemLayer: failingFS, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), + ), + }), + ), + ) + }) + + it.effect("marks a discovered file that disappears before read as unavailable", () => { + const discovered = AbsolutePath.make("/repo/AGENTS.md") + const racingFS = Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => + FSUtil.Service.of({ + ...fs, + up: () => Effect.succeed([discovered]), + readFileStringSafe: () => Effect.succeed(undefined), }), ), - ) - + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + return Effect.gen(function* () { + const discovery = yield* start() expect( - (yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed, + (yield* readUpdate( + yield* discovery.load(), + state({ "core/instructions": [{ path: discovered, content: "old" }] }), + )).changed, ).toBe(false) - }), - ) + }).pipe( + Effect.provide( + instructionLayer({ + filesystemLayer: racingFS, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), + ), + }), + ), + ) + }) - it.effect("canonicalizes upward discovery boundaries", () => + it.effect("canonicalizes boundaries and honors project opt-out", () => Effect.gen(function* () { - let observed: { targets: string[]; start: string; stop?: string } | undefined + const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] } const observingFS = Layer.effect( FSUtil.Service, FSUtil.Service.pipe( Effect.map((fs) => FSUtil.Service.of({ ...fs, - up: (options) => - Effect.sync(() => { - observed = options - return [] - }), + up: (options) => Effect.sync(() => (observed.values.push(options), [])), }), ), ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + yield* start().pipe( Effect.provide( instructionLayer({ - config: testConfig, filesystemLayer: observingFS, locationServiceLayer: Layer.succeed( Location.Service, @@ -235,31 +352,11 @@ describe("InstructionDiscovery", () => { }), ), ) - - 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* () { - let scanned = false - - yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + yield* start().pipe( Effect.provide( instructionLayer({ - config: testConfig, + filesystemLayer: observingFS, project: false, - filesystemLayer: Layer.effect( - FSUtil.Service, - FSUtil.Service.pipe( - Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), - ), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))), locationServiceLayer: Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), @@ -267,25 +364,10 @@ describe("InstructionDiscovery", () => { }), ), ) - - expect(scanned).toBe(false) - }), - ) - - it.effect("does not discover project instructions outside the canonical project root", () => - Effect.gen(function* () { - let scanned = false - yield* InstructionDiscovery.Service.pipe( - Effect.flatMap((service) => service.load()), + yield* start().pipe( Effect.provide( instructionLayer({ - config: testConfig, - filesystemLayer: Layer.effect( - FSUtil.Service, - FSUtil.Service.pipe( - Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), - ), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))), + filesystemLayer: observingFS, locationServiceLayer: Layer.succeed( Location.Service, Location.Service.of( @@ -299,7 +381,8 @@ describe("InstructionDiscovery", () => { ), ) - expect(scanned).toBe(false) + const repo = path.resolve("/repo") + expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }]) }), ) }) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index b89019056f8d..35f2998cc649 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -99,7 +99,10 @@ const builtins = Layer.mock(InstructionBuiltIns.Service, { }), ), }) -const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) +const discovery = Layer.mock(InstructionDiscovery.Service, { + project: true, + load: () => Effect.succeed(Instructions.empty), +}) const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 3b1883231786..02bec18ed261 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -83,7 +83,10 @@ const models = Layer.mock(SessionRunnerModel.Service)({ ), }) const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) -const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) +const instructionContext = Layer.mock(InstructionDiscovery.Service, { + project: true, + load: () => Effect.succeed(Instructions.empty), +}) const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const referenceInstructions = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index e44b3d2c9c88..fa068812bb60 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -312,7 +312,10 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, { }), ), }) -const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) +const instructionContext = Layer.mock(InstructionDiscovery.Service, { + project: true, + load: () => Effect.succeed(Instructions.empty), +}) const skillInstructions = Layer.mock(SkillInstructions.Service, { load: (agent) => Effect.succeed(