test: use testEffect for instruction tests (#25046)

This commit is contained in:
Kit Langton
2026-04-30 11:48:13 -04:00
committed by GitHub
parent 8f57a2a462
commit 65c15afe9f
5 changed files with 359 additions and 508 deletions
+17 -7
View File
@@ -4,6 +4,7 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os" import os from "os"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock" import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
const app = "opencode" const app = "opencode"
const data = path.join(xdgData!, app) const data = path.join(xdgData!, app)
@@ -47,19 +48,28 @@ export interface Interface {
readonly log: string readonly log: string
} }
export const layer = Layer.effect( export function make(input: Partial<Interface> = {}): Interface {
Service, return {
Effect.gen(function* () {
return Service.of({
home: Path.home, home: Path.home,
data: Path.data, data: Path.data,
cache: Path.cache, cache: Path.cache,
config: Path.config, config: Flag.OPENCODE_CONFIG_DIR ?? Path.config,
state: Path.state, state: Path.state,
bin: Path.bin, bin: Path.bin,
log: Path.log, log: Path.log,
}) ...input,
}), }
}
export const layer = Layer.effect(
Service,
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" export * as Global from "./global"
@@ -18,11 +18,9 @@ function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms)) return new Promise<void>((resolve) => setTimeout(resolve, ms))
} }
const msg: Msg = JSON.parse(process.argv[2]!) const msg: Msg = JSON.parse(process.argv[2])
const testGlobal = Layer.succeed( const testGlobal = Global.layerWith({
Global.Service,
Global.Service.of({
home: os.homedir(), home: os.homedir(),
data: os.tmpdir(), data: os.tmpdir(),
cache: os.tmpdir(), cache: os.tmpdir(),
@@ -30,8 +28,7 @@ const testGlobal = Layer.succeed(
state: os.tmpdir(), state: os.tmpdir(),
bin: os.tmpdir(), bin: os.tmpdir(),
log: os.tmpdir(), log: os.tmpdir(),
}), })
)
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+2 -5
View File
@@ -93,9 +93,7 @@ async function waitForFile(file: string, timeout = 3_000) {
// Test layer // Test layer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const testGlobal = Layer.succeed( const testGlobal = Global.layerWith({
Global.Service,
Global.Service.of({
home: os.homedir(), home: os.homedir(),
data: os.tmpdir(), data: os.tmpdir(),
cache: os.tmpdir(), cache: os.tmpdir(),
@@ -103,8 +101,7 @@ const testGlobal = Layer.succeed(
state: os.tmpdir(), state: os.tmpdir(),
bin: os.tmpdir(), bin: os.tmpdir(),
log: os.tmpdir(), log: os.tmpdir(),
}), })
)
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer)) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+15 -27
View File
@@ -1,4 +1,3 @@
import os from "os"
import path from "path" import path from "path"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" 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 { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema" import type { MessageID } from "./schema"
const log = Log.create({ service: "instruction" })
const FILES = [ const FILES = [
"AGENTS.md", "AGENTS.md",
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]), ...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
"CONTEXT.md", // deprecated "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[]) { function extract(messages: MessageV2.WithParts[]) {
const paths = new Set<string>() const paths = new Set<string>()
for (const msg of messages) { for (const msg of messages) {
@@ -63,13 +47,21 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> = export const layer: Layer.Layer<
Layer.effect( Service,
never,
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient
> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const cfg = yield* Config.Service const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const global = yield* Global.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) 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( const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() => Effect.fn("Instruction.state")(() =>
@@ -87,14 +79,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
.globUp(instruction, ctx.directory, ctx.worktree) .globUp(instruction, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[]))) .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 []
}
return yield* fs return yield* fs
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR) .globUp(instruction, global.config, global.config)
.pipe(Effect.catch(() => Effect.succeed([] as string[]))) .pipe(Effect.catch(() => Effect.succeed([] as string[])))
}) })
@@ -122,7 +108,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const paths = new Set<string>() const paths = new Set<string>()
for (const file of globalFiles()) { for (const file of globalFiles) {
if (yield* fs.existsSafe(file)) { if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file)) paths.add(path.resolve(file))
break break
@@ -143,7 +129,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
if (config.instructions) { if (config.instructions) {
for (const raw of config.instructions) { for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw
const matches = yield* ( const matches = yield* (
path.isAbsolute(instruction) path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), { ? fs.glob(path.basename(instruction), {
@@ -181,6 +167,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
const filepath = path.resolve(path.join(dir, file)) const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath if (yield* fs.existsSafe(filepath)) return filepath
} }
return undefined
}) })
const resolve = Effect.fn("Instruction.resolve")(function* ( const resolve = Effect.fn("Instruction.resolve")(function* (
@@ -233,6 +220,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Global.layer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
) )
+128 -269
View File
@@ -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 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 { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
import type { MessageV2 } from "../../src/session/message-v2" import type { MessageV2 } from "../../src/session/message-v2"
import { Instance } from "../../src/project/instance"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "@opencode-ai/core/global" 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 = <A>(effect: Effect.Effect<A, any, Instruction.Service>) => const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
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 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[] { function loaded(filepath: string): MessageV2.WithParts[] {
const sessionID = SessionID.make("session-loaded-1") const sessionID = SessionID.make("session-loaded-1")
@@ -52,77 +112,42 @@ function loaded(filepath: string): MessageV2.WithParts[] {
} }
describe("Instruction.resolve", () => { describe("Instruction.resolve", () => {
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { it.live("returns empty when AGENTS.md is at project root (already in systemPaths)", () =>
await using tmp = await tmpdir({ withFiles({ "AGENTS.md": "# Root Instructions", "src/file.ts": "const x = 1" }, (dir) =>
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* () { Effect.gen(function* () {
const svc = yield* Instruction.Service
const system = yield* svc.systemPaths() const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true) expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true)
const results = yield* svc.resolve( const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1"))
[],
path.join(tmp.path, "src", "file.ts"),
MessageID.make("message-test-1"),
)
expect(results).toEqual([]) expect(results).toEqual([])
}), }),
), ),
), )
})
})
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => { it.live("returns AGENTS.md from subdirectory (not in systemPaths)", () =>
await using tmp = await tmpdir({ withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
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* () { Effect.gen(function* () {
const svc = yield* Instruction.Service
const system = yield* svc.systemPaths() const system = yield* svc.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false) expect(system.has(path.join(dir, "subdir", "AGENTS.md"))).toBe(false)
const results = yield* svc.resolve( const results = yield* svc.resolve(
[], [],
path.join(tmp.path, "subdir", "nested", "file.ts"), path.join(dir, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"), MessageID.make("message-test-2"),
) )
expect(results.length).toBe(1) expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
}), }),
), ),
), )
})
})
test("doesn't reload AGENTS.md when reading it directly", async () => { it.live("doesn't reload AGENTS.md when reading it directly", () =>
await using tmp = await tmpdir({ withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
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* () { Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md") const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "AGENTS.md")
const system = yield* svc.systemPaths() const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false) expect(system.has(filepath)).toBe(false)
@@ -130,52 +155,30 @@ describe("Instruction.resolve", () => {
expect(results).toEqual([]) expect(results).toEqual([])
}), }),
), ),
), )
})
})
test("does not reattach the same nearby instructions twice for one message", async () => { it.live("does not reattach the same nearby instructions twice for one message", () =>
await using tmp = await tmpdir({ withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
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* () { Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1") const id = MessageID.make("message-claim-1")
const first = yield* svc.resolve([], filepath, id) const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id) const second = yield* svc.resolve([], filepath, id)
expect(first).toHaveLength(1) expect(first).toHaveLength(1)
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
expect(second).toEqual([]) expect(second).toEqual([])
}), }),
), ),
), )
})
})
test("clear allows nearby instructions to be attached again for the same message", async () => { it.live("clear allows nearby instructions to be attached again for the same message", () =>
await using tmp = await tmpdir({ withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
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* () { Effect.gen(function* () {
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2") const id = MessageID.make("message-claim-2")
const first = yield* svc.resolve([], filepath, id) const first = yield* svc.resolve([], filepath, id)
@@ -184,204 +187,60 @@ describe("Instruction.resolve", () => {
expect(first).toHaveLength(1) expect(first).toHaveLength(1)
expect(second).toHaveLength(1) expect(second).toHaveLength(1)
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md")) expect(second[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
}), }),
), ),
), )
})
})
test("skips instructions already reported by prior read metadata", async () => { it.live("skips instructions already reported by prior read metadata", () =>
await using tmp = await tmpdir({ withFiles({ "subdir/AGENTS.md": "# Subdir Instructions", "subdir/nested/file.ts": "const x = 1" }, (dir) =>
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* () { Effect.gen(function* () {
const agents = path.join(tmp.path, "subdir", "AGENTS.md") const svc = yield* Instruction.Service
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts") 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 id = MessageID.make("message-claim-3")
const results = yield* svc.resolve(loaded(agents), filepath, id) const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([]) expect(results).toEqual([])
}), }),
), ),
), )
})
})
test.todo("fetches remote instructions from config URLs via HttpClient", () => {}) test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
}) })
describe("Instruction.system", () => { describe("Instruction.system", () => {
test("loads both project and global AGENTS.md when both exist", async () => { it.live("loads both project and global AGENTS.md when both exist", () =>
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* () { 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() const paths = yield* svc.systemPaths()
expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true) expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true)
const rules = yield* svc.system() const rules = yield* svc.system()
expect(rules).toHaveLength(2) expect(rules).toHaveLength(2)
expect(rules[0]).toBe( expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`)
`Instructions from: ${path.join(globalTmp.path, "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 }))
}),
) )
expect(rules[1]).toBe( })
`Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`,
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 }))
}),
) )
}),
),
),
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
}
}
})
})
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
}
})
}) })