Files
stanp-opencode/tests/plugin.test.ts
2026-10-10 13:54:07 +02:00

301 lines
12 KiB
TypeScript

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<string, DeepMutable<Agent.Info>>();
let build: DeepMutable<Agent.Info> = Agent.Info.default(Agent.ID.make("build"));
const skills = new Map<string, Skill.Info>(
existingSkill ? [[existingSkill.id, existingSkill]] : [],
);
let transform: (editor: AgentEditor) => void;
let contextHook: (event: Pick<SessionContext, "agent" | "tools">) => Promise<void> | void;
let promptHook: (event: { sessionID: string }) => Promise<void> | void;
let before: (event: { tool: string; agent: string; input: unknown }) => Promise<void> | void;
let parent = "build";
let child = "fast-lo";
let tools: string[] = [];
let toolTransform: (editor: ToolEditor) => void;
let permissions: () => Promise<Agent.Info["permissions"]>;
let controller: ReadableStreamDefaultController<unknown>;
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<Plugin.Context, "options">,
) => ReturnType<typeof plugin.setup>;
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<SessionContext, "agent" | "tools"> = {
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([...h.skills.keys()].sort()).toEqual(["anti-overengineering", "task-planning"]);
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"] });
});