summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/core/src/global.ts32
-rw-r--r--packages/core/test/fixture/effect-flock-worker.ts25
-rw-r--r--packages/core/test/util/effect-flock.test.ts21
-rw-r--r--packages/opencode/src/session/instruction.ts316
-rw-r--r--packages/opencode/test/session/instruction.test.ts521
5 files changed, 383 insertions, 532 deletions
diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts
index 0c83e3a1f..42e0f1030 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> = {}): 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<Interface>) =>
+ 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 3dc3ee2c8..c442a62cf 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<void>((resolve) => setTimeout(resolve, ms))
}
-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 msg: Msg = JSON.parse(process.argv[2])
+
+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 9e8bc24ac..76cee4f8e 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 5d91066b4..6629ce67b 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<string>()
for (const msg of messages) {
@@ -63,176 +47,180 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
-export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
- Layer.effect(
- Service,
- Effect.gen(function* () {
- const cfg = yield* Config.Service
- const fs = yield* AppFileSystem.Service
- const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
-
- 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<MessageID, Set<string>>(),
- }),
- ),
- )
-
- 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 []
- }
+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<MessageID, Set<string>>(),
+ }),
+ ),
+ )
+
+ 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[])))
- })
-
- 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 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<string>()
-
- for (const file of globalFiles()) {
- if (yield* fs.existsSafe(file)) {
- paths.add(path.resolve(file))
- break
- }
+ }
+ 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 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 systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
+ const config = yield* cfg.get()
+ const ctx = yield* InstanceState.context
+ const paths = new Set<string>()
+
+ 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(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)))
- }
+ 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)))
}
+ }
- 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
+ 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 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
}
- })
-
- 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}` })
- }
+ 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 results
- })
+ set.add(found)
+ const content = yield* read(found)
+ if (content) {
+ results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
+ }
- return Service.of({ clear, systemPaths, system, find, resolve })
- }),
- )
+ 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 a9926b1e2..f80081759 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 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<Global.Interface>) =>
+ Instruction.layer.pipe(
+ Layer.provide(configLayer),
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(FetchHttpClient.layer),
+ Layer.provide(Global.layerWith(global)),
+ )
+
+const provideInstruction =
+ (global: Partial<Global.Interface>) =>
+ <A, E, R>(self: Effect.Effect<A, E, R>) =>
+ 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 run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
- Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
+const writeFiles = (dir: string, files: Record<string, string>) =>
+ Effect.all(
+ Object.entries(files).map(([file, content]) => write(path.join(dir, file), content)),
+ { discard: true },
+ )
+
+const withFiles = <A, E, R>(files: Record<string, string>, self: (dir: string) => Effect.Effect<A, E, R>) =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ yield* writeFiles(dir, files)
+ return yield* self(dir).pipe(provideInstruction({ home: dir, config: dir }))
+ }),
+ )
+
+const tmpWithFiles = (files: Record<string, string>) =>
+ 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)
-
- const results = yield* svc.resolve(
- [],
- path.join(tmp.path, "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)
-
- 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"))
- }),
- ),
- ),
- })
- })
-
- 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)
-
- 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")
-
- 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([])
- }),
- ),
- ),
- })
- })
-
- 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")
-
- 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"))
- }),
- ),
- ),
- })
- })
-
- 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")
-
- const results = yield* svc.resolve(loaded(agents), filepath, id)
- expect(results).toEqual([])
- }),
- ),
- ),
- })
- })
+ 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(dir, "src", "file.ts"), MessageID.make("message-test-1"))
+ expect(results).toEqual([])
+ }),
+ ),
+ )
+
+ 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(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"))
+ }),
+ ),
+ )
+
+ 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([])
+ }),
+ ),
+ )
+
+ 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)
+
+ expect(first).toHaveLength(1)
+ expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
+ expect(second).toEqual([])
+ }),
+ ),
+ )
+
+ 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)
+
+ expect(first).toHaveLength(1)
+ expect(second).toHaveLength(1)
+ expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
+ }),
+ ),
+ )
+
+ 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([])
+ }),
+ ),
+ )
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"]
-
- 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")
- },
- })
-
- 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
- }
- }
- })
+ 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" })
+
+ 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 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
-
- 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
- }
- })
+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()
+
+ 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 }))
+ }),
+ )
})