mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
726 lines
28 KiB
TypeScript
726 lines
28 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Deferred, Effect, Fiber, Layer, PlatformError, PubSub, Stream } from "effect"
|
|
import { HttpClient, HttpClientResponse } from "effect/http"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
|
import { Bus } from "@opencode/core/bus"
|
|
import { Config } from "@opencode/core/config"
|
|
import { ConfigInstructionPlugin } from "@opencode/core/config/plugin/instruction"
|
|
import { Watcher } from "@opencode/core/filesystem/watcher"
|
|
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
|
|
import { Location } from "@opencode/core/location"
|
|
import { AbsolutePath } from "@opencode/core/schema"
|
|
import { Document, Info } from "@opencode/schema/config"
|
|
import { Event } from "@opencode/schema/event"
|
|
import { FSUtil } from "@opencode/util/fs-util"
|
|
import { Global } from "@opencode/util/global"
|
|
import { LayerNode } from "@opencode/util/effect/layer-node"
|
|
import { tempGlobalLayer } from "./fixture/global"
|
|
import { location } from "./fixture/location"
|
|
import { tmpdirScoped } 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
|
|
home?: string
|
|
locationServiceLayer: Layer.Layer<Location.Service>
|
|
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
|
instructions?: string[]
|
|
http?: HttpClient.HttpClient
|
|
project?: boolean
|
|
}) => {
|
|
const watcher = Watcher.testLayer
|
|
const config = Config.testLayer(input.instructions ? [configured(input.instructions)] : [])
|
|
return Layer.mergeAll(
|
|
AppNodeBuilder.build(
|
|
LayerNode.group([
|
|
InstructionDiscovery.node,
|
|
Bus.node,
|
|
Config.node,
|
|
FSUtil.node,
|
|
Global.node,
|
|
Location.node,
|
|
Watcher.node,
|
|
]),
|
|
[
|
|
Config.node.replace(config),
|
|
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
|
|
Global.node.replace(
|
|
input.config || input.home
|
|
? Global.layerWith({
|
|
...(input.config ? { config: input.config } : {}),
|
|
...(input.home ? { home: input.home } : {}),
|
|
})
|
|
: tempGlobalLayer,
|
|
),
|
|
Location.node.replace(input.locationServiceLayer),
|
|
Watcher.node.replace(watcher),
|
|
...(input.filesystemLayer ? [FSUtil.node.replace(input.filesystemLayer)] : []),
|
|
],
|
|
),
|
|
watcher,
|
|
config,
|
|
Layer.succeed(HttpClient.HttpClient, input.http ?? respond(() => new Response(null, { status: 404 }))),
|
|
)
|
|
}
|
|
|
|
const start = Effect.fnUntraced(function* (ctx = host()) {
|
|
yield* ConfigInstructionPlugin.Plugin.effect(ctx)
|
|
return yield* InstructionDiscovery.Service
|
|
})
|
|
|
|
const configured = (instructions: string[]) => new Document({ type: "document", info: new Info({ instructions }) })
|
|
|
|
const readFailure = (tag: "NotFound" | "Unknown", code: string, file: string) =>
|
|
PlatformError.systemError({
|
|
_tag: tag,
|
|
module: "FileSystem",
|
|
method: "readFile",
|
|
pathOrDescriptor: file,
|
|
cause: Object.assign(new Error(code), { code }),
|
|
})
|
|
|
|
const configUpdated = () => ({ id: Event.ID.create(), created: Date.now(), type: "config.updated" as const, data: {} })
|
|
|
|
const respond = (handler: (url: string) => Response) =>
|
|
HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request.url))))
|
|
|
|
const file = (path: string, content: string) => new InstructionDiscovery.File({ path, content })
|
|
|
|
function emitAndWait(update: Watcher.Update) {
|
|
return Effect.gen(function* () {
|
|
const watcher = yield* Watcher.Test
|
|
yield* triggerAndWait(watcher.emit(update))
|
|
})
|
|
}
|
|
|
|
function triggerAndWait<E, R>(trigger: Effect.Effect<void, E, R>) {
|
|
return Effect.gen(function* () {
|
|
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* trigger
|
|
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((editor) => {
|
|
editor.add(file("/repo/AGENTS.md", "first"))
|
|
editor.add(file("/repo/packages/AGENTS.md", "package"))
|
|
editor.add(file("/repo/AGENTS.md", "last"))
|
|
editor.update("/repo/packages/AGENTS.md", (current) => {
|
|
current.content = "updated"
|
|
current.path = AbsolutePath.make("/ignored")
|
|
})
|
|
editor.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((editor) => editor.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])))),
|
|
)
|
|
|
|
it.effect("renders granular instruction updates", () =>
|
|
Effect.gen(function* () {
|
|
const discovery = yield* InstructionDiscovery.Service
|
|
yield* discovery.transform((editor) => {
|
|
editor.add(file("/global/AGENTS.md", "global"))
|
|
editor.add(
|
|
file("/repo/AGENTS.md", ["old", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")),
|
|
)
|
|
})
|
|
const initial = yield* readInitial(yield* discovery.load())
|
|
|
|
yield* discovery.transform((editor) => {
|
|
editor.update("/repo/AGENTS.md", (current) => {
|
|
current.content = ["new", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")
|
|
})
|
|
})
|
|
const modified = (yield* readUpdate(yield* discovery.load(), initial)).text
|
|
expect(modified).toContain("The instructions from /repo/AGENTS.md changed. Here's the diff:")
|
|
expect(modified).toContain("-old\n+new")
|
|
expect(modified).not.toContain("global")
|
|
|
|
const rewritten = state({
|
|
"core/instructions": [{ path: "/repo/AGENTS.md", content: "old one\nold two\nold three\nold four" }],
|
|
})
|
|
yield* discovery.transform((editor) => {
|
|
editor.remove("/global/AGENTS.md")
|
|
editor.update("/repo/AGENTS.md", (current) => {
|
|
current.content = "new"
|
|
})
|
|
})
|
|
expect((yield* readUpdate(yield* discovery.load(), rewritten)).text).toBe(
|
|
"The instructions changed:\nInstructions from: /repo/AGENTS.md\nnew",
|
|
)
|
|
|
|
yield* discovery.transform((editor) => {
|
|
editor.add(file("/repo/packages/AGENTS.md", "package"))
|
|
})
|
|
const structural = (yield* readUpdate(yield* discovery.load(), initial)).text
|
|
expect(structural).toContain("The instructions from /global/AGENTS.md no longer apply.")
|
|
expect(structural).toContain("New instructions apply from:\nInstructions from: /repo/packages/AGENTS.md\npackage")
|
|
expect(structural).not.toContain("Instructions from: /global/AGENTS.md\nglobal")
|
|
}).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", () =>
|
|
tmpdirScoped().pipe(
|
|
Effect.flatMap((tmp) => {
|
|
const global = path.join(tmp.path, "global")
|
|
const home = path.join(tmp.path, "home")
|
|
const shared = path.join(home, "code")
|
|
const project = path.join(shared, "repo")
|
|
const directory = path.join(project, "packages", "core")
|
|
const outside = path.join(tmp.path, "AGENTS.md")
|
|
const globalFile = path.join(global, "AGENTS.md")
|
|
const sharedFile = path.join(shared, "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(sharedFile, "shared")
|
|
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" },
|
|
{ path: sharedFile, type: "file" },
|
|
{ path: path.join(home, "AGENTS.md"), type: "file" },
|
|
])
|
|
expect(yield* watcher.subscriptions()).not.toContainEqual({
|
|
path: path.join(tmp.path, "AGENTS.md"),
|
|
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`,
|
|
`Instructions from: ${sharedFile}\nshared`,
|
|
].join("\n\n"),
|
|
)
|
|
expect(initialized.text).not.toContain("outside")
|
|
|
|
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
|
yield* emitAndWait({ type: "update", path: packageFile })
|
|
const changed = (yield* readUpdate(yield* discovery.load(), initialized)).text
|
|
expect(changed).toContain(`The instructions changed:\nInstructions from: ${packageFile}\nchanged`)
|
|
expect(changed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
|
|
|
|
yield* Effect.promise(() => fs.rm(packageFile))
|
|
yield* emitAndWait({ type: "delete", path: packageFile })
|
|
const removed = (yield* readUpdate(yield* discovery.load(), initialized)).text
|
|
expect(removed).toContain(`The instructions from ${packageFile} no longer apply.`)
|
|
expect(removed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
|
|
|
|
yield* Effect.promise(() => fs.rm(globalFile))
|
|
yield* emitAndWait({ type: "delete", path: globalFile })
|
|
yield* Effect.promise(() => fs.rm(projectFile))
|
|
yield* emitAndWait({ type: "delete", path: projectFile })
|
|
yield* Effect.promise(() => fs.rm(sharedFile))
|
|
yield* emitAndWait({ type: "delete", path: sharedFile })
|
|
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
|
"Previously loaded instructions no longer apply.",
|
|
)
|
|
}).pipe(
|
|
Effect.provide(
|
|
instructionLayer({
|
|
config: global,
|
|
home,
|
|
locationServiceLayer: Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(
|
|
location(
|
|
{ directory: AbsolutePath.make(directory) },
|
|
{ projectDirectory: AbsolutePath.make(project) },
|
|
),
|
|
),
|
|
),
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads configured files, globs, home paths, and URLs alongside AGENTS.md", () =>
|
|
tmpdirScoped().pipe(
|
|
Effect.flatMap((tmp) => {
|
|
const project = path.join(tmp.path, "repo")
|
|
const directory = path.join(project, "packages", "core")
|
|
const agents = path.join(project, "AGENTS.md")
|
|
const first = path.join(project, "rules", "a.md")
|
|
const second = path.join(project, "rules", "b.md")
|
|
const absolute = path.join(tmp.path, "absolute.md")
|
|
const homeFile = path.join(tmp.path, "home.md")
|
|
const noteTop = path.join(tmp.path, "notes", "guide.md")
|
|
const noteNested = path.join(tmp.path, "notes", "projects", "memory.md")
|
|
const remote = "https://example.test/instructions.md"
|
|
const requests: string[] = []
|
|
return Effect.gen(function* () {
|
|
yield* Effect.promise(async () => {
|
|
await fs.mkdir(path.dirname(first), { recursive: true })
|
|
await fs.mkdir(directory, { recursive: true })
|
|
await fs.mkdir(path.dirname(noteNested), { recursive: true })
|
|
await Promise.all([
|
|
fs.writeFile(agents, "agents"),
|
|
fs.writeFile(first, "first"),
|
|
fs.writeFile(second, "second"),
|
|
fs.writeFile(absolute, "absolute"),
|
|
fs.writeFile(homeFile, "home"),
|
|
fs.writeFile(noteTop, "note top"),
|
|
fs.writeFile(noteNested, "note nested"),
|
|
])
|
|
})
|
|
|
|
const discovery = yield* start()
|
|
const initialized = yield* readInitial(yield* discovery.load())
|
|
// The unreachable URL and missing file are skipped without making AGENTS.md unavailable.
|
|
expect(initialized.text).toBe(
|
|
[
|
|
`Instructions from: ${agents}\nagents`,
|
|
`Instructions from: ${first}\nfirst`,
|
|
`Instructions from: ${second}\nsecond`,
|
|
`Instructions from: ${absolute}\nabsolute`,
|
|
`Instructions from: ${homeFile}\nhome`,
|
|
`Instructions from: ${noteTop}\nnote top`,
|
|
`Instructions from: ${noteNested}\nnote nested`,
|
|
`Instructions from: ${remote}\nremote`,
|
|
].join("\n\n"),
|
|
)
|
|
|
|
yield* Effect.promise(() => fs.writeFile(first, "changed"))
|
|
yield* emitAndWait({ type: "update", path: first })
|
|
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
|
`The instructions changed:\nInstructions from: ${first}\nchanged`,
|
|
)
|
|
// File rescans reuse fetched URLs instead of requesting them again.
|
|
expect(requests).toHaveLength(2)
|
|
}).pipe(
|
|
Effect.provide(
|
|
instructionLayer({
|
|
config: path.join(tmp.path, "global"),
|
|
home: tmp.path,
|
|
instructions: [
|
|
"rules/*.md",
|
|
absolute,
|
|
"~/home.md",
|
|
"~/notes/**/*.md",
|
|
remote,
|
|
"missing.md",
|
|
"https://example.test/missing.md",
|
|
],
|
|
http: respond((url) => {
|
|
requests.push(url)
|
|
return url === remote ? new Response("remote") : new Response(null, { status: 404 })
|
|
}),
|
|
locationServiceLayer: Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(
|
|
location(
|
|
{ directory: AbsolutePath.make(directory) },
|
|
{ projectDirectory: AbsolutePath.make(project) },
|
|
),
|
|
),
|
|
),
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("reloads configured sources on config updates and keeps the last fetched URL", () =>
|
|
tmpdirScoped().pipe(
|
|
Effect.flatMap((tmp) => {
|
|
const first = path.join(tmp.path, "first.md")
|
|
const second = path.join(tmp.path, "second.md")
|
|
const remote = "https://example.test/instructions.md"
|
|
const responses = [new Response("remote"), new Response(null, { status: 500 })]
|
|
return Effect.gen(function* () {
|
|
yield* Effect.promise(async () => {
|
|
await fs.writeFile(first, "first")
|
|
await fs.writeFile(second, "second")
|
|
})
|
|
const events = yield* PubSub.unbounded<ReturnType<typeof configUpdated>>()
|
|
const config = yield* Config.Test
|
|
const discovery = yield* start(host({ event: { subscribe: () => Stream.fromPubSub(events) } }))
|
|
const initialized = yield* readInitial(yield* discovery.load())
|
|
expect(initialized.text).toBe(
|
|
[`Instructions from: ${first}\nfirst`, `Instructions from: ${remote}\nremote`].join("\n\n"),
|
|
)
|
|
|
|
yield* config.setEntries([configured([second, remote])])
|
|
yield* triggerAndWait(PubSub.publish(events, configUpdated()).pipe(Effect.asVoid))
|
|
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
|
[`Instructions from: ${second}\nsecond`, `Instructions from: ${remote}\nremote`].join("\n\n"),
|
|
)
|
|
}).pipe(
|
|
Effect.provide(
|
|
instructionLayer({
|
|
config: path.join(tmp.path, "global"),
|
|
instructions: [first, remote],
|
|
http: respond(() => responses.shift() ?? new Response(null, { status: 500 })),
|
|
locationServiceLayer: Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
|
),
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("keeps an empty AGENTS.md as available context", () =>
|
|
tmpdirScoped().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 above the project root", () =>
|
|
tmpdirScoped().pipe(
|
|
Effect.flatMap((tmp) => {
|
|
const home = path.join(tmp.path, "home")
|
|
const shared = path.join(home, "code")
|
|
const project = path.join(shared, "repo")
|
|
const intermediate = path.join(shared, "AGENTS.md")
|
|
const directory = path.join(project, "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: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
|
|
"\n\n",
|
|
),
|
|
)
|
|
}).pipe(
|
|
Effect.provide(
|
|
instructionLayer({
|
|
config: path.join(tmp.path, "global"),
|
|
home,
|
|
locationServiceLayer: Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(
|
|
location(
|
|
{ directory: AbsolutePath.make(directory) },
|
|
{ projectDirectory: AbsolutePath.make(project) },
|
|
),
|
|
),
|
|
),
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("stops instruction candidates at the project root outside home", () =>
|
|
tmpdirScoped().pipe(
|
|
Effect.flatMap((tmp) => {
|
|
const global = path.join(tmp.path, "global")
|
|
const home = path.join(tmp.path, "home")
|
|
const project = path.join(tmp.path, "scratch", "repo")
|
|
const directory = path.join(project, "packages", "core")
|
|
return Effect.gen(function* () {
|
|
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
|
yield* start()
|
|
const watcher = yield* Watcher.Test
|
|
expect(yield* watcher.subscriptions()).toEqual([
|
|
{ path: path.join(global, "AGENTS.md"), type: "file" },
|
|
{ path: path.join(directory, "AGENTS.md"), type: "file" },
|
|
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
|
|
{ path: path.join(project, "AGENTS.md"), type: "file" },
|
|
])
|
|
}).pipe(
|
|
Effect.provide(
|
|
instructionLayer({
|
|
config: global,
|
|
home,
|
|
locationServiceLayer: Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(
|
|
location(
|
|
{ directory: AbsolutePath.make(directory) },
|
|
{ projectDirectory: AbsolutePath.make(project) },
|
|
),
|
|
),
|
|
),
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
|
|
// A client CWD can spell the drive `c:` while git reports the project root as `C:`.
|
|
;(process.platform === "win32" ? it.live : it.live.skip)(
|
|
"stops at the project root when the location drive letter differs in case",
|
|
() =>
|
|
tmpdirScoped().pipe(
|
|
Effect.flatMap((tmp) => {
|
|
const global = path.join(tmp.path, "global")
|
|
const home = path.join(tmp.path, "home")
|
|
const project = path.join(tmp.path, "repo")
|
|
const directory = project[0].toLowerCase() + project.slice(1)
|
|
return Effect.gen(function* () {
|
|
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
|
|
yield* Effect.promise(() => fs.writeFile(path.join(project, "AGENTS.md"), "project"))
|
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "AGENTS.md"), "outside"))
|
|
const discovery = yield* start()
|
|
const watcher = yield* Watcher.Test
|
|
expect(yield* watcher.subscriptions()).toEqual([
|
|
{ path: path.join(global, "AGENTS.md"), type: "file" },
|
|
{ path: path.join(directory, "AGENTS.md"), type: "file" },
|
|
])
|
|
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
|
`Instructions from: ${path.join(directory, "AGENTS.md")}\nproject`,
|
|
)
|
|
}).pipe(
|
|
Effect.provide(
|
|
instructionLayer({
|
|
config: global,
|
|
home,
|
|
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]),
|
|
readFileString: (file) => Effect.fail(readFailure("NotFound", "ENOENT", file)),
|
|
}),
|
|
),
|
|
),
|
|
).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("skips discovered files a sandbox denies and keeps the rest available", () => {
|
|
const denied = path.resolve("/repo/AGENTS.md")
|
|
const readable = path.resolve("/repo/packages/AGENTS.md")
|
|
const sandboxedFS = Layer.effect(
|
|
FSUtil.Service,
|
|
FSUtil.Service.pipe(
|
|
Effect.map((fs) =>
|
|
FSUtil.Service.of({
|
|
...fs,
|
|
up: () => Effect.succeed([readable, denied]),
|
|
readFileString: (file) =>
|
|
file === denied ? Effect.fail(readFailure("Unknown", "EPERM", file)) : Effect.succeed("package"),
|
|
}),
|
|
),
|
|
),
|
|
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
|
return Effect.gen(function* () {
|
|
const discovery = yield* start()
|
|
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${readable}\npackage`)
|
|
}).pipe(
|
|
Effect.provide(
|
|
instructionLayer({
|
|
filesystemLayer: sandboxedFS,
|
|
locationServiceLayer: Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(location({ directory: AbsolutePath.make("/repo/packages") })),
|
|
),
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
it.effect("canonicalizes boundaries and honors project opt-out", () =>
|
|
Effect.gen(function* () {
|
|
const observed: { values: FSUtil.UpOptions[] } = { 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, type: "file" }])
|
|
}),
|
|
)
|
|
})
|