import { afterEach, expect, test } from "bun:test"; import { Agent, Model, Provider, type Plugin } from "@opencode/plugin"; import type { AgentEditor } from "@opencode/plugin/promise/agent"; import type { ToolEditor } from "@opencode/plugin/promise/tool"; import type { SessionContext } from "@opencode/plugin/promise/session"; import type { SkillEditor } from "@opencode/plugin/promise/skill"; import type { DeepMutable } from "@opencode/plugin/promise/types"; import type { Skill } from "@opencode/schema/skill"; import type { ModelInfo } from "@opencode/client"; import plugin from "../src/agents.js"; import { model } from "../src/model.js"; import { z } from "zod"; import { AgentsRpc } from "../src/agents-rpc.js"; const cleanups: Plugin.Cleanup[] = []; afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); }); const models = ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => ({ ...Model.Info.default(Provider.ID.make("test"), Model.ID.make(id)), variants: ["low", "high"].map((effort) => ({ id: Model.VariantID.make(effort), settings: { reasoningEffort: effort }, })), })); const agents = { "fast-lo": "test/gpt-6-luna#low", "fast-hi": "test/gpt-6-luna#high", "good-lo": "test/gpt-6.1-sol#low", "good-hi": "test/gpt-6.1-sol#high", "deep-lo": "test/gpt-6-astra#low", "deep-hi": "test/gpt-6-astra#high", }; async function host( catalog: ModelInfo[] = models, options: Plugin.Context["options"] = { agents }, existingSkill?: Skill.Info, ) { const agents = new Map>(); let build: DeepMutable = Agent.Info.default(Agent.ID.make("build")); const skills = new Map( existingSkill ? [[existingSkill.id, existingSkill]] : [], ); let transform: (editor: AgentEditor) => void; let contextHook: (event: Pick) => Promise | void; let promptHook: (event: { sessionID: string }) => Promise | void; let before: (event: { tool: string; agent: string; input: unknown }) => Promise | void; let parent = "build"; let child = "fast-lo"; let tools: string[] = []; let toolTransform: (editor: ToolEditor) => void; let permissions: () => Promise; let controller: ReadableStreamDefaultController; const stream = new ReadableStream({ start(value) { controller = value; }, }); const registration = { async dispose() {} }; const replay = () => { agents.clear(); agents.set("build", structuredClone(build)); transform?.({ get: (id) => agents.get(id), list: () => [...agents.values()], default() {}, remove: (id) => { agents.delete(id); }, update(id, update) { const draft = agents.get(id) ?? structuredClone(Agent.Info.default(Agent.ID.make(id))); update(draft); agents.set(id, draft); }, }); }; const replayTools = () => toolTransform?.({ list: () => tools.map((id) => ({ id, name: id, description: id, input: z.object({}), async execute() { return { content: "" }; }, })), get: () => undefined, namespace() {}, add() { throw new Error("Agents must not register custom tools"); }, update() {}, remove() {}, }); const context = { location: { directory: "/fixture" }, options, plugin: { async list() { return { data: [] }; }, }, model: { async list() { return { data: catalog }; }, }, agent: { async list() { return { data: [...agents.values()] }; }, async reload() { replay(); }, async transform(callback: typeof transform) { transform = callback; replay(); return registration; }, }, skill: { async transform(callback: (editor: SkillEditor) => void) { callback({ get: (id) => skills.get(id), add: (skill) => { skills.set(skill.id, skill); }, list: () => [...skills.values()], update() {}, remove() {}, }); return registration; }, }, rpc: { async register( _definition: typeof AgentsRpc, handlers: { permissions: typeof permissions }, ) { permissions = handlers.permissions; return registration; }, }, event: { subscribe({ signal }: { signal: AbortSignal }) { signal.addEventListener("abort", () => controller.close(), { once: true }); return stream; }, }, tool: { async transform(callback: typeof toolTransform) { toolTransform = callback; replayTools(); return registration; }, async hook(_name: string, callback: typeof before) { before = callback; return registration; }, }, session: { async hook(name: string, callback: typeof contextHook & typeof promptHook) { if (name === "context") contextHook = callback; if (name === "prompt") promptHook = callback; return registration; }, async get({ sessionID }: { sessionID: string }) { return sessionID === "child" ? { agent: child, parentID: "parent" } : { agent: parent }; }, }, }; // SAFETY: The fixture provides precisely the native domains used by Agents; // accessing storage, MCP registration, or quota dependencies fails loudly. const setup = plugin.setup as ( context: Pick, ) => ReturnType; replay(); const cleanup = await setup(context); if (cleanup) cleanups.push(cleanup); const flush = async () => { await Bun.sleep(10); }; return { agents, skills, permissions: () => permissions(), async configure(rules: Agent.Info["permissions"]) { build = { ...build, permissions: rules.map((rule) => ({ ...rule })) }; replay(); controller.enqueue({ type: "agent.updated" }); await flush(); }, async models(next: ModelInfo[], location = { directory: "/fixture" }) { catalog = next; controller.enqueue({ type: "model.updated", location }); await flush(); }, async availableTools(next: string[]) { tools = next; replayTools(); await flush(); }, async delegate(agent: string, target: string) { await before({ tool: "subagent", agent, input: { agent: target } }); }, async command(owner: string, target: string) { parent = owner; child = target; await promptHook({ sessionID: "child" }); }, async request(agent: string) { const event: Pick = { agent: Agent.ID.make(agent), tools: { subagent: { description: "native", input: { type: "object", properties: { agent: { type: "string" } } }, }, }, }; await contextHook(event); return event.tools; }, }; } test("Agents is independently loadable with no quota, storage, MCP, or custom tools", async () => { expect(plugin.id).toBe("stanp-opencode.agents"); const h = await host(); expect(h.agents.has("coordinate")).toBe(true); expect(h.agents.has("multi-work")).toBe(false); expect(h.agents.get("build")?.mode).toBe("primary"); expect(h.skills.get("anti-overengineering")?.content).toContain("correctness"); }); test("preserves an existing user anti-overengineering skill", async () => { const first = await host(); const original = { ...first.skills.get("anti-overengineering")!, content: "user definition" }; const h = await host(models, { agents }, original); expect(h.skills.get("anti-overengineering")).toEqual(original); }); test("worker availability, deep fallback, and model refresh remain provider aware", async () => { const h = await host(); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); await h.models(models.slice(0, 2)); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); expect(h.agents.has("deep-lo")).toBe(false); await h.models(models.slice(0, 1)); expect(h.agents.has("coordinate")).toBe(false); expect(await h.request("coordinate")).toEqual({}); await h.models(models); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); }); test("without agents options or with the empty skeleton nothing is registered", async () => { const skeleton = Object.fromEntries(Object.keys(agents).map((name) => [name, ""])); for (const options of [{}, { agents: skeleton }]) { const h = await host(models, options); expect([...h.agents.keys()]).toEqual(["build"]); expect(h.skills.size).toBe(0); } }); test("empty deep pair disables deep; partially filled required assignments fail", async () => { const h = await host(models, { agents: { ...agents, "deep-lo": "", "deep-hi": "" } }); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); expect(h.agents.has("deep-lo")).toBe(false); await expect(host(models, { agents: { ...agents, "good-hi": "" } })).rejects.toThrow( "agents good-hi is required", ); await expect(host(models, { agents: { "deep-lo": agents["deep-lo"] } })).rejects.toThrow( "agents fast-lo is required", ); }); test("assignments are provider-qualified with optional deep pair and variant", async () => { const shallow = Object.fromEntries( Object.entries(agents).filter(([name]) => !name.startsWith("deep-")), ); const duplicate = models.map((model) => ({ ...model, providerID: Provider.ID.make("other") })); const h = await host([...duplicate, ...models], { agents: shallow }); expect(String(h.agents.get("coordinate")?.model?.providerID)).toBe("test"); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); expect(h.agents.has("deep-hi")).toBe(false); expect(model("a/b/c#high", "n")).toEqual({ providerID: "a", id: "b/c", variant: "high" }); expect(model("a/b", "n")).toEqual({ providerID: "a", id: "b" }); expect(() => model("unqualified#low", "agents fast-lo")).toThrow( "agents fast-lo must be provider/model[#variant]: unqualified#low", ); await expect( host(models, { agents: { ...agents, "fast-lo": "unqualified#low" } }), ).rejects.toThrow("agents fast-lo must be provider/model[#variant]"); }); test("live final build permissions reach workers, coordinate and the native Agents RPC", async () => { const h = await host(); const rules = [ { action: "*", resource: "*", effect: "allow" as const }, { action: "read", resource: "secret", effect: "deny" as const }, ]; await h.configure(rules); expect(await h.permissions()).toEqual(rules); expect(h.agents.get("fast-lo")?.permissions).toEqual([ ...rules, { action: "subagent", resource: "*", effect: "deny" }, ]); expect(h.agents.get("coordinate")?.permissions).toContainEqual(rules[1]); expect(h.agents.get("coordinate")?.permissions.some((rule) => rule.action === "shell")).toBe( false, ); }); test("available Codegraph tools inherit permission rules without changing MCP configuration", async () => { const h = await host(); await h.availableTools(["codegraph_query", "docs_search"]); await h.configure([ { action: "*", resource: "*", effect: "allow" }, { action: "code*", resource: "*", effect: "ask" }, ]); expect(h.agents.get("coordinate")?.permissions).toContainEqual({ action: "codegraph_query", resource: "*", effect: "ask", }); expect( h.agents.get("coordinate")?.permissions.some((rule) => rule.action === "docs_search"), ).toBe(false); await h.availableTools([]); expect( h.agents .get("coordinate") ?.permissions.some((rule) => rule.action.startsWith("codegraph_")), ).toBe(false); }); test("native tool and command paths retain core delegation restrictions", async () => { const h = await host(models.slice(0, 2)); for (const invoke of [h.delegate, h.command]) { await expect(invoke("build", "coordinate")).rejects.toThrow("cannot run as a subagent"); await expect(invoke("multi-work", "coordinate")).rejects.toThrow("without Multi-work"); await expect(invoke("fast-hi", "good-lo")).rejects.toThrow("Workers cannot delegate"); await expect(invoke("coordinate", "deep-lo")).rejects.toThrow( "only to its available workers", ); await invoke("coordinate", "good-hi"); } expect(await h.request("fast-lo")).toEqual({}); expect( z .object({ agent: z.object({ type: z.string(), enum: z.array(z.string()) }) }) .parse((await h.request("coordinate")).subagent?.input.properties).agent, ).toEqual({ type: "string", enum: ["fast-lo", "fast-hi", "good-lo", "good-hi"] }); }); test("foreign-location model events do not refresh this plugin instance", async () => { const h = await host(); await h.models([], { directory: "/elsewhere" }); expect(h.agents.has("coordinate")).toBe(true); }); test("reasoning uses advertised variants and falls back to request settings", async () => { const h = await host(); expect(String(h.agents.get("fast-lo")?.model?.variant)).toBe("low"); expect(h.agents.get("fast-lo")?.request?.settings).toEqual({}); const noVariants = models.map((model) => ({ ...model, variants: [] })); const fallback = await host(noVariants); expect(fallback.agents.get("fast-lo")?.model?.variant).toBeUndefined(); expect(fallback.agents.get("fast-lo")?.request?.settings).toEqual({ reasoningEffort: "low" }); expect(fallback.agents.get("coordinate")?.request?.settings).toEqual({ reasoningEffort: "low", }); }); test("disabled catalog entries do not authorize worker availability", async () => { const h = await host( models.map((model) => ({ ...model, enabled: model.id !== "gpt-6-astra" })), ); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); expect(h.agents.has("deep-lo")).toBe(false); });