|
|
@@ -0,0 +1,245 @@
|
|
|
+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 { testEffect } from "./lib/effect"
|
|
|
+import { settleTool, testModel } from "./lib/tool"
|
|
|
+
|
|
|
+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,
|
|
|
+ ReadTool.node,
|
|
|
+ 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<unknown>
|
|
|
+
|
|
|
+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,
|
|
|
+ messageID: SessionMessage.ID.create(),
|
|
|
+ timestamp: yield* DateTime.now,
|
|
|
+ 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 ${deepPath}, ${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 ${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("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 ${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()
|
|
|
+ })
|
|
|
+})
|