import { afterEach, expect, test } from "bun:test"; import { Agent, Model, Plugin, Provider } from "@opencode/plugin"; import type { AgentEditor } from "@opencode/plugin/promise/agent"; import type { DeepMutable } from "@opencode/plugin/promise/types"; import type { Info, ToolContext, ToolEditor } from "@opencode/plugin/promise/tool"; import type { JsonValue, ModelInfo, SessionGetInput } from "@opencode/client"; import type { SessionContext } from "@opencode/plugin/promise/session"; import { gptAgents } from "../src/config.js"; import plugin from "../src/index.js"; const providerID = Provider.ID.make("test"); function model(id: string): ModelInfo { return Model.Info.default(providerID, Model.ID.make(id)); } const allModels = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map(model); const cleanups: Plugin.Cleanup[] = []; afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); }); type ModelContext = Pick; type HookEvent = ModelContext | Pick; type FixtureSetup = ( context: Pick, ) => Plugin.Cleanup | void | Promise; async function host( models: ModelInfo[] = allModels, options: Plugin.Context["options"] = { provider: "test" }, ) { const agents = new Map>(); const build: DeepMutable = Agent.Info.default(Agent.ID.make("build")); build.permissions.push({ action: "shell", resource: "git push *", effect: "ask" }); agents.set("build", build); const originalBuild = structuredClone(build); const sessionHooks: Record Promise | void> = {}; let before!: (event: { tool: string; agent: string; input: unknown }) => Promise | void; let parent = "build"; let child = "fast-lo"; let defaultAgent = "build"; const registration = { async dispose() {} }; let transform!: (editor: AgentEditor) => void; let reloaded = Promise.withResolvers(); let events!: ReadableStreamDefaultController<{ type: string }>; const stream = new ReadableStream<{ type: string }>({ start(controller) { events = controller; }, }); const storage = new Map(); const tools = new Map(); // SAFETY: This test host implements the native APIs used by setup; unrelated context // services are deliberately absent so unexpected new dependencies fail the test. const context = { options, catalog: { model: { async list() { return { data: models }; }, }, }, event: { subscribe({ signal }: { signal: AbortSignal }) { signal.addEventListener("abort", () => events.close(), { once: true }); return stream; }, }, storage: { async get(key: string) { return storage.get(key); }, async set(key: string, value: JsonValue) { storage.set(key, value); }, }, agent: { async list() { return { data: [...agents.values()] }; }, async get({ agentID }: { agentID: string }) { return { data: agents.get(agentID) }; }, async transform(callback: (editor: AgentEditor) => void) { transform = callback; callback({ get: (id) => agents.get(id), list: () => [...agents.values()], default(id) { defaultAgent = id ?? "build"; }, remove(id) { agents.delete(id); }, update(id, update) { const agent = agents.get(id) ?? Agent.Info.default(Agent.ID.make(id)); update(agent); agents.set(id, agent); }, }); return registration; }, async reload() { agents.clear(); agents.set("build", build); await this.transform(transform); reloaded.resolve(); }, }, session: { async hook(name: string, callback: (event: HookEvent) => Promise | void) { sessionHooks[name] = callback; return registration; }, async get({ sessionID }: { sessionID: string }) { return sessionID === "child" ? { agent: child, parentID: "parent" } : { agent: parent }; }, }, tool: { async transform(callback: (editor: ToolEditor) => void) { callback({ get(id) { const tool = tools.get(id); return tool ? { ...tool, id } : undefined; }, list: () => [...tools].map(([id, tool]) => ({ ...tool, id })), namespace() {}, add(tool) { tools.set(tool.name, tool); }, update(id, update) { const tool = tools.get(id); if (tool) update(tool); }, remove(id) { tools.delete(id); }, }); return registration; }, async hook(_name: string, callback: typeof before) { before = callback; return registration; }, }, }; // SAFETY: Exercise the real setup with the native operations implemented by this // partial host; absent services intentionally fail instead of becoming inert mocks. const setup = plugin.setup as FixtureSetup; const cleanup = await setup(context); if (cleanup) cleanups.push(cleanup); return { agents, build, defaultAgent, originalBuild, async refresh(next: ModelInfo[]) { models = next; reloaded = Promise.withResolvers(); events.enqueue({ type: "catalog.updated" }); await reloaded.promise; }, async changeBuildPermissions(permissions: Agent.Info["permissions"]) { build.permissions = [...permissions]; reloaded = Promise.withResolvers(); events.enqueue({ type: "agent.updated" }); await reloaded.promise; }, async todo(name: string, sessionID: string, input: JsonValue = {}) { // SAFETY: Todo executors use only sessionID and agent from the native context. return tools .get(name)! .execute(input, { sessionID, agent: Agent.ID.make("build") } as ToolContext); }, async delegate(caller: string, target: string) { await before({ tool: "subagent", agent: caller, input: { agent: target } }); }, async command(caller: string, target: string) { parent = caller; child = target; await sessionHooks.prompt!({ sessionID: "child" }); }, async tools(caller: string) { const event: ModelContext = { agent: Agent.ID.make(caller), tools: { subagent: { description: "Native subagents", input: { type: "object", properties: { agent: { type: "string" }, prompt: { type: "string" } }, }, }, }, }; await sessionHooks.context!(event); return event.tools; }, }; } test("missing required models disables registration; only GPT mode degrades without Astra", async () => { const full = await host(); expect(full.agents.has("deep-hi")).toBe(true); expect(String(full.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); const reduced = await host(allModels.slice(0, 2)); expect(reduced.agents.has("deep-hi")).toBe(false); expect(String(reduced.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol"); expect(reduced.agents.get("coordinate")?.system).toContain("Deep agents are unavailable"); expect((await host([])).agents.has("coordinate")).toBe(false); expect((await host(allModels.slice(1))).agents.has("coordinate")).toBe(false); const custom = await host([...allModels, model("local")], { mode: "custom", provider: "test", agents: { ...gptAgents, coordinate: { model: "local", effort: "low" } }, }); expect(String(custom.agents.get("coordinate")?.model?.id)).toBe("local"); expect( ( await host(allModels.slice(0, 2), { mode: "custom", provider: "test", agents: gptAgents, }) ).agents.has("coordinate"), ).toBe(false); }); test("worker inheritance and coordinator isolation preserve build and default", async () => { const result = await host(); expect(result.defaultAgent).toBe("build"); expect(result.agents.get("build")).toEqual(result.originalBuild); const worker = result.agents.get("fast-lo")!; expect(worker.permissions).toContainEqual({ action: "shell", resource: "git push *", effect: "ask", }); expect(worker.permissions.at(-1)).toEqual({ action: "subagent", resource: "*", effect: "deny", }); const coordinate = result.agents.get("coordinate")!; expect(coordinate.permissions[0]).toEqual({ action: "*", resource: "*", effect: "deny" }); expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false); expect(coordinate.permissions).toContainEqual({ action: "read", resource: "*.env", effect: "ask", }); expect(coordinate.permissions).toContainEqual({ action: "subagent", resource: "fast-lo", effect: "allow", }); }); test("manual calls and command-created child sessions cannot bypass delegation restrictions", async () => { const result = await host(allModels.slice(0, 2)); for (const invoke of [result.delegate, result.command]) { await expect(invoke("build", "coordinate")).rejects.toThrow("cannot run as a subagent"); 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"); } const tools = await result.tools("coordinate"); expect(tools.subagent?.input.properties).toEqual({ agent: { type: "string", enum: ["fast-lo", "fast-hi", "good-lo", "good-hi"] }, prompt: { type: "string" }, }); expect(await result.tools("fast-lo")).toEqual({}); expect((await result.tools("build")).subagent?.description).toBe("Native subagents"); }); test("reasoning settings and Luna priority use native advertised or official support", async () => { const luna = { ...model("gpt-5.6-luna"), package: "@opencode/ai/providers/openai", settings: { baseURL: "https://api.openai.com/v1" }, }; const ordinary = await host(); expect(ordinary.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" }); const official = await host([luna, ...allModels.slice(1)]); expect(official.agents.get("fast-hi")?.request.settings).toEqual({ reasoningEffort: "high", serviceTier: "priority", }); const proxy = await host([ { ...luna, settings: { baseURL: "https://proxy.example/v1" } }, ...allModels.slice(1), ]); expect(proxy.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" }); const advertised = await host([ { ...luna, package: "custom", settings: { serviceTier: "fast" } }, ...allModels.slice(1), ]); expect(advertised.agents.get("fast-lo")?.request.settings.serviceTier).toBe("fast"); const disabled = await host([luna, ...allModels.slice(1)], { provider: "test", lunaPriority: false, }); expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({ reasoningEffort: "low" }); }); test("catalog updates activate late models and remove unavailable agents", async () => { const result = await host([]); await result.refresh(allModels); expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra"); await result.refresh(allModels.slice(0, 2)); expect(result.agents.has("deep-lo")).toBe(false); expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol"); await result.refresh([]); expect(result.agents.has("coordinate")).toBe(false); }); test("late build permission overrides remain effective for workers and coordinator", async () => { const result = await host(); await result.changeBuildPermissions([ ...result.build.permissions, { action: "read", resource: "*.env", effect: "deny" }, { action: "shell", resource: "git push *", effect: "deny" }, ]); expect(result.agents.get("fast-lo")?.permissions).toContainEqual({ action: "shell", resource: "git push *", effect: "deny", }); expect( result.agents .get("coordinate") ?.permissions.filter((rule) => rule.action === "read" && rule.resource === "*.env") .at(-1)?.effect, ).toBe("deny"); }); test("todo lists persist within one session without leaking to another", async () => { const result = await host(); const todos = [{ id: "1", content: "Implement", status: "in_progress", priority: "high" }]; await result.todo("todowrite", "one", { todos }); expect((await result.todo("todoread", "one")).content).toBe(JSON.stringify(todos)); expect((await result.todo("todoread", "two")).content).toBe("[]"); result.build.permissions.push({ action: "todowrite", resource: "*", effect: "ask" }); await expect(result.todo("todowrite", "one", { todos: [] })).rejects.toThrow("cannot request"); expect((await result.todo("todoread", "one")).content).toBe(JSON.stringify(todos)); result.build.permissions.push({ action: "todoread", resource: "*", effect: "deny" }); await expect(result.todo("todoread", "one")).rejects.toThrow("(deny)"); });