Sfoglia il codice sorgente

refactor(instruction): migrate to Effect service pattern (#20542)

Kit Langton 5 mesi fa
parent
commit
0bae38c062

+ 216 - 142
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))
   }
 }

+ 13 - 11
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),

+ 2 - 2
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)

+ 129 - 13
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)
         },
       })

+ 2 - 0
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),
     ),
   )

+ 2 - 0
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),
     ),
   )