import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { DateTime, Effect, Layer } from "effect" import { Message, Model } from "@opencode-ai/llm" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Config } from "@opencode-ai/core/config" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Image } from "@opencode-ai/core/image" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { ModelV2 } from "@opencode-ai/core/model" import { PermissionV2 } from "@opencode-ai/core/permission" import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { ReadTool } from "@opencode-ai/core/tool/read" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInstructions } from "@opencode-ai/core/session/instructions" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionV2 } from "@opencode-ai/core/session" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" import { ToolHooks } from "@opencode-ai/core/tool/hooks" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { tempLocationLayer } from "./fixture/location" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" import { registerToolPlugin, settleTool, testModel } from "./lib/tool" const readToolNode = makeLocationNode({ name: "test/read-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)), deps: [ ToolRegistry.toolsNode, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node, SessionInstructions.node, FSUtil.node, Location.node, ], }) const projects = Layer.succeed( ProjectV2.Service, ProjectV2.Service.of({ resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), directories: () => Effect.succeed([]), commit: () => Effect.void, }), ) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ assert: () => Effect.void, ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), get: () => Effect.die("unused"), forSession: () => Effect.die("unused"), list: () => Effect.die("unused"), }), ) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) const testLayer = AppNodeBuilder.build( LayerNode.group([ Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node, Location.node, FSUtil.node, LocationMutation.node, ReadToolFileSystem.node, readToolNode, ToolRegistry.node, ToolRegistry.toolsNode, ToolHooks.node, SessionInstructions.node, Global.node, ToolOutputStore.node, Image.node, ]), [ [ProjectV2.node, projects], [SessionExecution.node, SessionExecution.noopLayer], [Location.node, tempLocationLayer], [PermissionV2.node, permission], [Config.node, config], [Image.node, imageLayer], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], ], ) as unknown as Layer.Layer const it = testEffect(testLayer) const identity = { agent: AgentV2.ID.make("build"), assistantMessageID: SessionMessage.ID.make("msg_nearby"), } const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({ sessionID, ...identity, call: { type: "tool-call", id, name: "read", input: { path: readPath } }, }) const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content)) const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) const synthetics = (sessionID: SessionV2.ID) => Effect.gen(function* () { const store = yield* SessionStore.Service return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic") }) // Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn // after the Location layer was reopened (in-memory set empty). const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) => Effect.gen(function* () { const events = yield* EventV2.Service yield* events.publish(SessionEvent.Synthetic, { sessionID, text: `Instructions from: ${paths[0]}\nprior`, description: `Loaded ${paths[0]}`, metadata: { instruction: { paths } }, }) }) describe("SessionInstructions", () => { it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () => Effect.gen(function* () { const location = yield* Location.Service const dir = location.directory const rootPath = path.resolve(dir, "AGENTS.md") const subPath = path.resolve(dir, "sub", "AGENTS.md") const deepPath = path.resolve(dir, "sub", "deep", "AGENTS.md") const otherPath = path.resolve(dir, "sub", "other", "AGENTS.md") yield* mkdir(path.dirname(deepPath)) yield* mkdir(path.dirname(otherPath)) yield* writeAgents(rootPath, "root-instructions") yield* writeAgents(subPath, "sub-instructions") yield* writeAgents(deepPath, "deep-instructions") yield* writeAgents(otherPath, "other-instructions") yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content")) yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2")) const session = yield* SessionV2.Service const registry = yield* ToolRegistry.Service const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id // A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but // excluding the Location root (already supplied by the core/instructions baseline). yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt")) const firstInjected = yield* synthetics(sessionID) expect(firstInjected).toHaveLength(1) expect(firstInjected[0]!.text).toBe( `Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`, ) expect(firstInjected[0]!.description).toBe( `Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`, ) // The synthetic's metadata carries the durable dedup ledger. expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } }) expect(firstInjected[0]!.text).not.toContain("root-instructions") // A sibling read under sub/other discovers only the new AGENTS.md; sub is already // injected for this session so it is not re-emitted, and the root is still excluded. yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt")) const secondInjected = yield* synthetics(sessionID) expect(secondInjected).toHaveLength(2) expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`) expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`) expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } }) expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false) }), ) it.effect("does not re-inject paths already recorded in durable session history", () => Effect.gen(function* () { const location = yield* Location.Service const dir = location.directory const rootPath = path.resolve(dir, "AGENTS.md") const subPath = path.resolve(dir, "sub", "AGENTS.md") yield* mkdir(path.resolve(dir, "sub")) yield* writeAgents(rootPath, "root-instructions") yield* writeAgents(subPath, "sub-instructions") yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content")) const session = yield* SessionV2.Service const registry = yield* ToolRegistry.Service const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id // Seed the durable history with a prior synthetic that already claims sub's AGENTS.md // via the instruction metadata ledger. yield* seedSynthetic(sessionID, [subPath]) expect(yield* synthetics(sessionID)).toHaveLength(1) yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt")) // The durable claim on the prior synthetic prevents re-injection; no new synthetic. expect(yield* synthetics(sessionID)).toHaveLength(1) }), ) it.effect( "discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () => Effect.gen(function* () { const location = yield* Location.Service const dir = location.directory const rootPath = path.resolve(dir, "AGENTS.md") const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md") yield* mkdir(path.resolve(dir, "packages", "foo")) yield* writeAgents(rootPath, "root-instructions") yield* writeAgents(pkgPath, "pkg-instructions") yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content")) const session = yield* SessionV2.Service const registry = yield* ToolRegistry.Service const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding // the Location root (already supplied by the core/instructions baseline). yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo")) const firstInjected = yield* synthetics(sessionID) expect(firstInjected).toHaveLength(1) expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`) expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`) expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } }) expect(firstInjected[0]!.text).not.toContain("root-instructions") // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is // already injected for this session, so nothing new is emitted. yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt")) expect(yield* synthetics(sessionID)).toHaveLength(1) }), ) it.effect("listing the Location root directory injects no instructions", () => Effect.gen(function* () { const location = yield* Location.Service const dir = location.directory const rootPath = path.resolve(dir, "AGENTS.md") const subPath = path.resolve(dir, "sub", "AGENTS.md") yield* mkdir(path.resolve(dir, "sub")) yield* writeAgents(rootPath, "root-instructions") yield* writeAgents(subPath, "sub-instructions") const session = yield* SessionV2.Service const registry = yield* ToolRegistry.Service const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id // The walk starts and stops at the Location root: the root AGENTS.md is searched but // dropped by the dirname filter, and up() only walks upward so nested dirs are unseen. yield* settleTool(registry, readCall(sessionID, "call-root-list", ".")) expect(yield* synthetics(sessionID)).toHaveLength(0) }), ) it.effect("loads instructions directly without a read", () => Effect.gen(function* () { const location = yield* Location.Service const dir = location.directory const subPath = path.resolve(dir, "sub", "AGENTS.md") yield* mkdir(path.resolve(dir, "sub")) yield* writeAgents(subPath, "sub-instructions") const session = yield* SessionV2.Service const sessionInstructions = yield* SessionInstructions.Service const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id yield* sessionInstructions.load({ sessionID, paths: [subPath] }) const injected = yield* synthetics(sessionID) expect(injected).toHaveLength(1) expect(injected[0]!.text).toBe(`Instructions from: ${subPath}\nsub-instructions`) expect(injected[0]!.description).toBe(`Loaded ${path.relative(dir, subPath)}`) expect(injected[0]!.metadata).toEqual({ instruction: { paths: [subPath] } }) }), ) test("toLLMMessages does not forward synthetic metadata to the provider", () => { const created = DateTime.makeUnsafe(0) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) const synthetic = SessionMessage.Synthetic.make({ id: SessionMessage.ID.make("msg_synthetic"), type: "synthetic", sessionID: SessionV2.ID.make("ses_test"), text: "Instructions from: /repo/sub/AGENTS.md\ncontent", description: "Loaded /repo/sub/AGENTS.md", metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } }, time: { created }, }) const messages = toLLMMessages([synthetic], model) expect(messages).toHaveLength(1) expect(messages[0]!.role).toBe("user") expect(messages[0]!.content).toEqual([{ type: "text", text: "Instructions from: /repo/sub/AGENTS.md\ncontent" }]) // Metadata is bookkeeping for the dedup ledger; the model must not see it. expect(messages[0]!.metadata).toBeUndefined() }) })