+10
-1
@@ -238,7 +238,16 @@ function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string)
|
||||
// The adapter reads these domain objects at construction; unimplemented operations
|
||||
// stay undefined and fail loudly if the plugin starts using them.
|
||||
const context = {
|
||||
options: { provider: "fixture" },
|
||||
options: {
|
||||
agents: {
|
||||
"fast-lo": "fixture/gpt-6-luna#low",
|
||||
"fast-hi": "fixture/gpt-6-luna#high",
|
||||
"good-lo": "fixture/gpt-6.1-sol#low",
|
||||
"good-hi": "fixture/gpt-6.1-sol#high",
|
||||
"deep-lo": "fixture/gpt-6-astra#low",
|
||||
"deep-hi": "fixture/gpt-6-astra#high",
|
||||
},
|
||||
},
|
||||
location,
|
||||
app: { version: "2.0.22-fixture" },
|
||||
command: {},
|
||||
|
||||
+46
-6
@@ -30,6 +30,16 @@ const unrelated = {
|
||||
},
|
||||
};
|
||||
const names = ["agents", "multi-work", "quota", "optchat"];
|
||||
const empty = {
|
||||
agents: {
|
||||
"fast-lo": "",
|
||||
"fast-hi": "",
|
||||
"good-lo": "",
|
||||
"good-hi": "",
|
||||
"deep-lo": "",
|
||||
"deep-hi": "",
|
||||
},
|
||||
};
|
||||
const helper = new URL("../scripts/install.ts", import.meta.url);
|
||||
async function run(
|
||||
command: string[],
|
||||
@@ -123,7 +133,9 @@ test("upgrades retain selection and options; explicit selection replaces only ow
|
||||
const f = await fixture();
|
||||
await install(f.source, f.config, "agents,quota");
|
||||
const current = await contents(f.config);
|
||||
current.plugins[1].options = { mode: "gpt", provider: "openai", lunaPriority: false };
|
||||
current.plugins[1].options = {
|
||||
agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" },
|
||||
};
|
||||
current.plugins.push({ package: "after", options: { untouched: 1 } });
|
||||
await writeFile(join(f.config, "opencode.json"), JSON.stringify(current));
|
||||
await install(f.source, f.config);
|
||||
@@ -159,7 +171,7 @@ test("mixed string/object registrations upgrade without flags and preserve unrel
|
||||
before,
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
after,
|
||||
@@ -188,7 +200,7 @@ test("owned string registrations retain their selection and explicit selection r
|
||||
"before",
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
@@ -216,7 +228,7 @@ for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absol
|
||||
"before",
|
||||
{
|
||||
package: "./plugins/stanp-opencode/agents",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: true },
|
||||
options: empty,
|
||||
},
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
@@ -225,6 +237,34 @@ for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absol
|
||||
});
|
||||
}
|
||||
|
||||
test("installer writes empty option skeletons, fills only missing keys, and is idempotent", async () => {
|
||||
const f = await fixture();
|
||||
await install(f.source, f.config, "agents,multi-work,quota,optchat");
|
||||
const fresh = await contents(f.config);
|
||||
expect(fresh.plugins.slice(1)).toEqual([
|
||||
{ package: "./plugins/stanp-opencode/agents", options: empty },
|
||||
{ package: "./plugins/stanp-opencode/multi-work" },
|
||||
{ package: "./plugins/stanp-opencode/quota" },
|
||||
{
|
||||
package: "./plugins/stanp-opencode/optchat",
|
||||
options: { compactionModel: "", dataDir: "" },
|
||||
},
|
||||
]);
|
||||
fresh.plugins[1].options = { agents: { "fast-lo": "openai/gpt-6-luna#low" }, extra: 1 };
|
||||
fresh.plugins[4].options = { compactionModel: "acme/cheap" };
|
||||
await writeFile(join(f.config, "opencode.json"), JSON.stringify(fresh));
|
||||
await install(f.source, f.config);
|
||||
const filled = await contents(f.config);
|
||||
expect(filled.plugins[1].options).toEqual({
|
||||
agents: { ...empty.agents, "fast-lo": "openai/gpt-6-luna#low" },
|
||||
extra: 1,
|
||||
});
|
||||
expect(filled.plugins[4].options).toEqual({ compactionModel: "acme/cheap", dataDir: "" });
|
||||
expect(filled).toMatchObject(unrelated);
|
||||
await install(f.source, f.config);
|
||||
expect(await contents(f.config)).toEqual(filled);
|
||||
});
|
||||
|
||||
test("local installer retains executable permissions and supports direct invocation", async () => {
|
||||
const f = await fixture();
|
||||
const path = new URL("../install-local.sh", import.meta.url).pathname;
|
||||
@@ -244,7 +284,7 @@ test("replaces the legacy monolith without duplicates and retains Agents options
|
||||
{ package: "before" },
|
||||
{
|
||||
package: "./plugins/stanp-opencode",
|
||||
options: { mode: "gpt", provider: "openai", lunaPriority: false },
|
||||
options: { agents: { "fast-lo": "openai/gpt-6-luna#low" } },
|
||||
},
|
||||
{ package: "stanp-opencode" },
|
||||
{ package: "after" },
|
||||
@@ -263,7 +303,7 @@ test("replaces the legacy monolith without duplicates and retains Agents options
|
||||
.map((name) => `./plugins/stanp-opencode/${name}`),
|
||||
"after",
|
||||
]);
|
||||
expect(result.plugins[1].options.lunaPriority).toBe(false);
|
||||
expect(result.plugins[1].options.agents["fast-lo"]).toBe("openai/gpt-6-luna#low");
|
||||
expect(await Bun.file(join(f.config, "plugins", "stanp-opencode.js")).exists()).toBe(false);
|
||||
expect(result).toMatchObject(unrelated);
|
||||
});
|
||||
|
||||
@@ -437,3 +437,26 @@ test("checkpoints are not logged, provider-executed calls end a turn, and empty
|
||||
"user: next",
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([{}, { compactionModel: "", dataDir: "" }])(
|
||||
"without compactionModel (%j), optchat registers nothing",
|
||||
async (options) => {
|
||||
const registered: string[] = [];
|
||||
const record = (name: string) => async () => {
|
||||
registered.push(name);
|
||||
return { dispose: async () => {} };
|
||||
};
|
||||
const context = {
|
||||
options,
|
||||
tool: { transform: record("tool") },
|
||||
command: { transform: record("command") },
|
||||
session: { hook: record("hook") },
|
||||
};
|
||||
// SAFETY: setup must return before touching any host service; a call is recorded and fails the test.
|
||||
const setup = optchat.setup as (
|
||||
context: Pick<Plugin.Context, "options">,
|
||||
) => ReturnType<typeof optchat.setup>;
|
||||
expect(await setup(context)).toBeUndefined();
|
||||
expect(registered).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
+43
-41
@@ -8,7 +8,7 @@ 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 { model } from "../src/model.js";
|
||||
import { z } from "zod";
|
||||
import { AgentsRpc } from "../src/agents-rpc.js";
|
||||
|
||||
@@ -23,9 +23,17 @@ const models = ["gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra"].map((id) => ({
|
||||
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"] = { provider: "test" },
|
||||
options: Plugin.Context["options"] = { agents },
|
||||
existingSkill?: Skill.Info,
|
||||
) {
|
||||
const agents = new Map<string, DeepMutable<Agent.Info>>();
|
||||
@@ -233,7 +241,7 @@ test("Agents is independently loadable with no quota, storage, MCP, or custom to
|
||||
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);
|
||||
const h = await host(models, { agents }, original);
|
||||
expect(h.skills.get("anti-overengineering")).toEqual(original);
|
||||
});
|
||||
|
||||
@@ -250,26 +258,44 @@ test("worker availability, deep fallback, and model refresh remain provider awar
|
||||
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}` }]),
|
||||
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("empty deep pair disables deep; partially filled required assignments fail", async () => {
|
||||
const h = await host(models, { agents: { ...agents, "deep-lo": "", "deep-hi": "" } });
|
||||
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol");
|
||||
expect(h.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("assignments are provider-qualified with optional deep pair and variant", 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], { mode: "custom", agents: assignments });
|
||||
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(settings.safeParse({ mode: "gpt", provider: "test", lunaPriority: true }).success).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",
|
||||
);
|
||||
expect(
|
||||
settings.safeParse({
|
||||
mode: "custom",
|
||||
agents: { ...assignments, "fast-lo": { model: "unqualified", effort: "low" } },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
await expect(
|
||||
host(models, { agents: { ...agents, "fast-lo": "unqualified#low" } }),
|
||||
).rejects.toThrow("agents fast-lo must be provider/model[#variant]");
|
||||
});
|
||||
|
||||
test("live final build permissions reach workers, coordinate and the native Agents RPC", async () => {
|
||||
@@ -351,30 +377,6 @@ test("reasoning uses advertised variants and falls back to request settings", as
|
||||
});
|
||||
});
|
||||
|
||||
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" })),
|
||||
|
||||
Reference in New Issue
Block a user