982 lines
40 KiB
TypeScript
982 lines
40 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
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, settings, workerNames } from "../src/config.js";
|
|
import { basePlugin as plugin } from "../src/index.js";
|
|
import { playwrightMcpCommand } from "../src/playwright.js";
|
|
import { bindingKey } from "../src/multi-work.js";
|
|
|
|
const providerID = Provider.ID.make("test");
|
|
function model(id: string): ModelInfo {
|
|
return Model.Info.default(providerID, Model.ID.make(id));
|
|
}
|
|
function reasoningModel(id: string): ModelInfo {
|
|
return {
|
|
...model(id),
|
|
variants: ["low", "medium", "high"].map((effort) => ({
|
|
id: Model.VariantID.make(effort),
|
|
settings: { reasoningEffort: effort },
|
|
})),
|
|
};
|
|
}
|
|
function providerModel(provider: string, id: string): ModelInfo {
|
|
return {
|
|
...reasoningModel(id),
|
|
providerID: Provider.ID.make(provider),
|
|
};
|
|
}
|
|
const allModels = ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-6-astra"].map(reasoningModel);
|
|
type CustomAssignment = {
|
|
model: string;
|
|
effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
};
|
|
const customAssignments = (provider = "test") => {
|
|
const assignment = (name: (typeof workerNames)[number]): CustomAssignment => ({
|
|
...gptAgents[name],
|
|
model: `${provider}/${gptAgents[name].model}`,
|
|
});
|
|
return {
|
|
"fast-lo": assignment("fast-lo"),
|
|
"fast-hi": assignment("fast-hi"),
|
|
"good-lo": assignment("good-lo"),
|
|
"good-hi": assignment("good-hi"),
|
|
"deep-lo": assignment("deep-lo"),
|
|
"deep-hi": assignment("deep-hi"),
|
|
};
|
|
};
|
|
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;
|
|
timeout?: { startup?: number; catalog?: number; execution?: number };
|
|
}
|
|
> = {},
|
|
configuredMcpTools: string[] = [],
|
|
directory = "/test",
|
|
workspaceID?: 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 childDirectory = directory;
|
|
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;
|
|
set(name: string, server: (typeof configuredMcp)[string]): void;
|
|
update(name: string, update: (server: (typeof configuredMcp)[string]) => void): void;
|
|
}) => void;
|
|
const toolTransforms: ((editor: ToolEditor) => void)[] = [];
|
|
const editMcp = () => ({
|
|
get: (name: string) => mcpServers.get(name),
|
|
set(name: string, server: (typeof configuredMcp)[string]) {
|
|
mcpServers.set(name, structuredClone(server));
|
|
},
|
|
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, workspaceID },
|
|
options,
|
|
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,
|
|
projectID: "project-fixture",
|
|
parentID: "parent",
|
|
location: { directory: childDirectory },
|
|
}
|
|
: { agent: parent, projectID: "project-fixture", location: { directory } };
|
|
},
|
|
},
|
|
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: "model.updated" });
|
|
await reloaded.promise;
|
|
},
|
|
publishModels(next: ModelInfo[], location?: { directory: string; workspaceID?: string }) {
|
|
models = next;
|
|
if (location) events.enqueue({ type: "model.updated", location });
|
|
else events.enqueue({ type: "model.updated" });
|
|
},
|
|
async waitForReload() {
|
|
reloaded = Promise.withResolvers<void>();
|
|
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" });
|
|
},
|
|
bindCoordinate(ready: boolean, moved = true, local = false) {
|
|
childDirectory = local ? directory : moved ? `${directory}/worktree` : directory;
|
|
storage.set(bindingKey("child"), {
|
|
owner: "parent",
|
|
child: "child",
|
|
projectID: "project-fixture",
|
|
ready,
|
|
root: { directory, workspaceID: "w1", repo: `${directory}/.git` },
|
|
directory: local ? directory : `${directory}/worktree`,
|
|
workspaceID: local ? "w1" : "w2",
|
|
branch: local ? "root" : "multi-work/test",
|
|
});
|
|
},
|
|
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("orchestration inherits deep-lo and falls back to good-lo with available worker sets", 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");
|
|
expect(String(full.agents.get("coordinate")?.model?.variant)).toBe("low");
|
|
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(String(reduced.agents.get("coordinate")?.model?.variant)).toBe("low");
|
|
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 customAgents = {
|
|
...customAssignments(),
|
|
"good-lo": {
|
|
model: "test/custom-good",
|
|
effort: "high" as const,
|
|
},
|
|
"deep-lo": {
|
|
model: "test/custom-deep",
|
|
effort: "medium" as const,
|
|
},
|
|
};
|
|
const custom = await host(
|
|
[...allModels, reasoningModel("custom-good"), reasoningModel("custom-deep")],
|
|
{
|
|
mode: "custom",
|
|
agents: customAgents,
|
|
},
|
|
);
|
|
expect(custom.agents.get("coordinate")?.model).toMatchObject({
|
|
id: "custom-deep",
|
|
variant: "medium",
|
|
});
|
|
const customFallback = await host([...allModels, reasoningModel("custom-good")], {
|
|
mode: "custom",
|
|
agents: customAgents,
|
|
});
|
|
expect(customFallback.agents.get("coordinate")?.model).toMatchObject({
|
|
id: "custom-good",
|
|
variant: "high",
|
|
});
|
|
expect(customFallback.agents.has("deep-lo")).toBe(false);
|
|
expect(
|
|
(
|
|
await host(allModels, {
|
|
mode: "custom",
|
|
agents: customAgents,
|
|
})
|
|
).agents.has("coordinate"),
|
|
).toBe(false);
|
|
});
|
|
|
|
test("custom assignments resolve duplicate model IDs by provider and refresh provider-aware fallback", async () => {
|
|
const agents = customAssignments("alpha");
|
|
agents["fast-lo"] = { ...agents["fast-lo"], model: "beta/shared" };
|
|
agents["fast-hi"] = { ...agents["fast-hi"], model: "alpha/shared" };
|
|
agents["deep-lo"] = { ...agents["deep-lo"], model: "beta/deep" };
|
|
agents["deep-hi"] = { ...agents["deep-hi"], model: "beta/deep" };
|
|
const base = [
|
|
providerModel("alpha", "shared"),
|
|
providerModel("beta", "shared"),
|
|
providerModel("alpha", "gpt-5.6-sol"),
|
|
providerModel("alpha", "gpt-6-astra"),
|
|
];
|
|
const result = await host(base, { mode: "custom", agents });
|
|
expect(String(result.agents.get("fast-lo")?.model?.providerID)).toBe("beta");
|
|
expect(String(result.agents.get("fast-hi")?.model?.providerID)).toBe("alpha");
|
|
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-5.6-sol");
|
|
expect(result.agents.has("deep-lo")).toBe(false);
|
|
|
|
await result.refresh([...base, providerModel("beta", "deep")]);
|
|
expect(result.agents.get("coordinate")?.model).toMatchObject({
|
|
providerID: "beta",
|
|
id: "deep",
|
|
});
|
|
expect(result.agents.has("deep-lo")).toBe(true);
|
|
|
|
const nested = customAssignments("alpha");
|
|
nested["fast-lo"] = { ...nested["fast-lo"], model: "alpha/group/shared" };
|
|
const nestedResult = await host(
|
|
[...base, providerModel("alpha", "gpt-5.6-luna"), providerModel("alpha", "group/shared")],
|
|
{ mode: "custom", agents: nested },
|
|
);
|
|
expect(String(nestedResult.agents.get("fast-lo")?.model?.id)).toBe("group/shared");
|
|
});
|
|
|
|
test("custom configuration requires qualified models and rejects obsolete fields", () => {
|
|
expect(() => settings.parse({ mode: "custom", agents: customAssignments() })).not.toThrow();
|
|
for (const model of ["bare", "/model", "provider/"])
|
|
expect(() =>
|
|
settings.parse({
|
|
mode: "custom",
|
|
agents: {
|
|
...customAssignments(),
|
|
"fast-lo": { model, effort: "low" },
|
|
},
|
|
}),
|
|
).toThrow();
|
|
const nested = customAssignments();
|
|
nested["fast-lo"] = { model: "provider/group/model", effort: "low" };
|
|
expect(() => settings.parse({ mode: "custom", agents: nested })).not.toThrow();
|
|
expect(() =>
|
|
settings.parse({ mode: "custom", provider: "test", agents: customAssignments() }),
|
|
).toThrow();
|
|
expect(() =>
|
|
settings.parse({ mode: "custom", lunaPriority: false, agents: customAssignments() }),
|
|
).toThrow();
|
|
expect(() =>
|
|
settings.parse({
|
|
mode: "custom",
|
|
agents: {
|
|
...customAssignments(),
|
|
"fast-lo": { ...customAssignments()["fast-lo"], provider: "test" },
|
|
},
|
|
}),
|
|
).toThrow();
|
|
expect(() =>
|
|
settings.parse({
|
|
mode: "custom",
|
|
agents: {
|
|
...customAssignments(),
|
|
"fast-lo": { ...customAssignments()["fast-lo"], lunaPriority: true },
|
|
},
|
|
}),
|
|
).toThrow();
|
|
expect(() =>
|
|
settings.parse({
|
|
mode: "custom",
|
|
agents: { ...customAssignments(), coordinate: customAssignments()["deep-lo"] },
|
|
}),
|
|
).not.toThrow();
|
|
for (const obsolete of [{ provider: "test" }, { lunaPriority: true }])
|
|
expect(() =>
|
|
settings.parse({
|
|
mode: "custom",
|
|
agents: {
|
|
...customAssignments(),
|
|
coordinate: {
|
|
...customAssignments()["deep-lo"],
|
|
...obsolete,
|
|
},
|
|
},
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
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("playwright MCP prefers direct MCP, uses npx for Playwright, and preserves fallback behavior", async () => {
|
|
const originalPath = process.env.PATH;
|
|
const root = await mkdtemp(join(tmpdir(), "stanp-playwright-"));
|
|
try {
|
|
process.env.PATH = "";
|
|
const packageRoot = join(root, "local", "node_modules", "@playwright", "mcp");
|
|
const executable = join(packageRoot, "cli.js");
|
|
const localProject = join(root, "local", "project");
|
|
const globalProject = join(root, "global-project");
|
|
await mkdir(localProject, { recursive: true });
|
|
await mkdir(globalProject);
|
|
await mkdir(packageRoot, { recursive: true });
|
|
await writeFile(
|
|
join(packageRoot, "package.json"),
|
|
JSON.stringify({ name: "@playwright/mcp", bin: { "playwright-mcp": "cli.js" } }),
|
|
);
|
|
await writeFile(executable, "#!/usr/bin/env node\n");
|
|
await chmod(executable, 0o755);
|
|
|
|
const local = await host(
|
|
allModels,
|
|
{ provider: "test", playwright: true },
|
|
{},
|
|
[],
|
|
localProject,
|
|
);
|
|
expect(local.mcpServers.get("playwright")).toEqual({
|
|
type: "local",
|
|
command: [executable],
|
|
timeout: { startup: 300_000 },
|
|
});
|
|
|
|
const globalPackage = join(root, "global", "@playwright", "mcp");
|
|
const globalExecutable = join(globalPackage, "cli.js");
|
|
const globalBin = join(root, "bin");
|
|
const pathExecutable = join(globalBin, "playwright-mcp");
|
|
await mkdir(globalPackage, { recursive: true });
|
|
await mkdir(globalBin, { recursive: true });
|
|
await writeFile(
|
|
join(globalPackage, "package.json"),
|
|
JSON.stringify({ name: "@playwright/mcp", bin: { "playwright-mcp": "cli.js" } }),
|
|
);
|
|
await writeFile(globalExecutable, "#!/usr/bin/env node\n");
|
|
await chmod(globalExecutable, 0o755);
|
|
await symlink(globalExecutable, pathExecutable);
|
|
expect(await playwrightMcpCommand(globalProject, globalBin)).toEqual([pathExecutable]);
|
|
|
|
const ordinaryPackage = join(root, "ordinary-project", "node_modules", "playwright");
|
|
const ordinaryProject = join(root, "ordinary-project", "project");
|
|
const ordinaryExecutable = join(ordinaryPackage, "cli.js");
|
|
await mkdir(ordinaryPackage, { recursive: true });
|
|
await mkdir(ordinaryProject);
|
|
await writeFile(
|
|
join(ordinaryPackage, "package.json"),
|
|
JSON.stringify({ name: "playwright", bin: { playwright: "cli.js" } }),
|
|
);
|
|
await writeFile(ordinaryExecutable, "#!/usr/bin/env node\n");
|
|
await chmod(ordinaryExecutable, 0o755);
|
|
const ordinary = await host(
|
|
allModels,
|
|
{ provider: "test", playwright: true },
|
|
{},
|
|
[],
|
|
ordinaryProject,
|
|
);
|
|
expect(ordinary.mcpServers.get("playwright")?.command).toEqual([
|
|
"npx",
|
|
"-y",
|
|
"@playwright/mcp@latest",
|
|
]);
|
|
|
|
const testPackage = join(root, "test-project", "node_modules", "@playwright", "test");
|
|
const testDependency = join(testPackage, "node_modules", "playwright");
|
|
const testProject = join(root, "test-project", "project");
|
|
await mkdir(testDependency, { recursive: true });
|
|
await mkdir(testProject);
|
|
await writeFile(
|
|
join(testPackage, "package.json"),
|
|
JSON.stringify({
|
|
name: "@playwright/test",
|
|
bin: { playwright: "cli.js" },
|
|
dependencies: { playwright: "1.0.0" },
|
|
}),
|
|
);
|
|
await writeFile(join(testPackage, "cli.js"), "#!/usr/bin/env node\n");
|
|
await chmod(join(testPackage, "cli.js"), 0o755);
|
|
await writeFile(
|
|
join(testDependency, "package.json"),
|
|
JSON.stringify({ name: "playwright", bin: { playwright: "cli.js" } }),
|
|
);
|
|
await writeFile(join(testDependency, "cli.js"), "#!/usr/bin/env node\n");
|
|
await chmod(join(testDependency, "cli.js"), 0o755);
|
|
expect(await playwrightMcpCommand(testProject, "")).toEqual([
|
|
"npx",
|
|
"-y",
|
|
"@playwright/mcp@latest",
|
|
]);
|
|
|
|
const pathPackage = join(root, "path-playwright", "playwright");
|
|
const pathBin = join(root, "playwright-bin");
|
|
const pathPlaywright = join(pathBin, "playwright");
|
|
await mkdir(pathPackage, { recursive: true });
|
|
await mkdir(pathBin);
|
|
await writeFile(
|
|
join(pathPackage, "package.json"),
|
|
JSON.stringify({ name: "playwright", bin: { playwright: "cli.js" } }),
|
|
);
|
|
await writeFile(join(pathPackage, "cli.js"), "#!/usr/bin/env node\n");
|
|
await chmod(join(pathPackage, "cli.js"), 0o755);
|
|
await symlink(join(pathPackage, "cli.js"), pathPlaywright);
|
|
expect(await playwrightMcpCommand(globalProject, pathBin)).toEqual([
|
|
"npx",
|
|
"-y",
|
|
"@playwright/mcp@latest",
|
|
]);
|
|
|
|
const ordinaryBin = join(root, "ordinary-bin");
|
|
const fakePlaywright = join(ordinaryBin, "playwright");
|
|
await mkdir(ordinaryBin);
|
|
await writeFile(fakePlaywright, "#!/usr/bin/env node\n");
|
|
await chmod(fakePlaywright, 0o755);
|
|
expect((await playwrightMcpCommand(globalProject, ordinaryBin))[0]).toBe("docker");
|
|
|
|
const fallback = await host(allModels, { provider: "test", playwright: true });
|
|
expect(fallback.mcpServers.get("playwright")).toEqual({
|
|
type: "local",
|
|
command: ["docker", "run", "-i", "--rm", "--init", "mcr.microsoft.com/playwright/mcp"],
|
|
timeout: { startup: 300_000 },
|
|
});
|
|
|
|
const existing = await host(
|
|
allModels,
|
|
{ provider: "test" },
|
|
{
|
|
playwright: {
|
|
type: "local",
|
|
command: ["custom-playwright-mcp"],
|
|
disabled: true,
|
|
timeout: { startup: 12_345 },
|
|
},
|
|
docs: { type: "local", command: ["docs"], codemode: true },
|
|
},
|
|
);
|
|
expect(existing.mcpServers.get("playwright")).toEqual({
|
|
type: "local",
|
|
command: ["custom-playwright-mcp"],
|
|
disabled: true,
|
|
timeout: { startup: 12_345 },
|
|
});
|
|
expect(existing.mcpServers.get("docs")?.codemode).toBe(true);
|
|
|
|
const disabled = await host();
|
|
expect(disabled.mcpServers.has("playwright")).toBe(false);
|
|
} finally {
|
|
if (originalPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = originalPath;
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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("coordinate prompt admission requires owned ready placement; local and moved bindings work", async () => {
|
|
const result = await host();
|
|
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
|
"ready multi-work binding",
|
|
);
|
|
result.bindCoordinate(false);
|
|
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
|
"ready multi-work binding",
|
|
);
|
|
result.bindCoordinate(true, false);
|
|
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
|
"ready multi-work binding",
|
|
);
|
|
result.bindCoordinate(true);
|
|
await result.command("multi-work", "coordinate");
|
|
result.bindCoordinate(true, true, true);
|
|
await result.command("multi-work", "coordinate");
|
|
await expect(result.command("build", "coordinate")).rejects.toThrow("ready multi-work binding");
|
|
result.bindCoordinate(true);
|
|
await expect(result.command("multi-work", "fast-lo")).rejects.toThrow("local fast-lo");
|
|
for (const invoke of [result.delegate, result.command])
|
|
await expect(invoke("build", "multi-work")).rejects.toThrow("primary only");
|
|
});
|
|
|
|
test("reasoning settings and Luna priority use native advertised or official support", async () => {
|
|
const luna = {
|
|
...providerModel("openai", "gpt-5.6-luna"),
|
|
package: "@opencode/ai/providers/openai",
|
|
settings: { baseURL: "https://api.openai.com/v1" },
|
|
};
|
|
const openaiModels = [
|
|
luna,
|
|
providerModel("openai", "gpt-5.6-sol"),
|
|
providerModel("openai", "gpt-6-astra"),
|
|
];
|
|
const ordinary = await host(
|
|
[providerModel("openai", "gpt-5.6-luna"), ...openaiModels.slice(1)],
|
|
{ provider: "openai" },
|
|
);
|
|
expect(ordinary.agents.get("fast-lo")?.request.settings).toEqual({});
|
|
expect(String(ordinary.agents.get("fast-lo")?.model?.variant)).toBe("low");
|
|
const official = await host(openaiModels, { provider: "openai" });
|
|
expect(official.agents.get("fast-hi")?.request.settings).toEqual({ serviceTier: "priority" });
|
|
const proxy = await host(
|
|
[{ ...luna, settings: { baseURL: "https://proxy.example/v1" } }, ...openaiModels.slice(1)],
|
|
{ provider: "openai" },
|
|
);
|
|
expect(proxy.agents.get("fast-lo")?.request.settings).toEqual({});
|
|
const advertised = await host(
|
|
[
|
|
{ ...luna, package: "custom", settings: { serviceTier: "fast" } },
|
|
...openaiModels.slice(1),
|
|
],
|
|
{ provider: "openai" },
|
|
);
|
|
expect(advertised.agents.get("fast-lo")?.request.settings.serviceTier).toBe("fast");
|
|
const sdkAlias = await host(
|
|
[{ ...luna, package: "@ai-sdk/openai" }, ...openaiModels.slice(1)],
|
|
{ provider: "openai" },
|
|
);
|
|
expect(sdkAlias.agents.get("fast-lo")?.request.settings.serviceTier).toBe("priority");
|
|
const disabled = await host(openaiModels, {
|
|
provider: "openai",
|
|
lunaPriority: false,
|
|
});
|
|
expect(disabled.agents.get("fast-lo")?.request.settings).toEqual({});
|
|
|
|
const custom = customAssignments("openai");
|
|
const customResult = await host(openaiModels, {
|
|
mode: "custom",
|
|
agents: custom,
|
|
});
|
|
expect(customResult.agents.get("fast-lo")?.request.settings.serviceTier).toBeUndefined();
|
|
expect(customResult.agents.get("fast-hi")?.request.settings.serviceTier).toBeUndefined();
|
|
|
|
const other = await host(allModels, { provider: "test" });
|
|
expect(other.agents.get("fast-lo")?.request.settings.serviceTier).toBeUndefined();
|
|
expect(() => settings.parse({ mode: "gpt", provider: "test", lunaPriority: true })).toThrow();
|
|
});
|
|
|
|
test("coordinator uses inherited effort and ignores the legacy custom assignment", async () => {
|
|
for (const options of [{ provider: "test" }, { mode: "gpt", provider: "test" }] as const) {
|
|
const result = await host(allModels, options);
|
|
expect(result.agents.get("coordinate")?.model).toMatchObject({
|
|
id: "gpt-6-astra",
|
|
variant: "low",
|
|
});
|
|
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
|
}
|
|
|
|
const fallback = await host(allModels.slice(0, 2));
|
|
expect(fallback.agents.get("coordinate")?.model).toMatchObject({
|
|
id: "gpt-5.6-sol",
|
|
variant: "low",
|
|
});
|
|
expect(fallback.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
|
|
|
const custom = await host([...allModels, model("local")], {
|
|
mode: "custom",
|
|
agents: {
|
|
...customAssignments(),
|
|
coordinate: { model: "test/local", effort: "xhigh" },
|
|
},
|
|
});
|
|
expect(custom.agents.get("coordinate")?.model).toMatchObject({
|
|
id: "gpt-6-astra",
|
|
variant: "low",
|
|
});
|
|
expect(custom.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
|
});
|
|
|
|
test("the current host model API filters discovery and reloads changed availability", async () => {
|
|
const disabled = { ...reasoningModel("gpt-5.6-luna"), enabled: false };
|
|
const other = { ...reasoningModel("gpt-5.6-sol"), providerID: Provider.ID.make("other") };
|
|
expect((await host([disabled, other, ...allModels.slice(1)])).agents.has("coordinate")).toBe(
|
|
false,
|
|
);
|
|
const result = await host([]);
|
|
await result.refresh(allModels);
|
|
expect(String(result.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
|
|
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low");
|
|
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
|
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");
|
|
expect(String(result.agents.get("coordinate")?.model?.variant)).toBe("low");
|
|
expect(result.agents.get("coordinate")?.request.settings.reasoningEffort).toBeUndefined();
|
|
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("model updates require an exact workspace match within the same directory", async () => {
|
|
const result = await host([], { provider: "test" }, {}, [], "/test", "workspace-a");
|
|
|
|
result.publishModels(allModels, { directory: "/test" });
|
|
await Bun.sleep(0);
|
|
expect(result.agents.has("coordinate")).toBe(false);
|
|
|
|
result.publishModels(allModels, { directory: "/test", workspaceID: "workspace-b" });
|
|
await Bun.sleep(0);
|
|
expect(result.agents.has("coordinate")).toBe(false);
|
|
|
|
const reloaded = result.waitForReload();
|
|
result.publishModels(allModels, { directory: "/test", workspaceID: "workspace-a" });
|
|
await reloaded;
|
|
expect(result.agents.has("coordinate")).toBe(true);
|
|
});
|
|
|
|
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)");
|
|
});
|