From 65c15afe9f78f2b0d2f400e94fab3194e81c77cc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Apr 2026 11:48:13 -0400 Subject: [PATCH] test: use testEffect for instruction tests (#25046) --- packages/core/src/global.ts | 32 +- .../core/test/fixture/effect-flock-worker.ts | 23 +- packages/core/test/util/effect-flock.test.ts | 21 +- packages/opencode/src/session/instruction.ts | 302 ++++++----- .../opencode/test/session/instruction.test.ts | 489 +++++++----------- 5 files changed, 359 insertions(+), 508 deletions(-) diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 0c83e3a1fa..42e0f1030a 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -4,6 +4,7 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" +import { Flag } from "./flag/flag" const app = "opencode" const data = path.join(xdgData!, app) @@ -47,19 +48,28 @@ export interface Interface { readonly log: string } +export function make(input: Partial = {}): Interface { + return { + home: Path.home, + data: Path.data, + cache: Path.cache, + config: Flag.OPENCODE_CONFIG_DIR ?? Path.config, + state: Path.state, + bin: Path.bin, + log: Path.log, + ...input, + } +} + export const layer = Layer.effect( Service, - Effect.gen(function* () { - return Service.of({ - home: Path.home, - data: Path.data, - cache: Path.cache, - config: Path.config, - state: Path.state, - bin: Path.bin, - log: Path.log, - }) - }), + Effect.sync(() => Service.of(make())), ) +export const layerWith = (input: Partial) => + Layer.effect( + Service, + Effect.sync(() => Service.of(make(input))), + ) + export * as Global from "./global" diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index 3dc3ee2c8b..c442a62cf5 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -18,20 +18,17 @@ function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } -const msg: Msg = JSON.parse(process.argv[2]!) +const msg: Msg = JSON.parse(process.argv[2]) -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) +const testGlobal = Global.layerWith({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), +}) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 9e8bc24ace..76cee4f8e0 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -93,18 +93,15 @@ async function waitForFile(file: string, timeout = 3_000) { // Test layer // --------------------------------------------------------------------------- -const testGlobal = Layer.succeed( - Global.Service, - Global.Service.of({ - home: os.homedir(), - data: os.tmpdir(), - cache: os.tmpdir(), - config: os.tmpdir(), - state: os.tmpdir(), - bin: os.tmpdir(), - log: os.tmpdir(), - }), -) +const testGlobal = Global.layerWith({ + home: os.homedir(), + data: os.tmpdir(), + cache: os.tmpdir(), + config: os.tmpdir(), + state: os.tmpdir(), + bin: os.tmpdir(), + log: os.tmpdir(), +}) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 5d91066b41..6629ce67bc 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -1,4 +1,3 @@ -import os from "os" import path from "path" import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" @@ -8,30 +7,15 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" -const log = Log.create({ service: "instruction" }) - const FILES = [ "AGENTS.md", ...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]), "CONTEXT.md", // deprecated ] -function globalFiles() { - const files = [] - if (Flag.OPENCODE_CONFIG_DIR) { - files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) - } - files.push(path.join(Global.Path.config, "AGENTS.md")) - if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { - files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) - } - return files -} - function extract(messages: MessageV2.WithParts[]) { const paths = new Set() for (const msg of messages) { @@ -63,176 +47,180 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Instruction") {} -export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const fs = yield* AppFileSystem.Service - const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) +export const layer: Layer.Layer< + Service, + never, + AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient +> = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const fs = yield* AppFileSystem.Service + const global = yield* Global.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + const globalFiles = [ + path.join(global.config, "AGENTS.md"), + ...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), + ] - const state = yield* InstanceState.make( - Effect.fn("Instruction.state")(() => - Effect.succeed({ - // Track which instruction files have already been attached for a given assistant message. - claims: new Map>(), - }), - ), - ) + const state = yield* InstanceState.make( + Effect.fn("Instruction.state")(() => + Effect.succeed({ + // Track which instruction files have already been attached for a given assistant message. + claims: new Map>(), + }), + ), + ) - const relative = Effect.fnUntraced(function* (instruction: string) { - const ctx = yield* InstanceState.context - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - return yield* fs - .globUp(instruction, ctx.directory, ctx.worktree) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - } - if (!Flag.OPENCODE_CONFIG_DIR) { - log.warn( - `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`, - ) - return [] - } + const relative = Effect.fnUntraced(function* (instruction: string) { + const ctx = yield* InstanceState.context + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { return yield* fs - .globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR) + .globUp(instruction, ctx.directory, ctx.worktree) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - }) + } + return yield* fs + .globUp(instruction, global.config, global.config) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + }) - const read = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) - }) + const read = Effect.fnUntraced(function* (filepath: string) { + return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) + }) - const fetch = Effect.fnUntraced(function* (url: string) { - const res = yield* http.execute(HttpClientRequest.get(url)).pipe( - Effect.timeout(5000), - Effect.catch(() => Effect.succeed(null)), - ) - if (!res) return "" - const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) - return new TextDecoder().decode(body) - }) + const fetch = Effect.fnUntraced(function* (url: string) { + const res = yield* http.execute(HttpClientRequest.get(url)).pipe( + Effect.timeout(5000), + Effect.catch(() => Effect.succeed(null)), + ) + if (!res) return "" + const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) + return new TextDecoder().decode(body) + }) - const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { - const s = yield* InstanceState.get(state) - s.claims.delete(messageID) - }) + const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { + const s = yield* InstanceState.get(state) + s.claims.delete(messageID) + }) - const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { - const config = yield* cfg.get() - const ctx = yield* InstanceState.context - const paths = new Set() + const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { + const config = yield* cfg.get() + const ctx = yield* InstanceState.context + const paths = new Set() - for (const file of globalFiles()) { - if (yield* fs.existsSafe(file)) { - paths.add(path.resolve(file)) + for (const file of globalFiles) { + if (yield* fs.existsSafe(file)) { + paths.add(path.resolve(file)) + break + } + } + + // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. + if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { + for (const file of FILES) { + const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) + if (matches.length > 0) { + matches.forEach((item) => paths.add(path.resolve(item))) break } } + } - // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of FILES) { - const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree) - if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) - break - } - } + if (config.instructions) { + for (const raw of config.instructions) { + if (raw.startsWith("https://") || raw.startsWith("http://")) continue + const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw + const matches = yield* ( + path.isAbsolute(instruction) + ? fs.glob(path.basename(instruction), { + cwd: path.dirname(instruction), + absolute: true, + include: "file", + }) + : relative(instruction) + ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) + matches.forEach((item) => paths.add(path.resolve(item))) } + } - if (config.instructions) { - for (const raw of config.instructions) { - if (raw.startsWith("https://") || raw.startsWith("http://")) continue - const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw - const matches = yield* ( - path.isAbsolute(instruction) - ? fs.glob(path.basename(instruction), { - cwd: path.dirname(instruction), - absolute: true, - include: "file", - }) - : relative(instruction) - ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) - matches.forEach((item) => paths.add(path.resolve(item))) - } - } + return paths + }) - return paths - }) + const system = Effect.fn("Instruction.system")(function* () { + const config = yield* cfg.get() + const paths = yield* systemPaths() + const urls = (config.instructions ?? []).filter( + (item) => item.startsWith("https://") || item.startsWith("http://"), + ) - const system = Effect.fn("Instruction.system")(function* () { - const config = yield* cfg.get() - const paths = yield* systemPaths() - const urls = (config.instructions ?? []).filter( - (item) => item.startsWith("https://") || item.startsWith("http://"), - ) + const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) + const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) - const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) - const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) + return [ + ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), + ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), + ] + }) - return [ - ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), - ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), - ] - }) + const find = Effect.fn("Instruction.find")(function* (dir: string) { + for (const file of FILES) { + const filepath = path.resolve(path.join(dir, file)) + if (yield* fs.existsSafe(filepath)) return filepath + } + return undefined + }) - const find = Effect.fn("Instruction.find")(function* (dir: string) { - for (const file of FILES) { - const filepath = path.resolve(path.join(dir, file)) - if (yield* fs.existsSafe(filepath)) return filepath - } - }) + const resolve = Effect.fn("Instruction.resolve")(function* ( + messages: MessageV2.WithParts[], + filepath: string, + messageID: MessageID, + ) { + const sys = yield* systemPaths() + const already = extract(messages) + const results: { filepath: string; content: string }[] = [] + const s = yield* InstanceState.get(state) + const root = path.resolve(yield* InstanceState.directory) - const resolve = Effect.fn("Instruction.resolve")(function* ( - messages: MessageV2.WithParts[], - filepath: string, - messageID: MessageID, - ) { - const sys = yield* systemPaths() - const already = extract(messages) - const results: { filepath: string; content: string }[] = [] - const s = yield* InstanceState.get(state) - const root = path.resolve(yield* InstanceState.directory) - - const target = path.resolve(filepath) - let current = path.dirname(target) - - // Walk upward from the file being read and attach nearby instruction files once per message. - while (current.startsWith(root) && current !== root) { - const found = yield* find(current) - if (!found || found === target || sys.has(found) || already.has(found)) { - current = path.dirname(current) - continue - } - - let set = s.claims.get(messageID) - if (!set) { - set = new Set() - s.claims.set(messageID, set) - } - if (set.has(found)) { - current = path.dirname(current) - continue - } - - set.add(found) - const content = yield* read(found) - if (content) { - results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) - } + const target = path.resolve(filepath) + let current = path.dirname(target) + // Walk upward from the file being read and attach nearby instruction files once per message. + while (current.startsWith(root) && current !== root) { + const found = yield* find(current) + if (!found || found === target || sys.has(found) || already.has(found)) { current = path.dirname(current) + continue } - return results - }) + let set = s.claims.get(messageID) + if (!set) { + set = new Set() + s.claims.set(messageID, set) + } + if (set.has(found)) { + current = path.dirname(current) + continue + } - return Service.of({ clear, systemPaths, system, find, resolve }) - }), - ) + set.add(found) + const content = yield* read(found) + if (content) { + results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) + } + + current = path.dirname(current) + } + + return results + }) + + return Service.of({ clear, systemPaths, system, find, resolve }) + }), +) export const defaultLayer = layer.pipe( Layer.provide(Config.defaultLayer), + Layer.provide(Global.layer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index a9926b1e22..f800817594 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,16 +1,76 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { describe, expect, test } from "bun:test" import path from "path" -import { Effect } from "effect" +import { Effect, FileSystem, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { NodeFileSystem } from "@effect/platform-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Config } from "@/config/config" +import { emptyConsoleState } from "@/config/console-state" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" -import { Instance } from "../../src/project/instance" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -const run = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer))) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) + +const configLayer = Layer.succeed( + Config.Service, + Config.Service.of({ + get: () => Effect.succeed({}), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed(emptyConsoleState), + update: () => Effect.void, + updateGlobal: (config) => Effect.succeed(config), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + }), +) + +const instructionLayer = (global: Partial) => + Instruction.layer.pipe( + Layer.provide(configLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Global.layerWith(global)), + ) + +const provideInstruction = + (global: Partial) => + (self: Effect.Effect) => + self.pipe(Effect.provide(instructionLayer(global))) + +const write = (filepath: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* fs.makeDirectory(path.dirname(filepath), { recursive: true }) + yield* fs.writeFileString(filepath, content) + }) + +const writeFiles = (dir: string, files: Record) => + Effect.all( + Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)), + { discard: true }, + ) + +const withFiles = (files: Record, self: (dir: string) => Effect.Effect) => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* writeFiles(dir, files) + return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir })) + }), + ) + +const tmpWithFiles = (files: Record) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* writeFiles(dir, files) + return dir + }) function loaded(filepath: string): MessageV2.WithParts[] { const sessionID = SessionID.make("session-loaded-1") @@ -52,336 +112,135 @@ function loaded(filepath: string): MessageV2.WithParts[] { } describe("Instruction.resolve", () => { - test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions") - await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const system = yield* svc.systemPaths() - expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) + it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () => + withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const system = yield* svc.systemPaths() + expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true) - const results = yield* svc.resolve( - [], - path.join(tmp.path, "src", "file.ts"), - MessageID.make("message-test-1"), - ) - expect(results).toEqual([]) - }), - ), - ), - }) - }) + const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1")) + expect(results).toEqual([]) + }), + ), + ) - test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const system = yield* svc.systemPaths() - expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) + it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const system = yield* svc.systemPaths() + expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false) - const results = yield* svc.resolve( - [], - path.join(tmp.path, "subdir", "nested", "file.ts"), - MessageID.make("message-test-2"), - ) - expect(results.length).toBe(1) - expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - }), - ), - ), - }) - }) + const results = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("message-test-2"), + ) + expect(results.length).toBe(1) + expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + }), + ), + ) - test("doesn't reload AGENTS.md when reading it directly", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "AGENTS.md") - const system = yield* svc.systemPaths() - expect(system.has(filepath)).toBe(false) + it.live("doesn't reload AGENTS.md when reading it directly", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "AGENTS.md") + const system = yield* svc.systemPaths() + expect(system.has(filepath)).toBe(false) - const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) - expect(results).toEqual([]) - }), - ), - ), - }) - }) + const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) + expect(results).toEqual([]) + }), + ), + ) - test("does not reattach the same nearby instructions twice for one message", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-1") + it.live("does not reattach the same nearby instructions twice for one message", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-1") - const first = yield* svc.resolve([], filepath, id) - const second = yield* svc.resolve([], filepath, id) + const first = yield* svc.resolve([], filepath, id) + const second = yield* svc.resolve([], filepath, id) - expect(first).toHaveLength(1) - expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - expect(second).toEqual([]) - }), - ), - ), - }) - }) + expect(first).toHaveLength(1) + expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + expect(second).toEqual([]) + }), + ), + ) - test("clear allows nearby instructions to be attached again for the same message", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-2") + it.live("clear allows nearby instructions to be attached again for the same message", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-2") - const first = yield* svc.resolve([], filepath, id) - yield* svc.clear(id) - const second = yield* svc.resolve([], filepath, id) + const first = yield* svc.resolve([], filepath, id) + yield* svc.clear(id) + const second = yield* svc.resolve([], filepath, id) - expect(first).toHaveLength(1) - expect(second).toHaveLength(1) - expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) - }), - ), - ), - }) - }) + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) + }), + ), + ) - test("skips instructions already reported by prior read metadata", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") - await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const agents = path.join(tmp.path, "subdir", "AGENTS.md") - const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-3") + it.live("skips instructions already reported by prior read metadata", () => + withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const agents = path.join(dir, "subdir", "AGENTS.md") + const filepath = path.join(dir, "subdir", "nested", "file.ts") + const id = MessageID.make("message-claim-3") - const results = yield* svc.resolve(loaded(agents), filepath, id) - expect(results).toEqual([]) - }), - ), - ), - }) - }) + const results = yield* svc.resolve(loaded(agents), filepath, id) + expect(results).toEqual([]) + }), + ), + ) test.todo("fetches remote instructions from config URLs via HttpClient", () => {}) }) describe("Instruction.system", () => { - test("loads both project and global AGENTS.md when both exist", async () => { - const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] - delete process.env["OPENCODE_CONFIG_DIR"] + it.live("loads both project and global AGENTS.md when both exist", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpWithFiles({ "AGENTS.md": "# Project Instructions" }) - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions") - }, - }) + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - - const rules = yield* svc.system() - expect(rules).toHaveLength(2) - expect(rules[0]).toBe( - `Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`, - ) - expect(rules[1]).toBe( - `Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`, - ) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - if (originalConfigDir === undefined) { - delete process.env["OPENCODE_CONFIG_DIR"] - } else { - process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir - } - } - }) + const rules = yield* svc.system() + expect(rules).toHaveLength(2) + expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`) + expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`) + }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp })) + }), + ) }) -describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => { - let originalConfigDir: string | undefined +describe("Instruction.systemPaths global config", () => { + it.live("uses Global.Service config AGENTS.md", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ "AGENTS.md": "# Global Instructions" }) + const projectTmp = yield* tmpdirScoped() - beforeEach(() => { - originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] - }) - - afterEach(() => { - if (originalConfigDir === undefined) { - delete process.env["OPENCODE_CONFIG_DIR"] - } else { - process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir - } - }) - - test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => { - await using profileTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions") - }, - }) - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() - - process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) - - test("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", async () => { - await using profileTmp = await tmpdir() - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() - - process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) - - test("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", async () => { - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") - }, - }) - await using projectTmp = await tmpdir() - - delete process.env["OPENCODE_CONFIG_DIR"] - const originalGlobalConfig = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - - try { - await Instance.provide({ - directory: projectTmp.path, - fn: () => - run( - Instruction.Service.use((svc) => - Effect.gen(function* () { - const paths = yield* svc.systemPaths() - expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) - }), - ), - ), - }) - } finally { - ;(Global.Path as { config: string }).config = originalGlobalConfig - } - }) + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const paths = yield* svc.systemPaths() + expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) + }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp })) + }), + ) })