Files
stanp-opencode/tests/plugin.test.ts
T
StanPonomarev c48fe99b96
Release / release (push) Failing after 5s
chore: update opencode api to v2
2026-09-12 10:29:24 +02:00

518 lines
21 KiB
TypeScript

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 { z } from "zod";
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<SessionContext, "agent" | "tools">;
type HookEvent = ModelContext | Pick<SessionGetInput, "sessionID">;
type FixtureSetup = (
context: Pick<Plugin.Context, "options">,
) => Plugin.Cleanup | void | Promise<Plugin.Cleanup | void>;
async function host(
models: ModelInfo[] = allModels,
options: Plugin.Context["options"] = { provider: "test" },
configuredMcp: Record<
string,
{ type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
> = {},
configuredMcpTools: string[] = [],
) {
const agents = new Map<string, DeepMutable<Agent.Info>>();
const build: DeepMutable<Agent.Info> = 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<string, ((event: HookEvent) => Promise<void> | void)[]> = {};
let before!: (event: { tool: string; agent: string; input: unknown }) => Promise<void> | void;
let parent = "build";
let child = "fast-lo";
let defaultAgent = "build";
const registration = { async dispose() {} };
let transform!: (editor: AgentEditor) => void;
let reloaded = Promise.withResolvers<void>();
let events!: ReadableStreamDefaultController<{
type: string;
location?: { directory: string; workspaceID?: string };
}>;
const stream = new ReadableStream<{
type: string;
location?: { directory: string; workspaceID?: string };
}>({
start(controller) {
events = controller;
},
});
const storage = new Map<string, JsonValue>();
const tools = new Map<string, Info>();
const mcpTools = new Set(configuredMcpTools);
const mcpServers = new Map(Object.entries(structuredClone(configuredMcp)));
let mcpTransform!: (editor: {
get(name: string): (typeof configuredMcp)[string] | undefined;
update(name: string, update: (server: (typeof configuredMcp)[string]) => void): void;
}) => void;
const toolTransforms: ((editor: ToolEditor) => void)[] = [];
const editMcp = () => ({
get: (name: string) => mcpServers.get(name),
update(name: string, update: (server: (typeof configuredMcp)[string]) => void) {
const server = mcpServers.get(name);
if (server) update(server);
},
});
const editTools = (): ToolEditor => ({
get(id) {
const tool = tools.get(id);
return tool ? { ...tool, id } : undefined;
},
list: () => [
...[...tools].map(([id, tool]) => ({ ...tool, id })),
...[...mcpTools].map((id) => ({
id,
name: id,
description: id,
input: z.object({}),
options: { codemode: false },
async execute() {
return { content: "" };
},
})),
],
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);
},
});
// 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 = {
location: { directory: "/test" },
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);
},
async remove(key: string) {
storage.delete(key);
},
},
integration: {
connection: {
async active() {
return undefined;
},
async resolve() {
return undefined;
},
},
},
rpc: {
async register() {
return { ...registration, events: { async emit() {} } };
},
},
mcp: {
async transform(callback: typeof mcpTransform) {
mcpTransform = callback;
callback(editMcp());
return registration;
},
},
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> | void) {
(sessionHooks[name] ??= []).push(callback);
return registration;
},
async get({ sessionID }: { sessionID: string }) {
return sessionID === "child"
? { agent: child, parentID: "parent" }
: { agent: parent };
},
},
tool: {
async transform(callback: (editor: ToolEditor) => void) {
toolTransforms.push(callback);
callback(editTools());
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,
mcpServers,
defaultAgent,
originalBuild,
async refresh(next: ModelInfo[]) {
models = next;
reloaded = Promise.withResolvers<void>();
events.enqueue({ type: "catalog.updated" });
await reloaded.promise;
},
async changeBuildPermissions(permissions: Agent.Info["permissions"]) {
build.permissions = [...permissions];
reloaded = Promise.withResolvers<void>();
events.enqueue({ type: "agent.updated" });
await reloaded.promise;
},
changeBuildPermissionsElsewhere(permissions: Agent.Info["permissions"]) {
build.permissions = [...permissions];
events.enqueue({ type: "agent.updated", location: { directory: "/other" } });
},
async changeCodegraph(
server:
| { type: "local"; command: string[]; codemode?: boolean; disabled?: boolean }
| undefined,
toolNames: string[],
) {
if (server) mcpServers.set("codegraph", server);
else mcpServers.delete("codegraph");
mcpTools.clear();
for (const name of toolNames) mcpTools.add(name);
reloaded = Promise.withResolvers<void>();
mcpTransform(editMcp());
for (const callback of toolTransforms) callback(editTools());
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;
for (const hook of sessionHooks.prompt ?? []) await hook({ 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" } },
},
},
},
};
for (const hook of sessionHooks.context ?? []) await hook(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("coordinator inherits only codegraph MCP permissions when that server exists", async () => {
const absent = await host();
expect(
absent.agents
.get("coordinate")
?.permissions.some((rule) => rule.action.startsWith("codegraph_")),
).toBe(false);
expect(absent.mcpServers.has("codegraph")).toBe(false);
const result = await host(
allModels,
{ provider: "test" },
{
codegraph: { type: "local", command: ["codegraph"], codemode: true },
docs: { type: "local", command: ["docs"], codemode: true },
},
["codegraph_codegraph_search", "codegraph_future_query", "docs_search"],
);
expect(result.mcpServers.get("codegraph")?.codemode).toBe(false);
expect(result.mcpServers.get("docs")?.codemode).toBe(true);
let coordinate = result.agents.get("coordinate")!;
expect(coordinate.permissions).toContainEqual({
action: "codegraph_codegraph_search",
resource: "*",
effect: "allow",
});
expect(coordinate.permissions.some((rule) => rule.action.startsWith("docs_"))).toBe(false);
expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false);
expect(coordinate.permissions.some((rule) => rule.action === "execute")).toBe(false);
await result.changeBuildPermissions([
{ action: "*", resource: "*", effect: "allow" },
{ action: "code*", resource: "*", effect: "ask" },
{ action: "*search", resource: "*", effect: "deny" },
{ action: "docs_*", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "allow" },
]);
coordinate = result.agents.get("coordinate")!;
expect(coordinate.permissions).toContainEqual({
action: "codegraph_future_query",
resource: "*",
effect: "ask",
});
expect(coordinate.permissions).toContainEqual({
action: "codegraph_codegraph_search",
resource: "*",
effect: "deny",
});
expect(coordinate.permissions.some((rule) => rule.action === "docs_*")).toBe(false);
expect(coordinate.permissions.some((rule) => rule.action === "shell")).toBe(false);
await result.changeCodegraph(undefined, []);
coordinate = result.agents.get("coordinate")!;
expect(coordinate.permissions.some((rule) => rule.action.startsWith("codegraph_"))).toBe(false);
await result.changeCodegraph({ type: "local", command: ["codegraph"], codemode: true }, [
"codegraph_added_later",
]);
expect(result.mcpServers.get("codegraph")?.codemode).toBe(false);
expect(result.agents.get("coordinate")?.permissions).toContainEqual({
action: "codegraph_added_later",
resource: "*",
effect: "ask",
});
});
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("events from another location do not reload this location's agents", async () => {
const result = await host();
result.changeBuildPermissionsElsewhere([
...result.build.permissions,
{ action: "read", resource: "*.env", effect: "deny" },
]);
await result.refresh(allModels);
expect(
result.agents
.get("coordinate")
?.permissions.filter((rule) => rule.action === "read" && rule.resource === "*.env")
.at(-1)?.effect,
).toBe("ask");
});
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)");
});