Files
stanp-opencode/tests/plugin.test.ts
T
StanPonomarev f527778b29
CI / verify (pull_request) Failing after 8s
feat: split plugins and cleanup
2026-10-04 12:49:41 +02:00

385 lines
15 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 { settings, gptAgents, workerNames } from "../src/config.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 },
})),
}));
async function host(
catalog: ModelInfo[] = models,
options: Plugin.Context["options"] = { provider: "test" },
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("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, { provider: "test" }, 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("custom models use provider-qualified names and optional deep assignments", async () => {
const assignments = Object.fromEntries(
workerNames
.filter((name) => !name.startsWith("deep-"))
.map((name) => [name, { ...gptAgents[name], model: `test/${gptAgents[name].model}` }]),
);
const duplicate = models.map((model) => ({ ...model, providerID: Provider.ID.make("other") }));
const h = await host([...duplicate, ...models], { mode: "custom", agents: assignments });
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(settings.safeParse({ mode: "gpt", provider: "test", lunaPriority: true }).success).toBe(
false,
);
expect(
settings.safeParse({
mode: "custom",
agents: { ...assignments, "fast-lo": { model: "unqualified", effort: "low" } },
}).success,
).toBe(false);
});
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("Luna priority requires official or advertised support and can be disabled", async () => {
const openai = models.map((model) => ({
...model,
providerID: Provider.ID.make("openai"),
package: "@opencode/ai/providers/openai",
}));
const official = await host(openai, { provider: "openai" });
expect(official.agents.get("fast-lo")?.request?.settings.serviceTier).toBe("priority");
expect(official.agents.get("good-lo")?.request?.settings.serviceTier).toBeUndefined();
const disabled = await host(openai, { provider: "openai", lunaPriority: false });
expect(disabled.agents.get("fast-hi")?.request?.settings.serviceTier).toBeUndefined();
const proxy = openai.map((model) => ({
...model,
settings: { baseURL: "https://proxy.example/v1" },
}));
const unadvertised = await host(proxy, { provider: "openai" });
expect(unadvertised.agents.get("fast-lo")?.request?.settings.serviceTier).toBeUndefined();
const advertised = await host(
proxy.map((model) => ({ ...model, settings: { ...model.settings, serviceTier: "fast" } })),
{ provider: "openai" },
);
expect(advertised.agents.get("fast-lo")?.request?.settings.serviceTier).toBe("fast");
});
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);
});