import { afterEach, expect, test } from "bun:test"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, PubSub, Schema, Stream } from "effect"; import { Agent, Model, Provider } from "@opencode/plugin"; import { Session } from "@opencode/schema/session"; import { SessionMessage } from "@opencode/schema/session-message"; import { Tool } from "@opencode/schema/tool"; import { AbsolutePath } from "@opencode/schema/schema"; import type { Context } from "@opencode/plugin/effect/plugin"; import type { AgentEditor } from "@opencode/plugin/effect/agent"; import type { ToolEditor } from "@opencode/plugin/effect/tool"; import type { SessionHooks } from "@opencode/plugin/effect/session"; import { z } from "zod"; import plugin from "../src/index.js"; import { command } from "../src/herdr.js"; // This is a composite plugin host fixture, not OpenCode's native executor/job runtime. // The real Promise adapter validates API payloads; registries replay from fresh drafts. const directories: string[] = []; afterEach(async () => { for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); }); interface LocationInstance { agents: Map>>; admit: () => Effect.Effect; } const scenario = async () => { const directory = await mkdtemp(join(tmpdir(), "composite-")); directories.push(directory); const root = join(directory, "root"); const destination = join(directory, "child"); const bin = join(directory, "bin"); await mkdir(root); await mkdir(bin); await mkdir(join(directory, "store")); const signal = new AbortController().signal; await command(["git", "init", "-b", "main"], root, signal); await command( [ "git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-m", "fixture", ], root, signal, ); // Fake Herdr, real isolated Git operations. No real Herdr server is contacted. const inventory = (path: string, id: string) => JSON.stringify({ result: { source: { repo_key: join(root, ".git"), repo_root: root, source_checkout_path: root, source_workspace_id: "w1", }, worktrees: [ { path: root, is_linked_worktree: false, is_bare: false, open_workspace_id: "w1", }, ...(path === root ? [] : [ { path, is_linked_worktree: true, is_bare: false, open_workspace_id: id, }, ]), ], }, }); const cli = join(bin, "herdr"); await writeFile( cli, `#!/bin/sh\ncase "$1 $2" in\n"worktree create") git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '${JSON.stringify({ result: { workspaces: [{ workspace_id: "w1" }] } })}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`, ); await chmod(cli, 0o755); const sessions = new Map(); const parent = Schema.decodeUnknownSync(Session.Info)({ id: "ses_parent", projectID: "fixture", agent: "multi-work", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, updated: 0 }, location: { directory: root }, }); sessions.set(parent.id, parent); const admissions: string[] = []; return { directory, root, destination, bin, sessions, instances: new Map(), admissions, }; }; function host(fixture: Awaited>, directory: string) { const location = { directory }; const agents = new Map>>(); const transforms: ((editor: AgentEditor) => void)[] = []; const prompts: ((event: SessionHooks["prompt"]) => Effect.Effect)[] = []; const tools = new Map(); const toolTransforms: ((editor: ToolEditor) => void)[] = []; let lateBuild: Agent.Info["permissions"] = []; let lateMulti: Agent.Info["permissions"] = []; let reloads = 0; const updates = Effect.runSync( PubSub.unbounded>(), ); const registration = Effect.succeed({ dispose: Effect.void }); const editAgents: AgentEditor = { get: (id) => agents.get(id), list: () => [...agents.values()], default() {}, remove: (id) => { agents.delete(id); }, update(id, update) { const draft = agents.get(id) ?? { ...Agent.Info.default(Agent.ID.make(id)), permissions: [], }; update(draft); agents.set(id, draft); }, }; const replay = () => { agents.clear(); agents.set("build", Agent.Info.default(Agent.ID.make("build"))); for (const transform of transforms) transform(editAgents); // Match the ordering under review: configuration runs AFTER both plugin transforms. editAgents.update("build", (agent) => { agent.permissions.push(...lateBuild); }); if (agents.has("multi-work")) editAgents.update("multi-work", (agent) => { agent.permissions.push(...lateMulti); }); }; const reload = () => Effect.gen(function* () { replay(); reloads++; yield* PubSub.publish( updates, Schema.decodeUnknownSync(Agent.Event.Updated)({ id: `evt_${reloads}`, created: Date.now(), type: "agent.updated", location, data: {}, }), ); }); const editTools: ToolEditor = { get: (id) => { const tool = tools.get(id); return tool && { ...tool, id }; }, list: () => [...tools.values()].map((tool) => ({ ...tool, id: tool.name })), add: (tool) => { tools.set(tool.name, tool); }, namespace() {}, remove: (id) => { tools.delete(id); }, update(id, update) { const tool = tools.get(id); if (tool) { const draft = { ...tool }; update(draft); tools.set(id, draft); } }, }; const native: Tool.Info = { name: "subagent", description: "fixture executor", input: z.object({}), execute: (raw, call) => Effect.gen(function* () { const input = z.object({ sessionID: z.string().optional() }).parse(raw); if (!input.sessionID) { const parent = fixture.sessions.get(call.sessionID)!; fixture.sessions.set("ses_child", { ...parent, id: Session.ID.make("ses_child"), parentID: parent.id, agent: Agent.ID.make("coordinate"), }); } yield* call.progress({ sessionID: "ses_child", status: "running" }); const child = fixture.sessions.get("ses_child")!; yield* fixture.instances.get(child.location.directory)!.admit(); fixture.admissions.push(child.location.directory); return { content: "fixture result", metadata: { sessionID: "ses_child" } }; }), }; const store = (key: string) => join(fixture.directory, "store", `${encodeURIComponent(key)}.json`); const providerID = Provider.ID.make("fixture"); const models = ["gpt-6-luna", "gpt-6-sol", "gpt-6-astra"].map((id) => ({ ...Model.Info.default(providerID, Model.ID.make(id)), enabled: true, })); // The adapter reads these domain objects at construction; unimplemented operations // stay undefined and fail loudly if the plugin starts using them. const context = { options: { provider: "fixture" }, location, app: { version: "2.0.4-fixture" }, command: {}, experimental: { terminal: {} }, generate: {}, provider: {}, permission: {}, plugin: {}, reference: {}, skill: {}, vcs: { branch: {} }, websearch: {}, worktree: {}, model: { list: () => Effect.succeed({ location, data: models }) }, event: { subscribe: () => Stream.fromPubSub(updates) }, integration: { connect: {}, oauth: {}, command: {}, connection: { active: () => Effect.succeed(undefined), resolve: () => Effect.succeed(undefined), }, }, rpc: { register: () => Effect.succeed({ dispose: Effect.void, events: { emit: () => Effect.void } }), }, mcp: { transform: (callback: (editor: { get: () => undefined }) => void) => Effect.sync(() => callback({ get: () => undefined })).pipe( Effect.andThen(registration), ), }, storage: { get: (key: string) => Effect.promise(async () => { try { return JSON.parse(await readFile(store(key), "utf8")); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; throw error; } }), set: (key: string, value: Schema.Json) => Effect.promise(() => writeFile(store(key), JSON.stringify(value))), }, agent: { list: (input?: { location?: { directory?: string } }) => Effect.sync(() => ({ location, data: [ ...( fixture.instances.get(input?.location?.directory ?? directory) ?.agents ?? agents ).values(), ], })), get: ({ agentID }: { agentID: string }) => Effect.sync(() => ({ location, data: agents.get(agentID)! })), transform: (callback: (editor: AgentEditor) => void) => Effect.sync(() => { transforms.push(callback); replay(); }).pipe(Effect.andThen(registration)), reload, }, tool: { transform: (callback: (editor: ToolEditor) => void) => Effect.sync(() => { toolTransforms.push(callback); tools.clear(); tools.set(native.name, native); for (const transform of toolTransforms) transform(editTools); }).pipe(Effect.andThen(registration)), hook: () => registration, }, session: { get: ({ sessionID }: { sessionID: string }) => Effect.sync(() => fixture.sessions.get(sessionID)!), move: ({ sessionID, directory }: { sessionID: string; directory: string }) => Effect.sync(() => { const child = fixture.sessions.get(sessionID)!; fixture.sessions.set(sessionID, { ...child, location: { directory: AbsolutePath.make(directory) }, }); }), hook: ( name: string, callback: (event: SessionHooks["prompt"]) => Effect.Effect, ) => Effect.sync(() => { if (name === "prompt") prompts.push(callback); }).pipe(Effect.andThen(registration)), }, }; // SAFETY: Exercise the exported composite through the real Promise adapter. The // fixture implements precisely the accessed services; unused operations fail. const install = plugin.effect as ( context: Pick, ) => ReturnType; const call: Tool.Context = { sessionID: Session.ID.make("ses_parent"), agent: Agent.ID.make("multi-work"), messageID: SessionMessage.ID.make("msg_fixture"), id: Tool.CallID.make("call_fixture"), progress: () => Effect.void, }; const result = { agents, install: install(context), replay: reload, get reloads() { return reloads; }, configure(build: Agent.Info["permissions"], multi: Agent.Info["permissions"] = []) { lateBuild = build; lateMulti = multi; return reload(); }, resume: () => tools.get("subagent")!.execute( { agent: "coordinate", sessionID: "ses_child", description: "resume", prompt: "continue", }, call, ), create: () => tools.get("subagent")!.execute( { agent: "coordinate", worktree: { base: "main" }, description: "new", prompt: "work", }, call, ), admit: () => Effect.forEach(prompts, (hook) => hook({ sessionID: Session.ID.make("ses_child"), messageID: SessionMessage.ID.make("msg_child"), prompt: { text: "continue" }, delivery: "steer", metadata: {}, }), ), }; fixture.instances.set(directory, result); return result; } test("exported composite inherits late build permissions, remains stable over two replays, and preserves later overrides", async () => { const f = await scenario(); const oldPath = process.env.PATH; process.env.PATH = `${f.bin}:${oldPath}`; try { const h = host(f, f.root); const destination = host(f, f.destination); await Effect.runPromise( Effect.scoped( Effect.gen(function* () { yield* h.install; yield* destination.install; expect(h.agents.has("multi-work")).toBe(true); expect(h.agents.get("multi-work")?.model).toEqual( h.agents.get("coordinate")?.model, ); expect(h.agents.get("multi-work")?.request).toEqual( h.agents.get("coordinate")?.request, ); const before = h.reloads; yield* h.configure([ { action: "herdr_worktree", resource: "*", effect: "deny" }, ]); yield* Effect.gen(function* () { while (h.reloads < before + 2) yield* Effect.sleep("1 millis"); }).pipe(Effect.timeout("2 seconds")); const inherited = () => h.agents .get("multi-work")! .permissions.filter((rule) => rule.action === "herdr_worktree"); const expected = [...inherited()]; expect(expected.at(-1)?.effect).toBe("deny"); for (let replay = 0; replay < 2; replay++) { yield* h.replay(); expect(inherited()).toEqual(expected); expect((yield* Effect.exit(h.create()))._tag).toBe("Failure"); } yield* h.configure( [{ action: "herdr_worktree", resource: "*", effect: "deny" }], [{ action: "herdr_worktree", resource: "*", effect: "allow" }], ); expect((yield* h.create()).content).toContain("fixture result"); const parent = f.sessions.get("ses_parent")!; f.sessions.set(parent.id, { ...parent, permissions: [{ action: "herdr_worktree", resource: "*", effect: "deny" }], }); expect((yield* Effect.exit(h.resume()))._tag).toBe("Failure"); }), ), ); } finally { process.env.PATH = oldPath; } }, 15_000); test("separate exported composite instances admit a disk-bound child and resume after plugin scopes restart", async () => { const f = await scenario(); const oldPath = process.env.PATH; process.env.PATH = `${f.bin}:${oldPath}`; try { for (let restart = 0; restart < 2; restart++) { const root = host(f, f.root); const destination = host(f, f.destination); await Effect.runPromise( Effect.scoped( Effect.gen(function* () { yield* root.install; yield* destination.install; expect(root.agents.has("multi-work")).toBe(true); expect(destination.agents.has("multi-work")).toBe(false); if (restart === 0) yield* root.create(); yield* destination.admit(); expect((yield* root.resume()).content).toContain(f.destination); const child = f.sessions.get("ses_child")!; f.sessions.set(child.id, { ...child, location: { directory: AbsolutePath.make(f.root) }, }); expect((yield* Effect.exit(destination.admit()))._tag).toBe("Failure"); f.sessions.set(child.id, child); }), ), ); } expect(f.admissions).toEqual([f.destination, f.destination, f.destination]); } finally { process.env.PATH = oldPath; } }, 15_000);