| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- import { describe, expect, test } from "bun:test"
- import fs from "fs/promises"
- import path from "path"
- import { DateTime, Effect, Layer, Stream } from "effect"
- import { Message } from "@opencode-ai/ai"
- import { Agent } from "@opencode-ai/core/agent"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { Config } from "@opencode-ai/core/config"
- import { Database } from "@opencode-ai/core/database/database"
- import { Bus } from "@opencode-ai/core/bus"
- import { FSUtil } from "@opencode-ai/util/fs-util"
- import { Global } from "@opencode-ai/util/global"
- import { Image } from "@opencode-ai/core/image"
- import { Location } from "@opencode-ai/core/location"
- import { LocationMutation } from "@opencode-ai/core/location-mutation"
- import { Model } from "@opencode-ai/core/model"
- import { Permission } from "@opencode-ai/core/permission"
- import { Project } from "@opencode-ai/core/project"
- import { Provider } from "@opencode-ai/core/provider"
- import { ReadTool } from "@opencode-ai/core/tool/plugin/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 { Session } from "@opencode-ai/core/session"
- import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
- import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
- import { Tool } from "@opencode-ai/core/tool"
- import { tempLocationLayer } from "./fixture/location"
- import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
- import { testEffect } from "./lib/effect"
- import { executeTool, registerToolPlugin } from "./lib/tool"
- const readToolNode = makeLocationNode({
- name: "test/read-tool-plugin",
- layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
- deps: [
- Tool.node,
- ReadToolFileSystem.node,
- LocationMutation.node,
- Image.node,
- Permission.node,
- SessionInstructions.node,
- FSUtil.node,
- Location.node,
- ],
- })
- const projects = Layer.succeed(
- Project.Service,
- Project.Service.of({
- list: () => Effect.succeed([]),
- resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
- directories: () => Effect.succeed([]),
- commit: () => Effect.void,
- }),
- )
- const permission = Layer.succeed(
- Permission.Service,
- Permission.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 = Config.testLayer()
- const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
- const testLayer = AppNodeBuilder.build(
- LayerNode.group([
- Database.node,
- Bus.node,
- SessionProjector.node,
- SessionStore.node,
- Session.node,
- Location.node,
- FSUtil.node,
- LocationMutation.node,
- ReadToolFileSystem.node,
- readToolNode,
- Tool.node,
- Tool.node,
- PluginHooks.node,
- SessionInstructions.node,
- Global.node,
- Image.node,
- ]),
- [
- [Project.node, projects],
- [SessionExecution.node, SessionExecution.noopLayer],
- [Location.node, tempLocationLayer],
- [Permission.node, permission],
- [Config.node, config],
- [Image.node, imageLayer],
- ],
- ) as unknown as Layer.Layer<unknown>
- const it = testEffect(testLayer)
- const identity = {
- agent: Agent.ID.make("build"),
- messageID: SessionMessage.ID.make("msg_nearby"),
- }
- const readCall = (sessionID: Session.ID, id: string, readPath: string): Parameters<Tool.Snapshot["execute"]>[0] => ({
- 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: Session.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: Session.ID, paths: string[]) =>
- Effect.gen(function* () {
- const bus = yield* Bus.Service
- yield* bus.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* Session.Service
- const registry = yield* Tool.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 core initial instructions).
- yield* executeTool(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* executeTool(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* Session.Service
- const registry = yield* Tool.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* executeTool(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* Session.Service
- const registry = yield* Tool.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 core initial instructions).
- yield* executeTool(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* executeTool(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* Session.Service
- const registry = yield* Tool.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* executeTool(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* Session.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.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
- const synthetic = SessionMessage.Synthetic.make({
- id: SessionMessage.ID.make("msg_synthetic"),
- type: "synthetic",
- 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()
- })
- })
|