From 0bae38c0622dca3235ae4f88f0d8af68085c1eb8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 1 Apr 2026 22:22:51 -0400 Subject: [PATCH] refactor(instruction): migrate to Effect service pattern (#20542) --- packages/opencode/src/session/instruction.ts | 364 +++++++++++------- packages/opencode/src/session/prompt.ts | 24 +- packages/opencode/src/tool/read.ts | 4 +- .../opencode/test/session/instruction.test.ts | 142 ++++++- .../test/session/prompt-effect.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + 6 files changed, 367 insertions(+), 171 deletions(-) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 526e3f4b1c..02a536edd8 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 { Global } from "../global" -import { Filesystem } from "../util/filesystem" -import { Config } from "../config/config" -import { Instance } from "../project/instance" +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 { Instance } from "../project/instance" 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 { - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => []) +function extract(messages: MessageV2.WithParts[]) { + const paths = new Set() + 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) + } + } + } } - if (!Flag.OPENCODE_CONFIG_DIR) { - log.warn( - `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`, - ) - return [] - } - 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>(), - } - }) - - 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 + readonly systemPaths: () => Effect.Effect, AppFileSystem.Error> + readonly system: () => Effect.Effect + readonly find: (dir: string) => Effect.Effect + 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()("@opencode/Instruction") {} - export function clear(messageID: string) { - state().claims.delete(messageID) + export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const fs = yield* AppFileSystem.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) + + const state = yield* InstanceState.make( + Effect.fn("Instruction.state")(() => + Effect.succeed({ + // Track which instruction files have already been attached for a given assistant message. + claims: new Map>(), + }), + ), + ) + + const relative = Effect.fnUntraced(function* (instruction: string) { + 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[]))) + }) + + 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 paths = new Set() + + // 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 + } + } + } + + 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 systemPaths() { - const config = await Config.get() - const paths = new Set() - - 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 - } - } - } - - for (const file of globalFiles()) { - if (await Filesystem.exists(file)) { - paths.add(path.resolve(file)) - break - } - } - - 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)) - }) - } - } - - return paths + return runPromise((svc) => svc.systemPaths()) } 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 : "")), - ) - - return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean)) + return runPromise((svc) => svc.system()) } export function loaded(messages: MessageV2.WithParts[]) { - const paths = new Set() - 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 paths + return extract(messages) } 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 - } + return runPromise((svc) => svc.find(dir)) } - 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 }[] = [] - - const target = path.resolve(filepath) - let current = path.dirname(target) - const root = path.resolve(Instance.directory) - - while (current.startsWith(root) && current !== root) { - const found = await find(current) - - 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) - } - - 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 2c799b1100..fb4705603e 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 extends MessageV2.Part ? Omit & { id?: string } : never const assign = (part: Draft): 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 e5509fdfae..18520c2a6f 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 e0bf94a950..a8c25c6f0e 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 c1c60b1b87..8e4543c247 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 4b63039233..8e7f3c8c45 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), ), )