+335
-170
@@ -1,186 +1,351 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk";
|
||||
import type { Config, PluginOptions } from "@opencode-ai/plugin";
|
||||
import plugin from "../src/index.js";
|
||||
import { z } from "zod";
|
||||
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 { gptAgents } from "../src/config.js";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
let directory: string;
|
||||
const originalModels = process.env.OPENCODE_MODELS_PATH;
|
||||
const originalAuth = process.env.OPENCODE_AUTH_CONTENT;
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "stanp-tests-"));
|
||||
const path = join(directory, "models.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
test: {
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: Object.fromEntries(
|
||||
["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map((id) => [id, { id }]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
process.env.OPENCODE_MODELS_PATH = path;
|
||||
process.env.OPENCODE_AUTH_CONTENT = "{}";
|
||||
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>;
|
||||
|
||||
afterAll(async () => {
|
||||
if (originalModels === undefined) delete process.env.OPENCODE_MODELS_PATH;
|
||||
else process.env.OPENCODE_MODELS_PATH = originalModels;
|
||||
if (originalAuth === undefined) delete process.env.OPENCODE_AUTH_CONTENT;
|
||||
else process.env.OPENCODE_AUTH_CONTENT = originalAuth;
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function hooks(caller = "build", options: PluginOptions = { provider: "test" }) {
|
||||
return plugin.server(
|
||||
{
|
||||
client: createOpencodeClient({
|
||||
baseUrl: "http://localhost:1",
|
||||
fetch: async (request) =>
|
||||
Response.json(
|
||||
request.url.endsWith("/user")
|
||||
? { info: { role: "user", agent: caller }, parts: [] }
|
||||
: [
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
mode: "general",
|
||||
parentID: "user",
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
),
|
||||
}),
|
||||
project: { id: "test", worktree: directory, time: { created: 0 } },
|
||||
directory,
|
||||
worktree: directory,
|
||||
serverUrl: new URL("http://localhost:1"),
|
||||
experimental_workspace: { register() {} },
|
||||
$: Bun.$,
|
||||
async function host(
|
||||
models: ModelInfo[] = allModels,
|
||||
options: Plugin.Context["options"] = { provider: "test" },
|
||||
) {
|
||||
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 }>;
|
||||
const stream = new ReadableStream<{ type: string }>({
|
||||
start(controller) {
|
||||
events = controller;
|
||||
},
|
||||
});
|
||||
const storage = new Map<string, JsonValue>();
|
||||
const tools = new Map<string, Info>();
|
||||
// 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 = {
|
||||
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);
|
||||
},
|
||||
},
|
||||
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] = callback;
|
||||
return registration;
|
||||
},
|
||||
async get({ sessionID }: { sessionID: string }) {
|
||||
return sessionID === "child"
|
||||
? { agent: child, parentID: "parent" }
|
||||
: { agent: parent };
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
async transform(callback: (editor: ToolEditor) => void) {
|
||||
callback({
|
||||
get(id) {
|
||||
const tool = tools.get(id);
|
||||
return tool ? { ...tool, id } : undefined;
|
||||
},
|
||||
list: () => [...tools].map(([id, tool]) => ({ ...tool, id })),
|
||||
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);
|
||||
},
|
||||
});
|
||||
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,
|
||||
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;
|
||||
},
|
||||
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;
|
||||
await sessionHooks.prompt!({ 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" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await sessionHooks.context!(event);
|
||||
return event.tools;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("registration is atomic; only missing Astra degrades to four workers", async () => {
|
||||
const full: Config = { provider: { test: {} } };
|
||||
await (await hooks()).config(full);
|
||||
// The host's /agent endpoint rejects undefined options rather than JSON-stringifying them.
|
||||
z.json().parse(full.agent);
|
||||
expect(Object.keys(full.agent ?? {})).toEqual([
|
||||
"fast-lo",
|
||||
"fast-hi",
|
||||
"good-lo",
|
||||
"good-hi",
|
||||
"deep-lo",
|
||||
"deep-hi",
|
||||
"coordinate",
|
||||
]);
|
||||
|
||||
const reduced: Config = { provider: { test: { blacklist: ["gpt-6-astra"] } } };
|
||||
await (await hooks()).config(reduced);
|
||||
expect(Object.keys(reduced.agent ?? {})).toEqual([
|
||||
"fast-lo",
|
||||
"fast-hi",
|
||||
"good-lo",
|
||||
"good-hi",
|
||||
"coordinate",
|
||||
]);
|
||||
expect(reduced.agent?.coordinate?.model).toBe("test/gpt-5.6-sol");
|
||||
|
||||
for (const missing of ["gpt-5.6-luna", "gpt-5.6-sol"]) {
|
||||
const config: Config = { provider: { test: { blacklist: [missing] } } };
|
||||
await (await hooks()).config(config);
|
||||
expect(config.agent).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("disconnected and explicitly disabled providers register nothing", async () => {
|
||||
const disconnected: Config = {};
|
||||
await (await hooks()).config(disconnected);
|
||||
expect(disconnected.agent).toBeUndefined();
|
||||
|
||||
const disabled: Config = { provider: { test: {} }, disabled_providers: ["test"] };
|
||||
await (await hooks()).config(disabled);
|
||||
expect(disabled.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
test("manual task targets cannot bypass coordinate's primary-only restriction", async () => {
|
||||
const active = await hooks();
|
||||
await active.config({ provider: { test: {} } });
|
||||
await expect(
|
||||
active["tool.execute.before"](
|
||||
{ tool: "task", sessionID: "test", callID: "test" },
|
||||
{ args: { subagent_type: "coordinate", prompt: "implement" } },
|
||||
),
|
||||
).rejects.toThrow("cannot be delegated to");
|
||||
});
|
||||
|
||||
test("subtask bypass uses the parent user agent, not the synthetic assistant target", async () => {
|
||||
const coordinate = await hooks("coordinate");
|
||||
await coordinate.config({ provider: { test: { blacklist: ["gpt-6-astra"] } } });
|
||||
for (const target of ["general", "deep-lo"]) {
|
||||
await expect(
|
||||
coordinate["tool.execute.before"](
|
||||
{ tool: "task", sessionID: "test", callID: "test" },
|
||||
{ args: { subagent_type: target } },
|
||||
),
|
||||
).rejects.toThrow("only to its available workers");
|
||||
}
|
||||
const worker = await hooks("fast-hi");
|
||||
await worker.config({ provider: { test: {} } });
|
||||
await expect(
|
||||
worker["tool.execute.before"](
|
||||
{ tool: "task", sessionID: "test", callID: "test" },
|
||||
{ args: { subagent_type: "good-lo" } },
|
||||
),
|
||||
).rejects.toThrow("Workers cannot delegate");
|
||||
});
|
||||
|
||||
test("custom assignments do not silently take the GPT fallback", async () => {
|
||||
const custom = await hooks("build", {
|
||||
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" } },
|
||||
});
|
||||
const config: Config = { provider: { test: { models: { local: { name: "Local" } } } } };
|
||||
await custom.config(config);
|
||||
expect(config.agent?.coordinate?.model).toBe("test/local");
|
||||
expect(config.agent?.coordinate?.options).toEqual({ reasoningEffort: "low" });
|
||||
|
||||
const missing: Config = {
|
||||
provider: { test: { blacklist: ["gpt-6-astra"], models: { local: {} } } },
|
||||
};
|
||||
await custom.config(missing);
|
||||
expect(missing.agent).toBeUndefined();
|
||||
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("tier selection requires evidence and preserves the advertised fast tier", async () => {
|
||||
const namedFast = { name: "Luna", variants: { fast: { reasoningEffort: "low" } } };
|
||||
const ordinary: Config = { provider: { test: { models: { "gpt-5.6-luna": namedFast } } } };
|
||||
await (await hooks()).config(ordinary);
|
||||
expect(ordinary.agent?.["fast-lo"]?.options).toEqual({ reasoningEffort: "low" });
|
||||
|
||||
const advertised: Config = {
|
||||
provider: {
|
||||
test: {
|
||||
models: { "gpt-5.6-luna": { options: { serviceTier: "fast" } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
await (await hooks()).config(advertised);
|
||||
expect(advertised.agent?.["fast-lo"]?.options).toEqual({
|
||||
reasoningEffort: "low",
|
||||
serviceTier: "fast",
|
||||
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("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("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)");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user