| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388 |
- import { describe, expect } from "bun:test"
- import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
- import fs from "fs/promises"
- import path from "path"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- 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 { readInitial, readUpdate, state } from "./lib/instructions"
- import { testEffect } from "./lib/effect"
- import { host } from "./plugin/host"
- const it = testEffect(Layer.empty)
- const instructionLayer = (input: {
- config?: string
- locationServiceLayer: Layer.Layer<Location.Service>
- filesystemLayer?: Layer.Layer<FSUtil.Service>
- project?: boolean
- }) => {
- 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<void>()
- 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.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) => {
- 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 })
- await fs.writeFile(outside, "outside")
- await fs.writeFile(globalFile, "global")
- await fs.writeFile(projectFile, "project")
- await fs.writeFile(packageFile, "package")
- })
- 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`,
- `Instructions from: ${packageFile}\npackage`,
- `Instructions from: ${projectFile}\nproject`,
- ].join("\n\n"),
- )
- expect(initialized.text).not.toContain("outside")
- yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
- 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))
- 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`,
- `Instructions from: ${projectFile}\nproject`,
- ].join("\n\n"),
- )
- 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) },
- ),
- ),
- ),
- }),
- ),
- )
- }),
- ),
- )
- it.live("keeps an empty AGENTS.md as available context", () =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((tmp) =>
- Effect.gen(function* () {
- const file = path.join(tmp.path, "AGENTS.md")
- yield* Effect.promise(() => fs.writeFile(file, ""))
- 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.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`)
- yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
- yield* emitAndWait({ type: "create", path: intermediate })
- 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)))
- 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(
- 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 boundaries and honors project opt-out", () =>
- Effect.gen(function* () {
- 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.values.push(options), [])),
- }),
- ),
- ),
- ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
- yield* start().pipe(
- Effect.provide(
- instructionLayer({
- filesystemLayer: observingFS,
- locationServiceLayer: Layer.succeed(
- Location.Service,
- Location.Service.of(
- location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
- ),
- ),
- }),
- ),
- )
- yield* start().pipe(
- Effect.provide(
- instructionLayer({
- filesystemLayer: observingFS,
- project: false,
- locationServiceLayer: Layer.succeed(
- Location.Service,
- Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
- ),
- }),
- ),
- )
- yield* start().pipe(
- Effect.provide(
- instructionLayer({
- filesystemLayer: observingFS,
- locationServiceLayer: Layer.succeed(
- Location.Service,
- Location.Service.of(
- location(
- { directory: AbsolutePath.make("/outside") },
- { projectDirectory: AbsolutePath.make("/repo") },
- ),
- ),
- ),
- }),
- ),
- )
- const repo = path.resolve("/repo")
- expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
- }),
- )
- })
|