summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-01 22:22:51 -0400
committerGitHub <[email protected]>2026-04-01 22:22:51 -0400
commit0bae38c0622dca3235ae4f88f0d8af68085c1eb8 (patch)
treeb63ded38b51cab0833cb4d6aab1fbba1cd24a72b
parenta09b086729063be9b882bc174cb8eb16d6ecec9b (diff)
downloadopencode-0bae38c0622dca3235ae4f88f0d8af68085c1eb8.tar.gz
opencode-0bae38c0622dca3235ae4f88f0d8af68085c1eb8.zip
refactor(instruction): migrate to Effect service pattern (#20542)
-rw-r--r--packages/opencode/src/session/instruction.ts358
-rw-r--r--packages/opencode/src/session/prompt.ts24
-rw-r--r--packages/opencode/src/tool/read.ts4
-rw-r--r--packages/opencode/test/session/instruction.test.ts142
-rw-r--r--packages/opencode/test/session/prompt-effect.test.ts2
-rw-r--r--packages/opencode/test/session/snapshot-tool-race.test.ts2
6 files changed, 364 insertions, 168 deletions
diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts
index 526e3f4b1..02a536edd 100644
--- a/packages/opencode/src/session/instruction.ts
+++ b/packages/opencode/src/session/instruction.ts
@@ -1,13 +1,18 @@
-import path from "path"
import os from "os"
+import path from "path"
+import { Effect, Layer, ServiceMap } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
+import { Config } from "@/config/config"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
+import { Flag } from "@/flag/flag"
+import { AppFileSystem } from "@/filesystem"
+import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global"
-import { Filesystem } from "../util/filesystem"
-import { Config } from "../config/config"
import { Instance } from "../project/instance"
-import { Flag } from "@/flag/flag"
import { Log } from "../util/log"
-import { Glob } from "../util/glob"
import type { MessageV2 } from "./message-v2"
+import type { MessageID } from "./schema"
const log = Log.create({ service: "instruction" })
@@ -29,164 +34,233 @@ function globalFiles() {
return files
}
-async function resolveRelative(instruction: string): Promise<string[]> {
- if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
- return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
- }
- if (!Flag.OPENCODE_CONFIG_DIR) {
- log.warn(
- `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
- )
- return []
+function extract(messages: MessageV2.WithParts[]) {
+ const paths = new Set<string>()
+ for (const msg of messages) {
+ for (const part of msg.parts) {
+ if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
+ if (part.state.time.compacted) continue
+ const loaded = part.state.metadata?.loaded
+ if (!loaded || !Array.isArray(loaded)) continue
+ for (const p of loaded) {
+ if (typeof p === "string") paths.add(p)
+ }
+ }
+ }
}
- return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
+ return paths
}
-export namespace InstructionPrompt {
- const state = Instance.state(() => {
- return {
- claims: new Map<string, Set<string>>(),
- }
- })
-
- function isClaimed(messageID: string, filepath: string) {
- const claimed = state().claims.get(messageID)
- if (!claimed) return false
- return claimed.has(filepath)
+export namespace Instruction {
+ export interface Interface {
+ readonly clear: (messageID: MessageID) => Effect.Effect<void>
+ readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
+ readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
+ readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
+ readonly resolve: (
+ messages: MessageV2.WithParts[],
+ filepath: string,
+ messageID: MessageID,
+ ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
}
- function claim(messageID: string, filepath: string) {
- const current = state()
- let claimed = current.claims.get(messageID)
- if (!claimed) {
- claimed = new Set()
- current.claims.set(messageID, claimed)
- }
- claimed.add(filepath)
- }
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Instruction") {}
- export function clear(messageID: string) {
- state().claims.delete(messageID)
- }
+ 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))
- export async function systemPaths() {
- const config = await Config.get()
- const paths = new Set<string>()
-
- if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
- for (const file of FILES) {
- const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
- if (matches.length > 0) {
- matches.forEach((p) => {
- paths.add(path.resolve(p))
- })
- break
- }
- }
- }
+ 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>>(),
+ }),
+ ),
+ )
- for (const file of globalFiles()) {
- if (await Filesystem.exists(file)) {
- paths.add(path.resolve(file))
- break
- }
- }
+ const relative = Effect.fnUntraced(function* (instruction: string) {
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ return yield* fs
+ .globUp(instruction, Instance.directory, Instance.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 []
+ }
+ return yield* fs
+ .globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
+ .pipe(Effect.catch(() => Effect.succeed([] as string[])))
+ })
- if (config.instructions) {
- for (let instruction of config.instructions) {
- if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
- if (instruction.startsWith("~/")) {
- instruction = path.join(os.homedir(), instruction.slice(2))
- }
- const matches = path.isAbsolute(instruction)
- ? await Glob.scan(path.basename(instruction), {
- cwd: path.dirname(instruction),
- absolute: true,
- include: "file",
- }).catch(() => [])
- : await resolveRelative(instruction)
- matches.forEach((p) => {
- paths.add(path.resolve(p))
+ const read = Effect.fnUntraced(function* (filepath: string) {
+ return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
- }
- }
- return paths
- }
+ 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)
+ })
- export async function system() {
- const config = await Config.get()
- const paths = await systemPaths()
-
- const files = Array.from(paths).map(async (p) => {
- const content = await Filesystem.readText(p).catch(() => "")
- return content ? "Instructions from: " + p + "\n" + content : ""
- })
-
- const urls: string[] = []
- if (config.instructions) {
- for (const instruction of config.instructions) {
- if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
- urls.push(instruction)
- }
- }
- }
- const fetches = urls.map((url) =>
- fetch(url, { signal: AbortSignal.timeout(5000) })
- .then((res) => (res.ok ? res.text() : ""))
- .catch(() => "")
- .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
- )
+ const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
+ const s = yield* InstanceState.get(state)
+ s.claims.delete(messageID)
+ })
- return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
- }
+ const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
+ const config = yield* cfg.get()
+ const paths = new Set<string>()
- export function loaded(messages: MessageV2.WithParts[]) {
- const paths = new Set<string>()
- for (const msg of messages) {
- for (const part of msg.parts) {
- if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
- if (part.state.time.compacted) continue
- const loaded = part.state.metadata?.loaded
- if (!loaded || !Array.isArray(loaded)) continue
- for (const p of loaded) {
- if (typeof p === "string") paths.add(p)
+ // 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, Instance.directory, Instance.worktree)
+ if (matches.length > 0) {
+ matches.forEach((item) => paths.add(path.resolve(item)))
+ break
+ }
+ }
}
- }
- }
- }
- return paths
- }
- export async function find(dir: string) {
- for (const file of FILES) {
- const filepath = path.resolve(path.join(dir, file))
- if (await Filesystem.exists(filepath)) return filepath
- }
+ for (const file of globalFiles()) {
+ if (yield* fs.existsSafe(file)) {
+ paths.add(path.resolve(file))
+ 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)))
+ }
+ }
+
+ 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 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]}`] : [])),
+ ]
+ })
+
+ 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 target = path.resolve(filepath)
+ const root = path.resolve(Instance.directory)
+ 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}` })
+ }
+
+ 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(AppFileSystem.defaultLayer),
+ Layer.provide(FetchHttpClient.layer),
+ )
+
+ const { runPromise } = makeRuntime(Service, defaultLayer)
+
+ export function clear(messageID: MessageID) {
+ return runPromise((svc) => svc.clear(messageID))
}
- export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
- const system = await systemPaths()
- const already = loaded(messages)
- const results: { filepath: string; content: string }[] = []
+ export async function systemPaths() {
+ return runPromise((svc) => svc.systemPaths())
+ }
- const target = path.resolve(filepath)
- let current = path.dirname(target)
- const root = path.resolve(Instance.directory)
+ export async function system() {
+ return runPromise((svc) => svc.system())
+ }
- while (current.startsWith(root) && current !== root) {
- const found = await find(current)
+ export function loaded(messages: MessageV2.WithParts[]) {
+ return extract(messages)
+ }
- if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
- claim(messageID, found)
- const content = await Filesystem.readText(found).catch(() => undefined)
- if (content) {
- results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
- }
- }
- current = path.dirname(current)
- }
+ export async function find(dir: string) {
+ return runPromise((svc) => svc.find(dir))
+ }
- return results
+ export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
+ return runPromise((svc) => svc.resolve(messages, filepath, messageID))
}
}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 2c799b110..fb4705603 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -15,7 +15,7 @@ import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system"
-import { InstructionPrompt } from "./instruction"
+import { Instruction } from "./instruction"
import { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
@@ -100,6 +100,7 @@ export namespace SessionPrompt {
const truncate = yield* Truncate.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const scope = yield* Scope.Scope
+ const instruction = yield* Instruction.Service
const state = yield* InstanceState.make(
Effect.fn("SessionPrompt.state")(function* () {
@@ -979,7 +980,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
variant,
}
- yield* Effect.addFinalizer(() => InstanceState.withALS(() => InstructionPrompt.clear(info.id)))
+ yield* Effect.addFinalizer(() =>
+ InstanceState.withALS(() => instruction.clear(info.id)).pipe(Effect.flatMap((x) => x)),
+ )
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
@@ -1486,14 +1489,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
- const [skills, env, instructions, modelMsgs] = yield* Effect.promise(() =>
- Promise.all([
- SystemPrompt.skills(agent),
- SystemPrompt.environment(model),
- InstructionPrompt.system(),
- MessageV2.toModelMessages(msgs, model),
- ]),
- )
+ const [skills, env, instructions, modelMsgs] = yield* Effect.all([
+ Effect.promise(() => SystemPrompt.skills(agent)),
+ Effect.promise(() => SystemPrompt.environment(model)),
+ instruction.system().pipe(Effect.orDie),
+ Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
+ ])
const system = [...env, ...(skills ? [skills] : []), ...instructions]
const format = lastUser.format ?? { type: "text" as const }
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
@@ -1542,7 +1543,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}),
Effect.fnUntraced(function* (exit) {
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
- yield* InstanceState.withALS(() => InstructionPrompt.clear(handle.message.id))
+ yield* InstanceState.withALS(() => instruction.clear(handle.message.id)).pipe(Effect.flatMap((x) => x))
}),
)
if (outcome === "break") break
@@ -1712,6 +1713,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.layer),
Layer.provide(Provider.defaultLayer),
+ Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index e5509fdfa..18520c2a6 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -9,7 +9,7 @@ import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
-import { InstructionPrompt } from "../session/instruction"
+import { Instruction } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
const DEFAULT_READ_LIMIT = 2000
@@ -118,7 +118,7 @@ export const ReadTool = Tool.define("read", {
}
}
- const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
+ const instructions = await Instruction.resolve(ctx.messages, filepath, ctx.messageID)
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
const mime = Filesystem.mimeType(filepath)
diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts
index e0bf94a95..a8c25c6f0 100644
--- a/packages/opencode/test/session/instruction.test.ts
+++ b/packages/opencode/test/session/instruction.test.ts
@@ -1,11 +1,53 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
-import { InstructionPrompt } from "../../src/session/instruction"
+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 "../../src/global"
import { tmpdir } from "../fixture/fixture"
-describe("InstructionPrompt.resolve", () => {
+function loaded(filepath: string): MessageV2.WithParts[] {
+ const sessionID = SessionID.make("session-loaded-1")
+ const messageID = MessageID.make("message-loaded-1")
+
+ return [
+ {
+ info: {
+ id: messageID,
+ sessionID,
+ role: "user",
+ time: { created: 0 },
+ agent: "build",
+ model: {
+ providerID: ProviderID.make("anthropic"),
+ modelID: ModelID.make("claude-sonnet-4-20250514"),
+ },
+ },
+ parts: [
+ {
+ id: PartID.make("part-loaded-1"),
+ messageID,
+ sessionID,
+ type: "tool",
+ callID: "call-loaded-1",
+ tool: "read",
+ state: {
+ status: "completed",
+ input: {},
+ output: "done",
+ title: "Read",
+ metadata: { loaded: [filepath] },
+ time: { start: 0, end: 1 },
+ },
+ },
+ ],
+ },
+ ]
+}
+
+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) => {
@@ -16,10 +58,14 @@ describe("InstructionPrompt.resolve", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const system = await InstructionPrompt.systemPaths()
+ const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
- const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"), "test-message-1")
+ const results = await Instruction.resolve(
+ [],
+ path.join(tmp.path, "src", "file.ts"),
+ MessageID.make("message-test-1"),
+ )
expect(results).toEqual([])
},
})
@@ -35,13 +81,13 @@ describe("InstructionPrompt.resolve", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const system = await InstructionPrompt.systemPaths()
+ const system = await Instruction.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
- const results = await InstructionPrompt.resolve(
+ const results = await Instruction.resolve(
[],
path.join(tmp.path, "subdir", "nested", "file.ts"),
- "test-message-2",
+ MessageID.make("message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
@@ -60,17 +106,87 @@ describe("InstructionPrompt.resolve", () => {
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
- const system = await InstructionPrompt.systemPaths()
+ const system = await Instruction.systemPaths()
expect(system.has(filepath)).toBe(false)
- const results = await InstructionPrompt.resolve([], filepath, "test-message-2")
+ const results = await Instruction.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: async () => {
+ const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
+ const id = MessageID.make("message-claim-1")
+
+ const first = await Instruction.resolve([], filepath, id)
+ const second = await Instruction.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: async () => {
+ const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
+ const id = MessageID.make("message-claim-2")
+
+ const first = await Instruction.resolve([], filepath, id)
+ await Instruction.clear(id)
+ const second = await Instruction.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: async () => {
+ 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 = await Instruction.resolve(loaded(agents), filepath, id)
+
expect(results).toEqual([])
},
})
})
+
+ test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
})
-describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
+describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
let originalConfigDir: string | undefined
beforeEach(() => {
@@ -106,7 +222,7 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
- const paths = await InstructionPrompt.systemPaths()
+ const paths = await Instruction.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
},
@@ -133,7 +249,7 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
- const paths = await InstructionPrompt.systemPaths()
+ const paths = await Instruction.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
@@ -159,7 +275,7 @@ describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
- const paths = await InstructionPrompt.systemPaths()
+ const paths = await Instruction.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
})
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
index c1c60b1b8..8e4543c24 100644
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ b/packages/opencode/test/session/prompt-effect.test.ts
@@ -21,6 +21,7 @@ import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { AppFileSystem } from "../../src/filesystem"
import { SessionCompaction } from "../../src/session/compaction"
+import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -171,6 +172,7 @@ function makeHttp() {
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
+ Layer.provide(Instruction.defaultLayer),
Layer.provideMerge(deps),
),
)
diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts
index 4b6303923..8e7f3c8c4 100644
--- a/packages/opencode/test/session/snapshot-tool-race.test.ts
+++ b/packages/opencode/test/session/snapshot-tool-race.test.ts
@@ -39,6 +39,7 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { SessionCompaction } from "../../src/session/compaction"
+import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
import { SessionStatus } from "../../src/session/status"
import { Shell } from "../../src/shell/shell"
@@ -134,6 +135,7 @@ function makeHttp() {
Layer.provideMerge(proc),
Layer.provideMerge(registry),
Layer.provideMerge(trunc),
+ Layer.provide(Instruction.defaultLayer),
Layer.provideMerge(deps),
),
)