diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 02c8aa0d2..4cec6fe4a 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -102,6 +102,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -225,6 +228,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -348,6 +354,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -471,6 +480,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -594,6 +606,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -717,6 +732,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -840,6 +858,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -963,6 +984,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1086,6 +1110,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1209,6 +1236,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1332,6 +1362,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1455,6 +1488,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1578,6 +1614,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1701,6 +1740,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1824,6 +1866,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "category": { "type": "string" }, @@ -1954,6 +1999,9 @@ "model": { "type": "string" }, + "variant": { + "type": "string" + }, "temperature": { "type": "number", "minimum": 0, diff --git a/src/agents/types.ts b/src/agents/types.ts index 8cbe78d90..a0f6d26d7 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -76,6 +76,7 @@ export type AgentName = BuiltinAgentName export type AgentOverrideConfig = Partial & { prompt_append?: string + variant?: string } export type AgentOverrides = Partial> diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 9f5e2d3cb..336ed628a 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -127,6 +127,31 @@ describe("buildAgent with category and skills", () => { expect(agent.temperature).toBe(0.7) }) + test("agent with category inherits variant", () => { + // #given + const source = { + "test-agent": () => + ({ + description: "Test agent", + category: "custom-category", + }) as AgentConfig, + } + + const categories = { + "custom-category": { + model: "openai/gpt-5.2", + variant: "xhigh", + }, + } + + // #when + const agent = buildAgent(source["test-agent"], undefined, categories) + + // #then + expect(agent.model).toBe("openai/gpt-5.2") + expect(agent.variant).toBe("xhigh") + }) + test("agent with skills has content prepended to prompt", () => { // #given const source = { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index de1765cfa..808a6ef36 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -1,5 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types" +import type { CategoriesConfig, CategoryConfig } from "../config/schema" import { createSisyphusAgent } from "./sisyphus" import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian" @@ -47,12 +48,19 @@ function isFactory(source: AgentSource): source is AgentFactory { return typeof source === "function" } -export function buildAgent(source: AgentSource, model?: string): AgentConfig { +export function buildAgent( + source: AgentSource, + model?: string, + categories?: CategoriesConfig +): AgentConfig { const base = isFactory(source) ? source(model) : source + const categoryConfigs: Record = categories + ? { ...DEFAULT_CATEGORIES, ...categories } + : DEFAULT_CATEGORIES - const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[] } + const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string } if (agentWithCategory.category) { - const categoryConfig = DEFAULT_CATEGORIES[agentWithCategory.category] + const categoryConfig = categoryConfigs[agentWithCategory.category] if (categoryConfig) { if (!base.model) { base.model = categoryConfig.model @@ -60,6 +68,9 @@ export function buildAgent(source: AgentSource, model?: string): AgentConfig { if (base.temperature === undefined && categoryConfig.temperature !== undefined) { base.temperature = categoryConfig.temperature } + if (base.variant === undefined && categoryConfig.variant !== undefined) { + base.variant = categoryConfig.variant + } } } @@ -118,11 +129,16 @@ export function createBuiltinAgents( disabledAgents: BuiltinAgentName[] = [], agentOverrides: AgentOverrides = {}, directory?: string, - systemDefaultModel?: string + systemDefaultModel?: string, + categories?: CategoriesConfig ): Record { const result: Record = {} const availableAgents: AvailableAgent[] = [] + const mergedCategories = categories + ? { ...DEFAULT_CATEGORIES, ...categories } + : DEFAULT_CATEGORIES + for (const [name, source] of Object.entries(agentSources)) { const agentName = name as BuiltinAgentName @@ -133,7 +149,7 @@ export function createBuiltinAgents( const override = agentOverrides[agentName] const model = override?.model - let config = buildAgent(source, model) + let config = buildAgent(source, model, mergedCategories) if (agentName === "librarian" && directory && config.prompt) { const envContext = createEnvContext() diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index f75a1d415..9f04ba578 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { AgentOverrideConfigSchema, BuiltinCategoryNameSchema, OhMyOpenCodeConfigSchema } from "./schema" +import { AgentOverrideConfigSchema, BuiltinCategoryNameSchema, CategoryConfigSchema, OhMyOpenCodeConfigSchema } from "./schema" describe("disabled_mcps schema", () => { test("should accept built-in MCP names", () => { @@ -174,6 +174,33 @@ describe("AgentOverrideConfigSchema", () => { }) }) + describe("variant field", () => { + test("accepts variant as optional string", () => { + // #given + const config = { variant: "high" } + + // #when + const result = AgentOverrideConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.variant).toBe("high") + } + }) + + test("rejects non-string variant", () => { + // #given + const config = { variant: 123 } + + // #when + const result = AgentOverrideConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(false) + }) + }) + describe("skills field", () => { test("accepts skills as optional string array", () => { // #given @@ -303,6 +330,33 @@ describe("AgentOverrideConfigSchema", () => { }) }) +describe("CategoryConfigSchema", () => { + test("accepts variant as optional string", () => { + // #given + const config = { model: "openai/gpt-5.2", variant: "xhigh" } + + // #when + const result = CategoryConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.variant).toBe("xhigh") + } + }) + + test("rejects non-string variant", () => { + // #given + const config = { model: "openai/gpt-5.2", variant: 123 } + + // #when + const result = CategoryConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(false) + }) +}) + describe("BuiltinCategoryNameSchema", () => { test("accepts all builtin category names", () => { // #given diff --git a/src/config/schema.ts b/src/config/schema.ts index 2d5515393..dba799cb8 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -97,6 +97,7 @@ export const BuiltinCommandNameSchema = z.enum([ export const AgentOverrideConfigSchema = z.object({ /** @deprecated Use `category` instead. Model is inherited from category defaults. */ model: z.string().optional(), + variant: z.string().optional(), /** Category name to inherit model and other settings from CategoryConfig */ category: z.string().optional(), /** Skill names to inject into agent prompt */ @@ -153,6 +154,7 @@ export const SisyphusAgentConfigSchema = z.object({ export const CategoryConfigSchema = z.object({ model: z.string(), + variant: z.string().optional(), temperature: z.number().min(0).max(2).optional(), top_p: z.number().min(0).max(1).optional(), maxTokens: z.number().optional(), diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index a77766f8a..8c384211b 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -27,7 +27,7 @@ export interface BackgroundTask { error?: string progress?: TaskProgress parentModel?: { providerID: string; modelID: string } - model?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string; variant?: string } /** Agent name used for concurrency tracking */ concurrencyKey?: string /** Parent session's agent name for notification */ @@ -46,7 +46,7 @@ export interface LaunchInput { parentMessageID: string parentModel?: { providerID: string; modelID: string } parentAgent?: string - model?: { providerID: string; modelID: string } + model?: { providerID: string; modelID: string; variant?: string } skills?: string[] skillContent?: string } diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index 022ffe1e1..ec470988d 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -210,4 +210,26 @@ describe("keyword-detector session filtering", () => { expect(output.message.variant).toBe("max") expect(toastCalls).toContain("Ultrawork Mode Activated") }) + + test("should not override existing variant", async () => { + // #given - main session set with pre-existing variant + setMainSession("main-123") + + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls })) + const output = { + message: { variant: "low" } as Record, + parts: [{ type: "text", text: "ultrawork mode" }], + } + + // #when - ultrawork keyword triggers + await hook["chat.message"]( + { sessionID: "main-123" }, + output + ) + + // #then - existing variant should remain + expect(output.message.variant).toBe("low") + expect(toastCalls).toContain("Ultrawork Mode Activated") + }) }) diff --git a/src/hooks/keyword-detector/index.ts b/src/hooks/keyword-detector/index.ts index e79f17b43..48145ceda 100644 --- a/src/hooks/keyword-detector/index.ts +++ b/src/hooks/keyword-detector/index.ts @@ -47,7 +47,9 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC if (hasUltrawork) { log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID }) - output.message.variant = "max" + if (output.message.variant === undefined) { + output.message.variant = "max" + } ctx.client.tui .showToast({ diff --git a/src/index.ts b/src/index.ts index 995c5ea48..4380e1a87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,8 @@ import { createContextInjectorMessagesTransformHook, } from "./features/context-injector"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; +import { applyAgentVariant, resolveAgentVariant } from "./shared/agent-variant"; +import { createFirstMessageVariantGate } from "./shared/first-message-variant"; import { discoverUserClaudeSkills, discoverProjectClaudeSkills, @@ -82,6 +84,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const pluginConfig = loadPluginConfig(ctx.directory, ctx); const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); + const firstMessageVariantGate = createFirstMessageVariantGate(); const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName); const modelCacheState = createModelCacheState(); @@ -316,6 +319,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }, "chat.message": async (input, output) => { + const message = (output as { message: { variant?: string } }).message + if (firstMessageVariantGate.shouldOverride(input.sessionID)) { + const variant = resolveAgentVariant(pluginConfig, input.agent) + if (variant !== undefined) { + message.variant = variant + } + firstMessageVariantGate.markApplied(input.sessionID) + } else { + applyAgentVariant(pluginConfig, input.agent, message) + } + await keywordDetector?.["chat.message"]?.(input, output); await claudeCodeHooks["chat.message"]?.(input, output); await contextInjector["chat.message"]?.(input, output); @@ -422,6 +436,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { if (!sessionInfo?.parentID) { setMainSession(sessionInfo?.id); } + firstMessageVariantGate.markSessionCreated(sessionInfo); } if (event.type === "session.deleted") { @@ -431,6 +446,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { } if (sessionInfo?.id) { clearSessionAgent(sessionInfo.id); + firstMessageVariantGate.clear(sessionInfo.id); await skillMcpManager.disconnectSession(sessionInfo.id); await lspManager.cleanupTempDirectoryClients(); } diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 95e08d9b4..a25900f0a 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -103,7 +103,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { pluginConfig.disabled_agents, pluginConfig.agents, ctx.directory, - config.model as string | undefined + config.model as string | undefined, + pluginConfig.categories ); // Claude Code agents: Do NOT apply permission migration diff --git a/src/shared/agent-variant.test.ts b/src/shared/agent-variant.test.ts new file mode 100644 index 000000000..7af36ccb8 --- /dev/null +++ b/src/shared/agent-variant.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test" +import type { OhMyOpenCodeConfig } from "../config" +import { applyAgentVariant, resolveAgentVariant } from "./agent-variant" + +describe("resolveAgentVariant", () => { + test("returns undefined when agent name missing", () => { + // #given + const config = {} as OhMyOpenCodeConfig + + // #when + const variant = resolveAgentVariant(config) + + // #then + expect(variant).toBeUndefined() + }) + + test("returns agent override variant", () => { + // #given + const config = { + agents: { + Sisyphus: { variant: "low" }, + }, + } as OhMyOpenCodeConfig + + // #when + const variant = resolveAgentVariant(config, "Sisyphus") + + // #then + expect(variant).toBe("low") + }) + + test("returns category variant when agent uses category", () => { + // #given + const config = { + agents: { + Sisyphus: { category: "ultrabrain" }, + }, + categories: { + ultrabrain: { model: "openai/gpt-5.2", variant: "xhigh" }, + }, + } as OhMyOpenCodeConfig + + // #when + const variant = resolveAgentVariant(config, "Sisyphus") + + // #then + expect(variant).toBe("xhigh") + }) +}) + +describe("applyAgentVariant", () => { + test("sets variant when message is undefined", () => { + // #given + const config = { + agents: { + Sisyphus: { variant: "low" }, + }, + } as OhMyOpenCodeConfig + const message: { variant?: string } = {} + + // #when + applyAgentVariant(config, "Sisyphus", message) + + // #then + expect(message.variant).toBe("low") + }) + + test("does not override existing variant", () => { + // #given + const config = { + agents: { + Sisyphus: { variant: "low" }, + }, + } as OhMyOpenCodeConfig + const message = { variant: "max" } + + // #when + applyAgentVariant(config, "Sisyphus", message) + + // #then + expect(message.variant).toBe("max") + }) +}) diff --git a/src/shared/agent-variant.ts b/src/shared/agent-variant.ts new file mode 100644 index 000000000..ec3e7ec50 --- /dev/null +++ b/src/shared/agent-variant.ts @@ -0,0 +1,40 @@ +import type { OhMyOpenCodeConfig } from "../config" + +export function resolveAgentVariant( + config: OhMyOpenCodeConfig, + agentName?: string +): string | undefined { + if (!agentName) { + return undefined + } + + const agentOverrides = config.agents as + | Record + | undefined + const agentOverride = agentOverrides?.[agentName] + if (!agentOverride) { + return undefined + } + + if (agentOverride.variant) { + return agentOverride.variant + } + + const categoryName = agentOverride.category + if (!categoryName) { + return undefined + } + + return config.categories?.[categoryName]?.variant +} + +export function applyAgentVariant( + config: OhMyOpenCodeConfig, + agentName: string | undefined, + message: { variant?: string } +): void { + const variant = resolveAgentVariant(config, agentName) + if (variant !== undefined && message.variant === undefined) { + message.variant = variant + } +} diff --git a/src/shared/first-message-variant.test.ts b/src/shared/first-message-variant.test.ts new file mode 100644 index 000000000..6f7fa5259 --- /dev/null +++ b/src/shared/first-message-variant.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { createFirstMessageVariantGate } from "./first-message-variant" + +describe("createFirstMessageVariantGate", () => { + test("marks new sessions and clears after apply", () => { + // #given + const gate = createFirstMessageVariantGate() + + // #when + gate.markSessionCreated({ id: "session-1" }) + + // #then + expect(gate.shouldOverride("session-1")).toBe(true) + + // #when + gate.markApplied("session-1") + + // #then + expect(gate.shouldOverride("session-1")).toBe(false) + }) + + test("ignores forked sessions", () => { + // #given + const gate = createFirstMessageVariantGate() + + // #when + gate.markSessionCreated({ id: "session-2", parentID: "session-parent" }) + + // #then + expect(gate.shouldOverride("session-2")).toBe(false) + }) +}) diff --git a/src/shared/first-message-variant.ts b/src/shared/first-message-variant.ts new file mode 100644 index 000000000..f8229066c --- /dev/null +++ b/src/shared/first-message-variant.ts @@ -0,0 +1,28 @@ +type SessionInfo = { + id?: string + parentID?: string +} + +export function createFirstMessageVariantGate() { + const pending = new Set() + + return { + markSessionCreated(info?: SessionInfo) { + if (info?.id && !info.parentID) { + pending.add(info.id) + } + }, + shouldOverride(sessionID?: string) { + if (!sessionID) return false + return pending.has(sessionID) + }, + markApplied(sessionID?: string) { + if (!sessionID) return + pending.delete(sessionID) + }, + clear(sessionID?: string) { + if (!sessionID) return + pending.delete(sessionID) + }, + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts index bb3601ed6..df7903799 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -21,3 +21,4 @@ export * from "./opencode-version" export * from "./permission-compat" export * from "./external-plugin-detector" export * from "./zip-extractor" +export * from "./agent-variant" diff --git a/src/tools/sisyphus-task/tools.test.ts b/src/tools/sisyphus-task/tools.test.ts index fcf0b278d..cd45bf2d7 100644 --- a/src/tools/sisyphus-task/tools.test.ts +++ b/src/tools/sisyphus-task/tools.test.ts @@ -207,6 +207,70 @@ describe("sisyphus-task", () => { }) }) + describe("category variant", () => { + test("passes variant to background model payload", async () => { + // #given + const { createSisyphusTask } = require("./tools") + let launchInput: any + + const mockManager = { + launch: async (input: any) => { + launchInput = input + return { + id: "task-variant", + sessionID: "session-variant", + description: "Variant task", + agent: "Sisyphus-Junior", + status: "running", + } + }, + } + + const mockClient = { + app: { agents: async () => ({ data: [] }) }, + session: { + create: async () => ({ data: { id: "test-session" } }), + prompt: async () => ({ data: {} }), + messages: async () => ({ data: [] }), + }, + } + + const tool = createSisyphusTask({ + manager: mockManager, + client: mockClient, + userCategories: { + ultrabrain: { model: "openai/gpt-5.2", variant: "xhigh" }, + }, + }) + + const toolContext = { + sessionID: "parent-session", + messageID: "parent-message", + agent: "Sisyphus", + abort: new AbortController().signal, + } + + // #when + await tool.execute( + { + description: "Variant task", + prompt: "Do something", + category: "ultrabrain", + run_in_background: true, + skills: [], + }, + toolContext + ) + + // #then + expect(launchInput.model).toEqual({ + providerID: "openai", + modelID: "gpt-5.2", + variant: "xhigh", + }) + }) + }) + describe("skills parameter", () => { test("SISYPHUS_TASK_DESCRIPTION documents skills parameter", () => { // #given / #when / #then diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index c082f8b1b..58fc9e9b9 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -315,7 +315,7 @@ ${textContent || "(No text output)"}` } let agentToUse: string - let categoryModel: { providerID: string; modelID: string } | undefined + let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined let categoryPromptAppend: string | undefined if (args.category) { @@ -325,7 +325,12 @@ ${textContent || "(No text output)"}` } agentToUse = SISYPHUS_JUNIOR_AGENT - categoryModel = parseModelString(resolved.config.model) + const parsedModel = parseModelString(resolved.config.model) + categoryModel = parsedModel + ? (resolved.config.variant + ? { ...parsedModel, variant: resolved.config.variant } + : parsedModel) + : undefined categoryPromptAppend = resolved.promptAppend || undefined } else { agentToUse = args.subagent_type!.trim()