300 lines
12 KiB
TypeScript
300 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(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"] });
|
|
});
|