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("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("assignments are provider-qualified with optional deep pair and variant; partial ones fail", 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]"); const empty = await host(models, { agents: { ...agents, "deep-lo": "", "deep-hi": "" } }); expect(String(empty.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); expect(empty.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("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"] }); });