diff --git a/README.md b/README.md index f5434e5..2ca1896 100644 --- a/README.md +++ b/README.md @@ -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). -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//`. 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//`. 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 @@ -21,12 +21,11 @@ From a local checkout, run: ```sh bash /path/to/stanp-opencode/install-local.sh --plugins agents,multi-work,quota 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 { @@ -37,39 +36,13 @@ Merge this into `~/.config/opencode/opencode.json`, preserving unrelated setting { "package": "./plugins/stanp-opencode/agents", "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": { - "fast-lo": { "model": "openai/gpt-6-luna", "effort": "low" }, - "fast-hi": { "model": "openai/gpt-6-luna", "effort": "high" }, - "good-lo": { "model": "openai/gpt-6.1-sol", "effort": "low" }, - "good-hi": { "model": "openai/gpt-6.1-sol", "effort": "high" }, - "deep-lo": { "model": "openai/gpt-6-astra", "effort": "low" }, - "deep-hi": { "model": "openai/gpt-6-astra", "effort": "high" }, + "fast-lo": "openai/gpt-6-luna#low", + "fast-hi": "openai/gpt-6-luna#high", + "good-lo": "openai/gpt-6.1-sol#low", + "good-hi": "openai/gpt-6.1-sol#high", + "deep-lo": "openai/gpt-6-astra#low", + "deep-hi": "openai/gpt-6-astra#high", }, }, }, diff --git a/scripts/install.ts b/scripts/install.ts index accc13a..a648a4d 100644 --- a/scripts/install.ts +++ b/scripts/install.ts @@ -9,6 +9,35 @@ const entrySchema = z .object({ package: z.string(), options: z.record(z.string(), z.json()).optional() }) .catchall(z.json()); type Entry = z.infer; +type Options = NonNullable; + +// 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 registrationSchema = z .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)), ); const options: NonNullable = {}; - if (name === "agents" && !prior?.options) - Object.assign(options, { mode: "gpt", provider: "openai", lunaPriority: true }); Object.assign(options, prior?.options); delete options.playwright; + fill(options, skeleton(name)); const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` }); delete entry.options; if (Object.keys(options).length) entry.options = options; diff --git a/src/agents.ts b/src/agents.ts index 28260bb..2e65727 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -1,7 +1,8 @@ import { Agent, Model, Plugin, Provider } from "@opencode/plugin"; import type { ModelInfo } from "@opencode/client"; 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 { descriptions, routingGuide } from "./routing.js"; import { Skill } from "@opencode/schema/skill"; @@ -37,12 +38,16 @@ const delegation = z.object({ agent: z.string() }); export default Plugin.define({ id: "stanp-opencode.agents", async setup(context) { - const selected = settings.parse({ mode: "gpt", ...context.options }); - const lunaPriority = - selected.mode === "gpt" && - selected.provider === "openai" && - selected.lunaPriority === true; - let models = new Map(); + const { agents } = settings.parse(context.options); + if (!workerNames.some((name) => agents?.[name])) return; + const assignments: Partial>> = + {}; + for (const name of workerNames) { + 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(); let inherited: Agent.Info["permissions"] = Agent.Info.default( Agent.ID.make("build"), ).permissions; @@ -64,7 +69,7 @@ export default Plugin.define({ continue; } if (event.type === "model.updated") { - models = await discover(context, lunaPriority); + models = await discover(context); await context.agent.reload(); } else if (event.type === "agent.updated") { // The configured build transform may run after ours. Read the final @@ -84,25 +89,13 @@ export default Plugin.define({ if (!controller.signal.aborted) console.error("stanp-opencode model update failed", error); }); - models = await discover(context, lunaPriority); + models = await discover(context); const { data: initialAgents } = await context.agent.list(); inherited = initialAgents.find((agent) => agent.id === "build")?.permissions ?? inherited; permissionKey = JSON.stringify(inherited); const registration = await context.rpc.register(AgentsRpc, { 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][] = []; const active = new Set(); @@ -136,19 +129,8 @@ export default Plugin.define({ }); await context.agent.transform((editor) => { - const coordinates = (assignment: { model: string }) => { - if (selected.mode === "gpt") - 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 availableFor = (assignment: { providerID: string; id: string }) => + models.get(modelKey(assignment.providerID, assignment.id)); const deepLo = assignments["deep-lo"]; const deep = deepLo !== undefined && @@ -163,30 +145,31 @@ export default Plugin.define({ : []; active.clear(); 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) { const assignment = assignments[name]!; const available = availableFor(assignment)!; editor.update(name, (agent) => { - const hasVariant = available.model.variants.some( - ({ id }) => id === assignment.effort, - ); + const hasVariant = + assignment.variant !== undefined && + available.variants.some(({ id }) => id === assignment.variant); agent.name = Agent.Name.make(name); agent.mode = "subagent"; agent.hidden = false; agent.description = descriptions[name]; agent.model = { - providerID: Provider.ID.make(available.model.providerID), - id: Model.ID.make(available.model.id), + providerID: Provider.ID.make(available.providerID), + 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 = { - settings: hasVariant ? {} : { reasoningEffort: assignment.effort }, + settings: + hasVariant || assignment.variant === undefined + ? {} + : { reasoningEffort: assignment.variant }, headers: {}, body: {}, }; - if (lunaPriority && available.serviceTier) - agent.request.settings.serviceTier = available.serviceTier; agent.permissions = [ ...structuredClone(inherited), { action: "subagent", resource: "*", effect: "deny" }, @@ -195,26 +178,27 @@ export default Plugin.define({ } editor.update("coordinate", (agent) => { const available = availableFor(coordinate)!; - const hasVariant = available.model.variants.some( - ({ id }) => id === coordinate.effort, - ); + const hasVariant = + coordinate.variant !== undefined && + available.variants.some(({ id }) => id === coordinate.variant); agent.name = Agent.Name.make("coordinate"); agent.mode = "primary"; agent.hidden = false; agent.description = "Read, plan, and coordinate specialist workers; never implement directly."; agent.model = { - providerID: Provider.ID.make(available.model.providerID), - id: Model.ID.make(available.model.id), + providerID: Provider.ID.make(available.providerID), + 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 = { - settings: hasVariant ? {} : { reasoningEffort: coordinate.effort }, + settings: + hasVariant || coordinate.variant === undefined + ? {} + : { reasoningEffort: coordinate.variant }, headers: {}, body: {}, }; - if (lunaPriority && available.serviceTier) - agent.request.settings.serviceTier = available.serviceTier; agent.system = routingGuide(deep); agent.permissions = [ { action: "*", resource: "*", effect: "deny" }, diff --git a/src/config.ts b/src/config.ts index 825a195..06c0ad8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,61 +9,10 @@ export const workerNames = [ "deep-hi", ] as const; -const gptAssignment = z.strictObject({ - model: z.string().trim().min(1), - effort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]), +// Each assignment is provider/model[#variant]; an empty string means unset. Without any +// assignment the plugin registers nothing. +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>; diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..7436f94 --- /dev/null +++ b/src/model.ts @@ -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 }; +} diff --git a/src/optchat.ts b/src/optchat.ts index 3c33e8a..8cc6f6c 100644 --- a/src/optchat.ts +++ b/src/optchat.ts @@ -34,18 +34,23 @@ import { type Line, } from "./optchat-core.js"; import { Memory } from "./optchat-memory.js"; +import { model } from "./model.js"; const settings = z.object({ - /** provider/model#variant used for compactions. */ - compactionModel: z.string().default("opencode/claude-haiku-5-5#xhigh"), + /** provider/model[#variant] used for compactions; without it optchat does not apply. */ + compactionModel: z.string().optional(), + /** Empty means the default directory. */ dataDir: z .string() - .default( - join( - process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), - "opencode", - "optchat", - ), + .optional() + .transform( + (value) => + value || + 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. @@ -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({ id: "stanp-opencode.optchat", setup: async (context) => { 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>(); const enabled = new Map(); const workers = new Map(); diff --git a/src/provider.ts b/src/provider.ts index aa97a6e..61c0d6c 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,39 +1,16 @@ import type { Plugin } from "@opencode/plugin"; 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) { 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. const { data } = await context.model.list(); - const models = new Map(); + const models = new Map(); for (const model of data) { - if (model.enabled) - models.set(modelKey(model.providerID, model.id), { - model, - serviceTier: detectPriority ? priority(model) : undefined, - }); + if (model.enabled) models.set(modelKey(model.providerID, model.id), model); } return models; } diff --git a/src/routing.ts b/src/routing.ts index e9364a0..57b7600 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -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, 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) { 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 @@ -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." } -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. -Your medium effort balances orchestration quality and latency. Model tiers are guidance, -not benchmark guarantees. Custom model assignments retain these role responsibilities. +Model tiers are guidance, not benchmark guarantees. 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, diff --git a/tests/composite.test.ts b/tests/composite.test.ts index 516ac77..63bbe00 100644 --- a/tests/composite.test.ts +++ b/tests/composite.test.ts @@ -238,7 +238,16 @@ function host(fixture: Awaited>, 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: {}, diff --git a/tests/install.test.ts b/tests/install.test.ts index a6ee7be..f3330ee 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -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); }); diff --git a/tests/optchat.test.ts b/tests/optchat.test.ts index c3acc7f..7ca4ff4 100644 --- a/tests/optchat.test.ts +++ b/tests/optchat.test.ts @@ -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, + ) => ReturnType; + expect(await setup(context)).toBeUndefined(); + expect(registered).toEqual([]); + }, +); diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 37f3457..8f9ec10 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -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>(); @@ -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" })),