385 lines
15 KiB
TypeScript
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);
|
|
});
|