fix: fix configurations
CI / verify (pull_request) Successful in 10s

This commit is contained in:
2026-10-08 09:18:01 +02:00
parent dd71bcb4d4
commit deaa4bfd07
12 changed files with 232 additions and 250 deletions
+9 -36
View File
@@ -12,7 +12,7 @@ Choose any plugin subset; Multi-work requires Agents first.
Quota shows ChatGPT limits for the connected OpenAI account and, when Claude Code is logged in, Claude subscription limits read from `${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json` (read-only; Claude Code refreshes the token). Quota shows ChatGPT limits for the connected OpenAI account and, when Claude Code is logged in, Claude subscription limits read from `${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json` (read-only; Claude Code refreshes the token).
Optchat implements [VictorTaelin's optchat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449). It is off by default; run `/optchat on`, `/optchat off`, or `/optchat status` in a root session. In an enabled session, each turn sees a constant-size view of the whole chat, made of one-line summaries, plus the new message. The model can open any line with the `zoom` tool and read message times with `date`. Optchat does not apply to subagent sessions. Memory is stored in `~/.local/share/opencode/optchat/<sessionID>/`. Compactions run in a child session named "optchat compactions". The plugin accepts two options: `compactionModel` (default `opencode/claude-haiku-5-5#xhigh`) and `dataDir`. Optchat implements [VictorTaelin's optchat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449). It is off by default; run `/optchat on`, `/optchat off`, or `/optchat status` in a root session. In an enabled session, each turn sees a constant-size view of the whole chat, made of one-line summaries, plus the new message. The model can open any line with the `zoom` tool and read message times with `date`. Optchat does not apply to subagent sessions. Memory is stored in `~/.local/share/opencode/optchat/<sessionID>/`. Compactions run in a child session named "optchat compactions". Optchat activates only when the `compactionModel` option is set, in the format `provider/model[#variant]` (for example `opencode/claude-haiku-5-5#xhigh`); without it (or when empty) the plugin registers nothing. A non-empty `dataDir` overrides the memory location. The installer writes both options as empty strings for you to fill in.
## Local installation ## Local installation
@@ -21,12 +21,11 @@ From a local checkout, run:
```sh ```sh
bash /path/to/stanp-opencode/install-local.sh --plugins agents,multi-work,quota bash /path/to/stanp-opencode/install-local.sh --plugins agents,multi-work,quota
opencode service restart opencode service restart
# Connect OpenAI and select coordinate in OpenCode.
``` ```
## GPT configuration ## Configuration
Merge this into `~/.config/opencode/opencode.json`, preserving unrelated settings and plugins; update existing plugin entries instead of appending duplicates. Agents registers nothing until its `agents` option is set. The installer writes every missing option with an empty value (`""`) for you to fill in; an empty value means unset, so a fresh install stays inert. Each assignment is `provider/model[#variant]`; the variant selects the model's reasoning variant, falling back to a `reasoningEffort` request setting when the model does not advertise it. Merge this into `~/.config/opencode/opencode.json`, preserving unrelated settings and plugins; update existing plugin entries instead of appending duplicates. The `deep-lo` and `deep-hi` assignments are optional as a pair; if either is omitted, both deep workers are disabled and orchestration uses `good-lo`.
```jsonc ```jsonc
{ {
@@ -37,39 +36,13 @@ Merge this into `~/.config/opencode/opencode.json`, preserving unrelated setting
{ {
"package": "./plugins/stanp-opencode/agents", "package": "./plugins/stanp-opencode/agents",
"options": { "options": {
"mode": "gpt",
"provider": "openai",
// Priority may cost more; set false to opt out.
"lunaPriority": true,
},
},
{ "package": "./plugins/stanp-opencode/multi-work" },
{ "package": "./plugins/stanp-opencode/quota" },
],
}
```
## Custom model configuration
Merge this into `~/.config/opencode/opencode.json`, preserving unrelated settings and plugins; update existing plugin entries instead of appending duplicates. The `deep-lo` and `deep-hi` assignments are optional as a pair; if either is omitted, both deep workers are disabled and orchestration uses `good-lo`.
```jsonc
{
"$schema": "https://opencode.ai/config.json",
// Required at the root and in worktrees.
"experimental": { "subagent_depth": 2 },
"plugins": [
{
"package": "./plugins/stanp-opencode/agents",
"options": {
"mode": "custom",
"agents": { "agents": {
"fast-lo": { "model": "openai/gpt-6-luna", "effort": "low" }, "fast-lo": "openai/gpt-6-luna#low",
"fast-hi": { "model": "openai/gpt-6-luna", "effort": "high" }, "fast-hi": "openai/gpt-6-luna#high",
"good-lo": { "model": "openai/gpt-6.1-sol", "effort": "low" }, "good-lo": "openai/gpt-6.1-sol#low",
"good-hi": { "model": "openai/gpt-6.1-sol", "effort": "high" }, "good-hi": "openai/gpt-6.1-sol#high",
"deep-lo": { "model": "openai/gpt-6-astra", "effort": "low" }, "deep-lo": "openai/gpt-6-astra#low",
"deep-hi": { "model": "openai/gpt-6-astra", "effort": "high" }, "deep-hi": "openai/gpt-6-astra#high",
}, },
}, },
}, },
+30 -2
View File
@@ -9,6 +9,35 @@ const entrySchema = z
.object({ package: z.string(), options: z.record(z.string(), z.json()).optional() }) .object({ package: z.string(), options: z.record(z.string(), z.json()).optional() })
.catchall(z.json()); .catchall(z.json());
type Entry = z.infer<typeof entrySchema>; type Entry = z.infer<typeof entrySchema>;
type Options = NonNullable<Entry["options"]>;
// Every option the plugins read, empty so users can fill them in; plugins treat "" as unset.
function skeleton(name: (typeof names)[number]): Options {
if (name === "agents")
return {
agents: Object.fromEntries(
["fast-lo", "fast-hi", "good-lo", "good-hi", "deep-lo", "deep-hi"].map((worker) => [
worker,
"",
]),
),
};
if (name === "optchat") return { compactionModel: "", dataDir: "" };
return {};
}
const nested = z.record(z.string(), z.json());
/** Adds missing keys from `defaults` without overwriting existing values. */
function fill(target: Options, defaults: Options) {
for (const [key, value] of Object.entries(defaults)) {
const existing = nested.safeParse(target[key]);
const missing = nested.safeParse(value);
if (target[key] === undefined) target[key] = structuredClone(value);
else if (existing.success && missing.success) {
fill(existing.data, missing.data);
target[key] = existing.data;
}
}
}
const pluginSchema = z.union([z.string(), entrySchema]); const pluginSchema = z.union([z.string(), entrySchema]);
const registrationSchema = z const registrationSchema = z
.union([z.string().transform((packageName) => ({ package: packageName })), entrySchema]) .union([z.string().transform((packageName) => ({ package: packageName })), entrySchema])
@@ -105,10 +134,9 @@ export async function install(source: string, config: string, selection?: string
(name === "agents" && monolith(entry.package)), (name === "agents" && monolith(entry.package)),
); );
const options: NonNullable<Entry["options"]> = {}; const options: NonNullable<Entry["options"]> = {};
if (name === "agents" && !prior?.options)
Object.assign(options, { mode: "gpt", provider: "openai", lunaPriority: true });
Object.assign(options, prior?.options); Object.assign(options, prior?.options);
delete options.playwright; delete options.playwright;
fill(options, skeleton(name));
const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` }); const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` });
delete entry.options; delete entry.options;
if (Object.keys(options).length) entry.options = options; if (Object.keys(options).length) entry.options = options;
+37 -53
View File
@@ -1,7 +1,8 @@
import { Agent, Model, Plugin, Provider } from "@opencode/plugin"; import { Agent, Model, Plugin, Provider } from "@opencode/plugin";
import type { ModelInfo } from "@opencode/client"; import type { ModelInfo } from "@opencode/client";
import { z } from "zod"; import { z } from "zod";
import { gptAgents, settings, workerNames } from "./config.js"; import { settings, workerNames } from "./config.js";
import { model } from "./model.js";
import { discover, modelKey } from "./provider.js"; import { discover, modelKey } from "./provider.js";
import { descriptions, routingGuide } from "./routing.js"; import { descriptions, routingGuide } from "./routing.js";
import { Skill } from "@opencode/schema/skill"; import { Skill } from "@opencode/schema/skill";
@@ -37,12 +38,16 @@ const delegation = z.object({ agent: z.string() });
export default Plugin.define({ export default Plugin.define({
id: "stanp-opencode.agents", id: "stanp-opencode.agents",
async setup(context) { async setup(context) {
const selected = settings.parse({ mode: "gpt", ...context.options }); const { agents } = settings.parse(context.options);
const lunaPriority = if (!workerNames.some((name) => agents?.[name])) return;
selected.mode === "gpt" && const assignments: Partial<Record<(typeof workerNames)[number], ReturnType<typeof model>>> =
selected.provider === "openai" && {};
selected.lunaPriority === true; for (const name of workerNames) {
let models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>(); const reference = agents?.[name];
if (reference) assignments[name] = model(reference, `agents ${name}`);
else if (!name.startsWith("deep-")) throw new Error(`agents ${name} is required`);
}
let models = new Map<string, ModelInfo>();
let inherited: Agent.Info["permissions"] = Agent.Info.default( let inherited: Agent.Info["permissions"] = Agent.Info.default(
Agent.ID.make("build"), Agent.ID.make("build"),
).permissions; ).permissions;
@@ -64,7 +69,7 @@ export default Plugin.define({
continue; continue;
} }
if (event.type === "model.updated") { if (event.type === "model.updated") {
models = await discover(context, lunaPriority); models = await discover(context);
await context.agent.reload(); await context.agent.reload();
} else if (event.type === "agent.updated") { } else if (event.type === "agent.updated") {
// The configured build transform may run after ours. Read the final // The configured build transform may run after ours. Read the final
@@ -84,25 +89,13 @@ export default Plugin.define({
if (!controller.signal.aborted) if (!controller.signal.aborted)
console.error("stanp-opencode model update failed", error); console.error("stanp-opencode model update failed", error);
}); });
models = await discover(context, lunaPriority); models = await discover(context);
const { data: initialAgents } = await context.agent.list(); const { data: initialAgents } = await context.agent.list();
inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited; inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited;
permissionKey = JSON.stringify(inherited); permissionKey = JSON.stringify(inherited);
const registration = await context.rpc.register(AgentsRpc, { const registration = await context.rpc.register(AgentsRpc, {
permissions: async () => inherited.map((rule) => ({ ...rule })), permissions: async () => inherited.map((rule) => ({ ...rule })),
}); });
const assignments =
selected.mode === "gpt"
? Object.fromEntries(
workerNames.map((name) => [
name,
{
...gptAgents[name],
provider: selected.provider,
},
]),
)
: selected.agents;
let workers: (typeof workerNames)[number][] = []; let workers: (typeof workerNames)[number][] = [];
const active = new Set<string>(); const active = new Set<string>();
@@ -136,19 +129,8 @@ export default Plugin.define({
}); });
await context.agent.transform((editor) => { await context.agent.transform((editor) => {
const coordinates = (assignment: { model: string }) => { const availableFor = (assignment: { providerID: string; id: string }) =>
if (selected.mode === "gpt") models.get(modelKey(assignment.providerID, assignment.id));
return { provider: selected.provider, model: assignment.model };
const slash = assignment.model.indexOf("/");
return {
provider: assignment.model.slice(0, slash),
model: assignment.model.slice(slash + 1),
};
};
const availableFor = (assignment: { model: string }) => {
const { provider, model } = coordinates(assignment);
return models.get(modelKey(provider, model));
};
const deepLo = assignments["deep-lo"]; const deepLo = assignments["deep-lo"];
const deep = const deep =
deepLo !== undefined && deepLo !== undefined &&
@@ -163,30 +145,31 @@ export default Plugin.define({
: []; : [];
active.clear(); active.clear();
for (const name of workers) active.add(name); for (const name of workers) active.add(name);
if (workers.length === 0) return; if (workers.length === 0 || coordinate === undefined) return;
for (const name of workers) { for (const name of workers) {
const assignment = assignments[name]!; const assignment = assignments[name]!;
const available = availableFor(assignment)!; const available = availableFor(assignment)!;
editor.update(name, (agent) => { editor.update(name, (agent) => {
const hasVariant = available.model.variants.some( const hasVariant =
({ id }) => id === assignment.effort, assignment.variant !== undefined &&
); available.variants.some(({ id }) => id === assignment.variant);
agent.name = Agent.Name.make(name); agent.name = Agent.Name.make(name);
agent.mode = "subagent"; agent.mode = "subagent";
agent.hidden = false; agent.hidden = false;
agent.description = descriptions[name]; agent.description = descriptions[name];
agent.model = { agent.model = {
providerID: Provider.ID.make(available.model.providerID), providerID: Provider.ID.make(available.providerID),
id: Model.ID.make(available.model.id), id: Model.ID.make(available.id),
}; };
if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.effort); if (hasVariant) agent.model.variant = Model.VariantID.make(assignment.variant!);
agent.request = { agent.request = {
settings: hasVariant ? {} : { reasoningEffort: assignment.effort }, settings:
hasVariant || assignment.variant === undefined
? {}
: { reasoningEffort: assignment.variant },
headers: {}, headers: {},
body: {}, body: {},
}; };
if (lunaPriority && available.serviceTier)
agent.request.settings.serviceTier = available.serviceTier;
agent.permissions = [ agent.permissions = [
...structuredClone(inherited), ...structuredClone(inherited),
{ action: "subagent", resource: "*", effect: "deny" }, { action: "subagent", resource: "*", effect: "deny" },
@@ -195,26 +178,27 @@ export default Plugin.define({
} }
editor.update("coordinate", (agent) => { editor.update("coordinate", (agent) => {
const available = availableFor(coordinate)!; const available = availableFor(coordinate)!;
const hasVariant = available.model.variants.some( const hasVariant =
({ id }) => id === coordinate.effort, coordinate.variant !== undefined &&
); available.variants.some(({ id }) => id === coordinate.variant);
agent.name = Agent.Name.make("coordinate"); agent.name = Agent.Name.make("coordinate");
agent.mode = "primary"; agent.mode = "primary";
agent.hidden = false; agent.hidden = false;
agent.description = agent.description =
"Read, plan, and coordinate specialist workers; never implement directly."; "Read, plan, and coordinate specialist workers; never implement directly.";
agent.model = { agent.model = {
providerID: Provider.ID.make(available.model.providerID), providerID: Provider.ID.make(available.providerID),
id: Model.ID.make(available.model.id), id: Model.ID.make(available.id),
}; };
if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.effort); if (hasVariant) agent.model.variant = Model.VariantID.make(coordinate.variant!);
agent.request = { agent.request = {
settings: hasVariant ? {} : { reasoningEffort: coordinate.effort }, settings:
hasVariant || coordinate.variant === undefined
? {}
: { reasoningEffort: coordinate.variant },
headers: {}, headers: {},
body: {}, body: {},
}; };
if (lunaPriority && available.serviceTier)
agent.request.settings.serviceTier = available.serviceTier;
agent.system = routingGuide(deep); agent.system = routingGuide(deep);
agent.permissions = [ agent.permissions = [
{ action: "*", resource: "*", effect: "deny" }, { action: "*", resource: "*", effect: "deny" },
+6 -57
View File
@@ -9,61 +9,10 @@ export const workerNames = [
"deep-hi", "deep-hi",
] as const; ] as const;
const gptAssignment = z.strictObject({ // Each assignment is provider/model[#variant]; an empty string means unset. Without any
model: z.string().trim().min(1), // assignment the plugin registers nothing.
effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]), export const settings = z.object({
agents: z
.strictObject(Object.fromEntries(workerNames.map((name) => [name, z.string().optional()])))
.optional(),
}); });
const customAssignment = gptAssignment.extend({
model: z
.string()
.trim()
.refine((model) => {
const slash = model.indexOf("/");
return slash > 0 && slash < model.length - 1;
}, "model must use the provider/model convention"),
});
export const settings = z.discriminatedUnion("mode", [
z
.strictObject({
mode: z.literal("gpt"),
provider: z.string().trim().min(1).default("openai"),
lunaPriority: z.boolean().optional(),
})
.transform((value, context) => {
if (value.provider !== "openai" && value.lunaPriority !== undefined) {
context.addIssue({
code: "custom",
path: ["lunaPriority"],
message: "lunaPriority is only supported by the openai provider",
});
return z.NEVER;
}
return value.provider === "openai"
? { ...value, lunaPriority: value.lunaPriority ?? true }
: value;
}),
z.strictObject({
mode: z.literal("custom"),
agents: z.strictObject({
"fast-lo": customAssignment,
"fast-hi": customAssignment,
"good-lo": customAssignment,
"good-hi": customAssignment,
"deep-lo": customAssignment.optional(),
"deep-hi": customAssignment.optional(),
// Deprecated compatibility field. Coordinate is derived from deep-lo/good-lo.
coordinate: customAssignment.optional(),
}),
}),
]);
export const gptAgents = {
"fast-lo": { model: "gpt-6-luna", effort: "low" },
"fast-hi": { model: "gpt-6-luna", effort: "high" },
"good-lo": { model: "gpt-6.1-sol", effort: "low" },
"good-hi": { model: "gpt-6.1-sol", effort: "high" },
"deep-lo": { model: "gpt-6-astra", effort: "low" },
"deep-hi": { model: "gpt-6-astra", effort: "high" },
} satisfies Record<(typeof workerNames)[number], z.infer<typeof gptAssignment>>;
+9
View File
@@ -0,0 +1,9 @@
/** Parses `provider/model[#variant]`; `name` prefixes the error for the offending option. */
export function model(reference: string, name: string) {
const [path = "", variant] = reference.split("#");
const slash = path.indexOf("/");
if (slash <= 0) throw new Error(`${name} must be provider/model[#variant]: ${reference}`);
const providerID = path.slice(0, slash);
const id = path.slice(slash + 1);
return variant ? { providerID, id, variant } : { providerID, id };
}
+15 -19
View File
@@ -34,18 +34,23 @@ import {
type Line, type Line,
} from "./optchat-core.js"; } from "./optchat-core.js";
import { Memory } from "./optchat-memory.js"; import { Memory } from "./optchat-memory.js";
import { model } from "./model.js";
const settings = z.object({ const settings = z.object({
/** provider/model#variant used for compactions. */ /** provider/model[#variant] used for compactions; without it optchat does not apply. */
compactionModel: z.string().default("opencode/claude-haiku-5-5#xhigh"), compactionModel: z.string().optional(),
/** Empty means the default directory. */
dataDir: z dataDir: z
.string() .string()
.default( .optional()
join( .transform(
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), (value) =>
"opencode", value ||
"optchat", join(
), process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
"opencode",
"optchat",
),
), ),
}); });
// SAFETY: Anthropic lowering reads only `type` and `ttlSeconds` from a hint; 300 s is the 5-minute bucket. // SAFETY: Anthropic lowering reads only `type` and `ttlSeconds` from a hint; 300 s is the 5-minute bucket.
@@ -199,21 +204,12 @@ function turnStart(history: readonly Entry[]) {
); );
} }
function model(reference: string) {
const [path = "", variant] = reference.split("#");
const slash = path.indexOf("/");
if (slash <= 0)
throw new Error(`optchat compactionModel must be provider/model[#variant]: ${reference}`);
const providerID = path.slice(0, slash);
const id = path.slice(slash + 1);
return variant ? { providerID, id, variant } : { providerID, id };
}
export default Plugin.define({ export default Plugin.define({
id: "stanp-opencode.optchat", id: "stanp-opencode.optchat",
setup: async (context) => { setup: async (context) => {
const options = settings.parse(context.options); const options = settings.parse(context.options);
const compactionModel = model(options.compactionModel); if (!options.compactionModel) return;
const compactionModel = model(options.compactionModel, "optchat compactionModel");
const chats = new Map<string, Promise<Chat>>(); const chats = new Map<string, Promise<Chat>>();
const enabled = new Map<string, boolean>(); const enabled = new Map<string, boolean>();
const workers = new Map<string, string>(); const workers = new Map<string, string>();
+3 -26
View File
@@ -1,39 +1,16 @@
import type { Plugin } from "@opencode/plugin"; import type { Plugin } from "@opencode/plugin";
import type { ModelInfo } from "@opencode/client"; import type { ModelInfo } from "@opencode/client";
import { z } from "zod";
function priority(model: ModelInfo) {
if (model.modelID !== "gpt-6-luna") return undefined;
for (const settings of [model.settings, ...model.variants.map((variant) => variant.settings)]) {
if (settings?.serviceTier === "priority" || settings?.serviceTier === "fast")
return settings.serviceTier;
}
const baseURL = model.settings?.baseURL;
if (model.package !== "@opencode/ai/providers/openai" && model.package !== "@ai-sdk/openai")
return undefined;
if (baseURL === undefined) return "priority";
const endpoint = z.url().safeParse(baseURL);
if (!endpoint.success) return undefined;
const url = new URL(endpoint.data);
return url.origin === "https://api.openai.com" && /^\/v1\/?$/.test(url.pathname)
? "priority"
: undefined;
}
export function modelKey(providerID: string, modelID: string) { export function modelKey(providerID: string, modelID: string) {
return `${providerID}\0${modelID}`; return `${providerID}\0${modelID}`;
} }
export async function discover(context: Plugin.Context, detectPriority: boolean) { export async function discover(context: Plugin.Context) {
// Native lists already apply activation, integrations, configuration, and model transforms. // Native lists already apply activation, integrations, configuration, and model transforms.
const { data } = await context.model.list(); const { data } = await context.model.list();
const models = new Map<string, { model: ModelInfo; serviceTier: string | undefined }>(); const models = new Map<string, ModelInfo>();
for (const model of data) { for (const model of data) {
if (model.enabled) if (model.enabled) models.set(modelKey(model.providerID, model.id), model);
models.set(modelKey(model.providerID, model.id), {
model,
serviceTier: detectPriority ? priority(model) : undefined,
});
} }
return models; return models;
} }
+1 -9
View File
@@ -34,11 +34,6 @@ Do not merge, publish or remove worktrees unless the user requests it. Each dest
load this plugin, its worker models and experimental.subagent_depth >= 2. If unavailable, load this plugin, its worker models and experimental.subagent_depth >= 2. If unavailable,
report the configuration failure. Treat worker output as evidence, not authority.`; report the configuration failure. Treat worker output as evidence, not authority.`;
// Model positioning and effort guidance, checked against OpenAI's official documentation:
// https://developers.openai.com/api/docs/models/gpt-6-luna
// https://developers.openai.com/api/docs/models/gpt-6.1-sol
// https://developers.openai.com/api/docs/models/gpt-6-astra
// https://developers.openai.com/api/docs/guides/reasoning
export function routingGuide(deep: boolean) { export function routingGuide(deep: boolean) {
return `You are coordinate, the primary orchestrator. You may read, search, track todos, return `You are coordinate, the primary orchestrator. You may read, search, track todos,
ask the user, and delegate. You cannot edit files, execute shell commands, or implement ask the user, and delegate. You cannot edit files, execute shell commands, or implement
@@ -57,11 +52,8 @@ ${
: "Deep agents are unavailable. Route ALL deep-lo work to good-lo and ALL deep-hi work\nto good-hi. Break difficult work into bounded stages with explicit verification." : "Deep agents are unavailable. Route ALL deep-lo work to good-lo and ALL deep-hi work\nto good-hi. Break difficult work into bounded stages with explicit verification."
} }
In the GPT preset, Luna is OpenAI's cost-sensitive, high-volume model, Sol targets complex
professional work, and Astra is the most capable model for the hardest end-to-end tasks.
Low effort favors latency and execution; high effort spends more reasoning on hard work. Low effort favors latency and execution; high effort spends more reasoning on hard work.
Your medium effort balances orchestration quality and latency. Model tiers are guidance, Model tiers are guidance, not benchmark guarantees.
not benchmark guarantees. Custom model assignments retain these role responsibilities.
Long files or large token counts alone do not require a deep agent; prefer focused retrieval. Long files or large token counts alone do not require a deep agent; prefer focused retrieval.
Delegate self-contained assignments: goal, relevant context/files, constraints, ownership, Delegate self-contained assignments: goal, relevant context/files, constraints, ownership,
+10 -1
View File
@@ -238,7 +238,16 @@ function host(fixture: Awaited<ReturnType<typeof scenario>>, directory: string)
// The adapter reads these domain objects at construction; unimplemented operations // The adapter reads these domain objects at construction; unimplemented operations
// stay undefined and fail loudly if the plugin starts using them. // stay undefined and fail loudly if the plugin starts using them.
const context = { 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, location,
app: { version: "2.0.22-fixture" }, app: { version: "2.0.22-fixture" },
command: {}, command: {},
+46 -6
View File
@@ -30,6 +30,16 @@ const unrelated = {
}, },
}; };
const names = ["agents", "multi-work", "quota", "optchat"]; 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); const helper = new URL("../scripts/install.ts", import.meta.url);
async function run( async function run(
command: string[], command: string[],
@@ -123,7 +133,9 @@ test("upgrades retain selection and options; explicit selection replaces only ow
const f = await fixture(); const f = await fixture();
await install(f.source, f.config, "agents,quota"); await install(f.source, f.config, "agents,quota");
const current = await contents(f.config); 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 } }); current.plugins.push({ package: "after", options: { untouched: 1 } });
await writeFile(join(f.config, "opencode.json"), JSON.stringify(current)); await writeFile(join(f.config, "opencode.json"), JSON.stringify(current));
await install(f.source, f.config); await install(f.source, f.config);
@@ -159,7 +171,7 @@ test("mixed string/object registrations upgrade without flags and preserve unrel
before, before,
{ {
package: "./plugins/stanp-opencode/agents", package: "./plugins/stanp-opencode/agents",
options: { mode: "gpt", provider: "openai", lunaPriority: true }, options: empty,
}, },
{ package: "./plugins/stanp-opencode/quota" }, { package: "./plugins/stanp-opencode/quota" },
after, after,
@@ -188,7 +200,7 @@ test("owned string registrations retain their selection and explicit selection r
"before", "before",
{ {
package: "./plugins/stanp-opencode/agents", package: "./plugins/stanp-opencode/agents",
options: { mode: "gpt", provider: "openai", lunaPriority: true }, options: empty,
}, },
{ package: "./plugins/stanp-opencode/multi-work" }, { package: "./plugins/stanp-opencode/multi-work" },
{ package: "./plugins/stanp-opencode/quota" }, { package: "./plugins/stanp-opencode/quota" },
@@ -216,7 +228,7 @@ for (const target of ["./plugins/stanp-opencode", "stanp-opencode@1.0.0", "absol
"before", "before",
{ {
package: "./plugins/stanp-opencode/agents", package: "./plugins/stanp-opencode/agents",
options: { mode: "gpt", provider: "openai", lunaPriority: true }, options: empty,
}, },
{ package: "./plugins/stanp-opencode/multi-work" }, { package: "./plugins/stanp-opencode/multi-work" },
{ package: "./plugins/stanp-opencode/quota" }, { 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 () => { test("local installer retains executable permissions and supports direct invocation", async () => {
const f = await fixture(); const f = await fixture();
const path = new URL("../install-local.sh", import.meta.url).pathname; 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: "before" },
{ {
package: "./plugins/stanp-opencode", package: "./plugins/stanp-opencode",
options: { mode: "gpt", provider: "openai", lunaPriority: false }, options: { agents: { "fast-lo": "openai/gpt-6-luna#low" } },
}, },
{ package: "stanp-opencode" }, { package: "stanp-opencode" },
{ package: "after" }, { package: "after" },
@@ -263,7 +303,7 @@ test("replaces the legacy monolith without duplicates and retains Agents options
.map((name) => `./plugins/stanp-opencode/${name}`), .map((name) => `./plugins/stanp-opencode/${name}`),
"after", "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(await Bun.file(join(f.config, "plugins", "stanp-opencode.js")).exists()).toBe(false);
expect(result).toMatchObject(unrelated); expect(result).toMatchObject(unrelated);
}); });
+23
View File
@@ -437,3 +437,26 @@ test("checkpoints are not logged, provider-executed calls end a turn, and empty
"user: next", "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
View File
@@ -8,7 +8,7 @@ import type { DeepMutable } from "@opencode/plugin/promise/types";
import type { Skill } from "@opencode/schema/skill"; import type { Skill } from "@opencode/schema/skill";
import type { ModelInfo } from "@opencode/client"; import type { ModelInfo } from "@opencode/client";
import plugin from "../src/agents.js"; 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 { z } from "zod";
import { AgentsRpc } from "../src/agents-rpc.js"; 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 }, 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( async function host(
catalog: ModelInfo[] = models, catalog: ModelInfo[] = models,
options: Plugin.Context["options"] = { provider: "test" }, options: Plugin.Context["options"] = { agents },
existingSkill?: Skill.Info, existingSkill?: Skill.Info,
) { ) {
const agents = new Map<string, DeepMutable<Agent.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 () => { test("preserves an existing user anti-overengineering skill", async () => {
const first = await host(); const first = await host();
const original = { ...first.skills.get("anti-overengineering")!, content: "user definition" }; 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); 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"); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6-astra");
}); });
test("custom models use provider-qualified names and optional deep assignments", async () => { test("without agents options or with the empty skeleton nothing is registered", async () => {
const assignments = Object.fromEntries( const skeleton = Object.fromEntries(Object.keys(agents).map((name) => [name, ""]));
workerNames for (const options of [{}, { agents: skeleton }]) {
.filter((name) => !name.startsWith("deep-")) const h = await host(models, options);
.map((name) => [name, { ...gptAgents[name], model: `test/${gptAgents[name].model}` }]), 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 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?.providerID)).toBe("test");
expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol"); expect(String(h.agents.get("coordinate")?.model?.id)).toBe("gpt-6.1-sol");
expect(h.agents.has("deep-hi")).toBe(false); expect(h.agents.has("deep-hi")).toBe(false);
expect(settings.safeParse({ mode: "gpt", provider: "test", lunaPriority: true }).success).toBe( expect(model("a/b/c#high", "n")).toEqual({ providerID: "a", id: "b/c", variant: "high" });
false, 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( await expect(
settings.safeParse({ host(models, { agents: { ...agents, "fast-lo": "unqualified#low" } }),
mode: "custom", ).rejects.toThrow("agents fast-lo must be provider/model[#variant]");
agents: { ...assignments, "fast-lo": { model: "unqualified", effort: "low" } },
}).success,
).toBe(false);
}); });
test("live final build permissions reach workers, coordinate and the native Agents RPC", async () => { 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 () => { test("disabled catalog entries do not authorize worker availability", async () => {
const h = await host( const h = await host(
models.map((model) => ({ ...model, enabled: model.id !== "gpt-6-astra" })), models.map((model) => ({ ...model, enabled: model.id !== "gpt-6-astra" })),