From 76c5356a805340bc4e6452b20ed39c50a6af7b7d Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Sun, 8 Mar 2026 13:50:32 -0400 Subject: [PATCH 01/12] test(agent-config): add regression tests for agent merge priority order --- .../claude-code-agent-loader/loader.test.ts | 275 +++++++++++++++ .../claude-code-agent-loader/loader.ts | 24 ++ .../claude-code-agent-loader/types.ts | 2 +- src/plugin-handlers/agent-config-handler.ts | 35 +- src/plugin-handlers/config-handler.test.ts | 174 ++++++++++ src/tools/call-omo-agent/agent-resolver.ts | 45 +++ src/tools/call-omo-agent/constants.ts | 7 +- src/tools/call-omo-agent/tools.test.ts | 314 ++++++++++++------ src/tools/call-omo-agent/tools.ts | 51 ++- 9 files changed, 806 insertions(+), 121 deletions(-) create mode 100644 src/features/claude-code-agent-loader/loader.test.ts create mode 100644 src/tools/call-omo-agent/agent-resolver.ts diff --git a/src/features/claude-code-agent-loader/loader.test.ts b/src/features/claude-code-agent-loader/loader.test.ts new file mode 100644 index 000000000..8a6a1cace --- /dev/null +++ b/src/features/claude-code-agent-loader/loader.test.ts @@ -0,0 +1,275 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { + loadUserAgents, + loadProjectAgents, + loadOpencodeGlobalAgents, + loadOpencodeProjectAgents, +} from "./loader"; + +/** + * Creates a temporary directory tree for testing agent loading. + * Returns the root dir with `.claude/agents/` and `.opencode/agents/` subdirs + * pre-created, containing the specified agent files. + */ +function createProjectWithAgents( + agents: { + claudeAgents?: Array<{ filename: string; content: string }>; + opencodeAgents?: Array<{ filename: string; content: string }>; + } = {}, +): string { + const root = mkdtempSync(join(tmpdir(), "agent-loader-test-")); + if (agents.claudeAgents) { + const dir = join(root, ".claude", "agents"); + mkdirSync(dir, { recursive: true }); + for (const { filename, content } of agents.claudeAgents) { + writeFileSync(join(dir, filename), content, "utf-8"); + } + } + if (agents.opencodeAgents) { + const dir = join(root, ".opencode", "agents"); + mkdirSync(dir, { recursive: true }); + for (const { filename, content } of agents.opencodeAgents) { + writeFileSync(join(dir, filename), content, "utf-8"); + } + } + return root; +} + +const BASIC_AGENT = `--- +name: test-agent +description: A test agent +tools: Bash,Read +--- +You are a test agent.`; + +const MINIMAL_AGENT = `--- +description: Minimal agent +--- +Do minimal things.`; + +const NO_FRONTMATTER_AGENT = `Just a prompt with no frontmatter.`; + +describe("claude-code-agent-loader", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }); + } + dirs.length = 0; + }); + + function trackDir(dir: string): string { + dirs.push(dir); + return dir; + } + + describe("loadProjectAgents", () => { + test("loads agents from /.claude/agents", () => { + const root = trackDir( + createProjectWithAgents({ + claudeAgents: [{ filename: "my-agent.md", content: BASIC_AGENT }], + }), + ); + + const result = loadProjectAgents(root); + + expect(Object.keys(result)).toEqual(["test-agent"]); + expect(result["test-agent"].description).toBe("(project) A test agent"); + expect(result["test-agent"].mode).toBe("subagent"); + expect(result["test-agent"].prompt).toBe("You are a test agent."); + expect(result["test-agent"].tools).toEqual({ bash: true, read: true }); + }); + + test("uses filename as agent name when frontmatter name is absent", () => { + const root = trackDir( + createProjectWithAgents({ + claudeAgents: [ + { filename: "fallback-name.md", content: MINIMAL_AGENT }, + ], + }), + ); + + const result = loadProjectAgents(root); + + expect(Object.keys(result)).toEqual(["fallback-name"]); + expect(result["fallback-name"].description).toBe( + "(project) Minimal agent", + ); + }); + + test("handles agent with no frontmatter", () => { + const root = trackDir( + createProjectWithAgents({ + claudeAgents: [{ filename: "raw.md", content: NO_FRONTMATTER_AGENT }], + }), + ); + + const result = loadProjectAgents(root); + + expect(Object.keys(result)).toEqual(["raw"]); + expect(result["raw"].prompt).toBe("Just a prompt with no frontmatter."); + }); + + test("returns empty object when project has no .claude/agents directory", () => { + const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-"))); + + const result = loadProjectAgents(root); + + expect(result).toEqual({}); + }); + + test("ignores non-markdown files", () => { + const root = trackDir( + createProjectWithAgents({ + claudeAgents: [ + { filename: "good.md", content: BASIC_AGENT }, + { filename: "bad.txt", content: "not a markdown file" }, + { filename: "also-bad.json", content: "{}" }, + ], + }), + ); + + const result = loadProjectAgents(root); + + expect(Object.keys(result)).toEqual(["test-agent"]); + }); + + test("loads multiple agents", () => { + const root = trackDir( + createProjectWithAgents({ + claudeAgents: [ + { filename: "agent-a.md", content: BASIC_AGENT }, + { + filename: "agent-b.md", + content: `---\nname: second-agent\ndescription: Another agent\n---\nDo other things.`, + }, + ], + }), + ); + + const result = loadProjectAgents(root); + + expect(Object.keys(result).sort()).toEqual([ + "second-agent", + "test-agent", + ]); + }); + }); + + describe("loadOpencodeProjectAgents", () => { + test("loads agents from /.opencode/agents", () => { + const root = trackDir( + createProjectWithAgents({ + opencodeAgents: [{ filename: "oc-agent.md", content: BASIC_AGENT }], + }), + ); + + const result = loadOpencodeProjectAgents(root); + + expect(Object.keys(result)).toEqual(["test-agent"]); + expect(result["test-agent"].description).toBe( + "(opencode-project) A test agent", + ); + expect(result["test-agent"].mode).toBe("subagent"); + expect(result["test-agent"].prompt).toBe("You are a test agent."); + }); + + test("returns empty object when project has no .opencode/agents directory", () => { + const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-"))); + + const result = loadOpencodeProjectAgents(root); + + expect(result).toEqual({}); + }); + }); + + describe("loadUserAgents", () => { + test("returns empty object when pointed at dir without agents/", () => { + const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-"))) + // Temporarily set env var — best-effort in parallel test runner + const prev = process.env.CLAUDE_CONFIG_DIR + try { + process.env.CLAUDE_CONFIG_DIR = root + const result = loadUserAgents() + expect(result).toEqual({}) + } finally { + if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev + else delete process.env.CLAUDE_CONFIG_DIR + } + }) + }) + + describe("loadOpencodeGlobalAgents", () => { + test("returns empty object when pointed at dir without agents/", () => { + const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-"))) + const prev = process.env.OPENCODE_CONFIG_DIR + try { + process.env.OPENCODE_CONFIG_DIR = root + const result = loadOpencodeGlobalAgents() + expect(result).toEqual({}) + } finally { + if (prev !== undefined) process.env.OPENCODE_CONFIG_DIR = prev + else delete process.env.OPENCODE_CONFIG_DIR + } + }) + }) + + describe("tools parsing", () => { + test("parses comma-separated tools into boolean record", () => { + const agentWithTools = `---\nname: tooled\ndescription: Has tools\ntools: Bash,Read,Edit\n---\nDo things.`; + const root = trackDir( + createProjectWithAgents({ + claudeAgents: [{ filename: "tooled.md", content: agentWithTools }], + }), + ); + + const result = loadProjectAgents(root); + + expect(result["tooled"].tools).toEqual({ + bash: true, + read: true, + edit: true, + }); + }); + + test("omits tools when frontmatter tools field is absent", () => { + const agentNoTools = `---\nname: no-tools\ndescription: No tools\n---\nDo things.`; + const root = trackDir( + createProjectWithAgents({ + claudeAgents: [{ filename: "no-tools.md", content: agentNoTools }], + }), + ); + + const result = loadProjectAgents(root); + + expect(result["no-tools"].tools).toBeUndefined(); + }); + }); + + describe("scope labeling", () => { + test("project and opencode-project loaders apply correct scope prefixes", () => { + const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-scope-"))) + const content = `---\nname: scoped\ndescription: Scoped agent\n---\nPrompt.` + + const claudeProjectDir = join(root, "project", ".claude", "agents") + const ocProjectDir = join(root, "project", ".opencode", "agents") + + mkdirSync(claudeProjectDir, { recursive: true }) + mkdirSync(ocProjectDir, { recursive: true }) + + writeFileSync(join(claudeProjectDir, "a.md"), content, "utf-8") + writeFileSync(join(ocProjectDir, "a.md"), content, "utf-8") + + const project = loadProjectAgents(join(root, "project")) + const ocProject = loadOpencodeProjectAgents(join(root, "project")) + + expect(project["scoped"].description).toBe("(project) Scoped agent") + expect(ocProject["scoped"].description).toBe("(opencode-project) Scoped agent") + }) + }) +}); diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index d2ef89b03..98b7c8566 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -5,6 +5,7 @@ import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types" import { mapClaudeModelToOpenCode } from "./claude-model-mapper" +import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" function parseToolsConfig(toolsStr?: string): Record | undefined { if (!toolsStr) return undefined @@ -94,3 +95,26 @@ export function loadProjectAgents(directory?: string): Record { + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) + const opencodeAgentsDir = join(configDir, "agents") + const agents = loadAgentsFromDir(opencodeAgentsDir, "opencode") + + const result: Record = {} + for (const agent of agents) { + result[agent.name] = agent.config + } + return result +} + +export function loadOpencodeProjectAgents(directory?: string): Record { + const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "agents") + const agents = loadAgentsFromDir(opencodeProjectDir, "opencode-project") + + const result: Record = {} + for (const agent of agents) { + result[agent.name] = agent.config + } + return result +} diff --git a/src/features/claude-code-agent-loader/types.ts b/src/features/claude-code-agent-loader/types.ts index 7db570637..90a236828 100644 --- a/src/features/claude-code-agent-loader/types.ts +++ b/src/features/claude-code-agent-loader/types.ts @@ -1,6 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" -export type AgentScope = "user" | "project" +export type AgentScope = "user" | "project" | "opencode" | "opencode-project" export type ClaudeCodeAgentConfig = Omit & { model?: string | { providerID: string; modelID: string } diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index b8c7a9ee6..d644f68c6 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -14,7 +14,7 @@ import { discoverProjectClaudeSkills, discoverUserClaudeSkills, } from "../features/opencode-skill-loader"; -import { loadProjectAgents, loadUserAgents } from "../features/claude-code-agent-loader"; +import { loadProjectAgents, loadUserAgents, loadOpencodeGlobalAgents, loadOpencodeProjectAgents } from "../features/claude-code-agent-loader"; import type { PluginComponents } from "./plugin-components-loader"; import { reorderAgentsByPriority } from "./agent-priority-order"; import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"; @@ -139,6 +139,9 @@ export async function applyAgentConfig(params: { disableOmoEnv, ); + const opencodeGlobalAgents = loadOpencodeGlobalAgents(); + const opencodeProjectAgents = loadOpencodeProjectAgents(params.ctx.directory); + const disabledAgentNames = new Set( (migratedDisabledAgents ?? []).map(a => a.toLowerCase()) ); @@ -257,6 +260,14 @@ export async function applyAgentConfig(params: { pluginAgents, protectedBuiltinAgentNames, ); + const filteredOpencodeGlobalAgents = filterProtectedAgentOverrides( + opencodeGlobalAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeProjectAgents = filterProtectedAgentOverrides( + opencodeProjectAgents, + protectedBuiltinAgentNames, + ); params.config.agent = { ...agentConfig, @@ -265,9 +276,12 @@ export async function applyAgentConfig(params: { ([key]) => key !== "sisyphus" && key !== "hephaestus" && key !== "atlas", ), ), - ...filterDisabledAgents(filteredUserAgents), - ...filterDisabledAgents(filteredProjectAgents), + // Precedence: later entries override earlier (project > global > user > plugin) ...filterDisabledAgents(filteredPluginAgents), + ...filterDisabledAgents(filteredUserAgents), + ...filterDisabledAgents(filteredOpencodeGlobalAgents), + ...filterDisabledAgents(filteredProjectAgents), + ...filterDisabledAgents(filteredOpencodeProjectAgents), ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), @@ -288,6 +302,14 @@ export async function applyAgentConfig(params: { pluginAgents, protectedBuiltinAgentNames, ); + const filteredOpencodeGlobalAgents = filterProtectedAgentOverrides( + opencodeGlobalAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeProjectAgents = filterProtectedAgentOverrides( + opencodeProjectAgents, + protectedBuiltinAgentNames, + ); const defaultedConfigAgents = configAgent ? Object.fromEntries( @@ -302,9 +324,12 @@ export async function applyAgentConfig(params: { params.config.agent = { ...builtinAgents, - ...filterDisabledAgents(filteredUserAgents), - ...filterDisabledAgents(filteredProjectAgents), + // Precedence: later entries override earlier (project > global > user > plugin) ...filterDisabledAgents(filteredPluginAgents), + ...filterDisabledAgents(filteredUserAgents), + ...filterDisabledAgents(filteredOpencodeGlobalAgents), + ...filterDisabledAgents(filteredProjectAgents), + ...filterDisabledAgents(filteredOpencodeProjectAgents), ...defaultedConfigAgents, }; } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 5e68eeede..668d21a32 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -69,6 +69,8 @@ beforeEach(async () => { spyOn(agentLoader, "loadUserAgents" as any).mockReturnValue({}) spyOn(agentLoader, "loadProjectAgents" as any).mockReturnValue({}) + spyOn(agentLoader, "loadOpencodeGlobalAgents" as any).mockReturnValue({}) + spyOn(agentLoader, "loadOpencodeProjectAgents" as any).mockReturnValue({}) spyOn(mcpLoader, "loadMcpConfigs" as any).mockResolvedValue({ servers: {} }) setAdditionalAllowedMcpEnvVarsSpy = spyOn(mcpLoader, "setAdditionalAllowedMcpEnvVars").mockImplementation(() => {}) @@ -118,6 +120,8 @@ afterEach(() => { ;(skillLoader.discoverOpencodeProjectSkills as any)?.mockRestore?.() ;(agentLoader.loadUserAgents as any)?.mockRestore?.() ;(agentLoader.loadProjectAgents as any)?.mockRestore?.() + ;(agentLoader.loadOpencodeGlobalAgents as any)?.mockRestore?.() + ;(agentLoader.loadOpencodeProjectAgents as any)?.mockRestore?.() ;(mcpLoader.loadMcpConfigs as any)?.mockRestore?.() setAdditionalAllowedMcpEnvVarsSpy?.mockRestore() ;(pluginLoader.loadAllPluginComponents as any)?.mockRestore?.() @@ -1596,3 +1600,173 @@ describe("disable_omo_env pass-through", () => { expect(disableOmoEnv).toBe(false) }) }) + +describe("Agent merge priority — project-local overrides global", () => { + test("project-local Claude agent overrides global Claude agent with same name", async () => { + // #given — same agent name in both global (user) and project scopes + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + "my-custom-agent": { + description: "(user) global version", + mode: "subagent", + prompt: "I am the global agent", + }, + }) + ;(agentLoader.loadProjectAgents as any).mockReturnValue({ + "my-custom-agent": { + description: "(project) project version", + mode: "subagent", + prompt: "I am the project agent", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then — project version wins + const agentConfig = config.agent as Record + expect(agentConfig["my-custom-agent"]?.description).toBe("(project) project version") + expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the project agent") + }) + + test("opencode project agent overrides opencode global agent with same name", async () => { + // #given — same agent name in opencode global vs opencode project + ;(agentLoader.loadOpencodeGlobalAgents as any).mockReturnValue({ + "my-custom-agent": { + description: "(opencode) global version", + mode: "subagent", + prompt: "I am the opencode global agent", + }, + }) + ;(agentLoader.loadOpencodeProjectAgents as any).mockReturnValue({ + "my-custom-agent": { + description: "(opencode-project) project version", + mode: "subagent", + prompt: "I am the opencode project agent", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then — opencode project version wins over opencode global + const agentConfig = config.agent as Record + expect(agentConfig["my-custom-agent"]?.description).toBe("(opencode-project) project version") + expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the opencode project agent") + }) + + test("project Claude agent overrides opencode global agent with same name", async () => { + // #given — project-scope Claude agent vs global-scope opencode agent + ;(agentLoader.loadOpencodeGlobalAgents as any).mockReturnValue({ + "my-custom-agent": { + description: "(opencode) global version", + mode: "subagent", + prompt: "I am the opencode global agent", + }, + }) + ;(agentLoader.loadProjectAgents as any).mockReturnValue({ + "my-custom-agent": { + description: "(project) project version", + mode: "subagent", + prompt: "I am the project Claude agent", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then — project-scope wins over global-scope regardless of format + const agentConfig = config.agent as Record + expect(agentConfig["my-custom-agent"]?.description).toBe("(project) project version") + expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the project Claude agent") + }) + + test("plugin agents have lowest priority — overridden by all other sources", async () => { + // #given — same agent in plugin, global, and project scopes + ;(pluginLoader.loadAllPluginComponents as any).mockResolvedValue({ + commands: {}, + skills: {}, + agents: { + "my-custom-agent": { + description: "plugin version", + mode: "subagent", + prompt: "I am the plugin agent", + }, + }, + mcpServers: {}, + hooksConfigs: [], + plugins: [], + errors: [], + }) + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + "my-custom-agent": { + description: "(user) global version", + mode: "subagent", + prompt: "I am the user agent", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then — user (global) agent overrides plugin agent + const agentConfig = config.agent as Record + expect(agentConfig["my-custom-agent"]?.description).toBe("(user) global version") + expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the user agent") + }) +}) diff --git a/src/tools/call-omo-agent/agent-resolver.ts b/src/tools/call-omo-agent/agent-resolver.ts new file mode 100644 index 000000000..d417be189 --- /dev/null +++ b/src/tools/call-omo-agent/agent-resolver.ts @@ -0,0 +1,45 @@ +import type { PluginInput } from "@opencode-ai/plugin"; +import { ALLOWED_AGENTS } from "./constants"; +import { normalizeSDKResponse } from "../../shared"; +import { log } from "../../shared/logger"; + +type AgentInfo = { + name: string; + mode?: "subagent" | "primary" | "all"; +}; + +/** + * Resolves the set of callable agent names at execute-time by merging the + * hardcoded `ALLOWED_AGENTS` with any additional agents discovered dynamically + * via `client.app.agents()`. Custom agents loaded from registered agent + * directories appear here alongside built-ins. + * + * Falls back to `ALLOWED_AGENTS` alone if the dynamic lookup fails. + * + * @param client - The plugin client with access to the agent registry + * @returns Array of lowercase callable agent names (excludes primary-mode agents) + */ +export async function resolveCallableAgents( + client: PluginInput["client"], +): Promise { + try { + const agentsResult = await client.app.agents(); + const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], { + preferResponseOnMissingData: true, + }); + + const dynamicAgents = agents + .filter((a) => a.mode !== "primary") + .map((a) => a.name.toLowerCase()); + + const merged = new Set([...ALLOWED_AGENTS, ...dynamicAgents]); + return [...merged]; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log( + "[call_omo_agent] Failed to resolve dynamic agents, falling back to built-in list", + { error: message }, + ); + return [...ALLOWED_AGENTS]; + } +} diff --git a/src/tools/call-omo-agent/constants.ts b/src/tools/call-omo-agent/constants.ts index 028b0d602..823313f2f 100644 --- a/src/tools/call-omo-agent/constants.ts +++ b/src/tools/call-omo-agent/constants.ts @@ -8,8 +8,11 @@ export const ALLOWED_AGENTS = [ "multimodal-looker", ] as const -export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent. run_in_background REQUIRED (true=async with task_id, false=sync). +export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent or custom agents. run_in_background REQUIRED (true=async with task_id, false=sync). -Available: {agents} +Built-in agents: +{agents} + +Custom agents registered via user or project agent directories are also supported. Pass \`session_id=\` to continue previous agent with full context. Nested subagent depth is tracked automatically and blocked past the configured limit. Prompts MUST be in English. Use \`background_output\` for async results.` diff --git a/src/tools/call-omo-agent/tools.test.ts b/src/tools/call-omo-agent/tools.test.ts index 5d499b7bb..2d0d4be2f 100644 --- a/src/tools/call-omo-agent/tools.test.ts +++ b/src/tools/call-omo-agent/tools.test.ts @@ -1,119 +1,237 @@ const { beforeEach, describe, test, expect, mock } = require("bun:test") const { createCallOmoAgent } = require("./tools") -describe("createCallOmoAgent", () => { - const assertCanSpawnMock = mock(() => Promise.resolve(undefined)) - const reserveCommitMock = mock(() => 1) - const reserveRollbackMock = mock(() => {}) - const reserveSubagentSpawnMock = mock(() => Promise.resolve({ - spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 }, - descendantCount: 1, - commit: reserveCommitMock, - rollback: reserveRollbackMock, - })) - const mockCtx = { - client: {}, +type PluginInput = { client: any; directory: string } +type BackgroundManager = { + assertCanSpawn: Function + reserveSubagentSpawn: Function + launch: Function + getTask: Function +} + +function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): PluginInput { + return { + client: { + app: { + agents: mock(() => Promise.resolve(agents)), + }, + }, directory: "/test", - } + } as unknown as PluginInput +} - const mockBackgroundManager = { - assertCanSpawn: assertCanSpawnMock, - reserveSubagentSpawn: reserveSubagentSpawnMock, - launch: mock(() => Promise.resolve({ - id: "test-task-id", - sessionID: null, - description: "Test task", - agent: "test-agent", - status: "pending", - })), - } +function createFailingMockCtx(error: Error = new Error("API unavailable")): PluginInput { + return { + client: { + app: { + agents: mock(() => Promise.reject(error)), + }, + }, + directory: "/test", + } as unknown as PluginInput +} - beforeEach(() => { - assertCanSpawnMock.mockClear() - reserveSubagentSpawnMock.mockClear() - reserveCommitMock.mockClear() - reserveRollbackMock.mockClear() +const DEFAULT_AGENTS = [ + { name: "explore", mode: "subagent" }, + { name: "librarian", mode: "subagent" }, + { name: "oracle", mode: "subagent" }, + { name: "hephaestus", mode: "subagent" }, + { name: "metis", mode: "subagent" }, + { name: "momus", mode: "subagent" }, + { name: "multimodal-looker", mode: "subagent" }, +] + +const assertCanSpawnMock = mock(() => Promise.resolve(undefined)) +const reserveCommitMock = mock(() => 1) +const reserveRollbackMock = mock(() => {}) +const reserveSubagentSpawnMock = mock(() => Promise.resolve({ + spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 }, + descendantCount: 1, + commit: reserveCommitMock, + rollback: reserveRollbackMock, +})) + +const mockBackgroundManager = { + assertCanSpawn: assertCanSpawnMock, + reserveSubagentSpawn: reserveSubagentSpawnMock, + launch: mock(() => Promise.resolve({ + id: "test-task-id", + sessionID: null, + description: "Test task", + agent: "test-agent", + status: "pending", + })), + getTask: mock(() => ({ status: "pending", sessionID: "ses-123" })), +} as unknown as BackgroundManager + +const toolCtx = { + sessionID: "test", + messageID: "msg", + agent: "test", + abort: new AbortController().signal, +} + +beforeEach(() => { + assertCanSpawnMock.mockClear() + reserveSubagentSpawnMock.mockClear() + reserveCommitMock.mockClear() + reserveRollbackMock.mockClear() +}) + +describe("createCallOmoAgent", () => { + describe("disabled_agents validation", () => { + test("should reject agent in disabled_agents list", async () => { + const mockCtx = createMockCtx(DEFAULT_AGENTS) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["explore"]) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true }, + toolCtx + ) + + expect(result).toContain("disabled via disabled_agents") + }) + + test("should reject agent in disabled_agents list with case-insensitive matching", async () => { + const mockCtx = createMockCtx(DEFAULT_AGENTS) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["Explore"]) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true }, + toolCtx + ) + + expect(result).toContain("disabled via disabled_agents") + }) + + test("should allow agent not in disabled_agents list", async () => { + const mockCtx = createMockCtx(DEFAULT_AGENTS) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["librarian"]) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true }, + toolCtx + ) + + expect(result).not.toContain("disabled via disabled_agents") + }) + + test("should allow all agents when disabled_agents is empty", async () => { + const mockCtx = createMockCtx(DEFAULT_AGENTS) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true }, + toolCtx + ) + + expect(result).not.toContain("disabled via disabled_agents") + }) }) - test("should reject agent in disabled_agents list", async () => { - //#given - const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["explore"]) - const executeFunc = toolDef.execute as Function + describe("dynamic custom agent resolution", () => { + test("should accept a custom agent returned by client.app.agents()", async () => { + const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }] + const mockCtx = createMockCtx(agents) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function - //#when - const result = await executeFunc( - { - description: "Test", - prompt: "Test prompt", - subagent_type: "explore", - run_in_background: true, - }, - { sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal } - ) + const result = await executeFunc( + { description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true }, + toolCtx + ) - //#then - expect(result).toContain("disabled via disabled_agents") - }) + expect(result).not.toContain("Invalid agent type") + expect(result).not.toContain("not found") + }) - test("should reject agent in disabled_agents list with case-insensitive matching", async () => { - //#given - const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["Explore"]) - const executeFunc = toolDef.execute as Function + test("should reject a custom agent NOT returned by client.app.agents()", async () => { + const mockCtx = createMockCtx(DEFAULT_AGENTS) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function - //#when - const result = await executeFunc( - { - description: "Test", - prompt: "Test prompt", - subagent_type: "explore", - run_in_background: true, - }, - { sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal } - ) + const result = await executeFunc( + { description: "Test", prompt: "Fix bug", subagent_type: "nonexistent-agent", run_in_background: true }, + toolCtx + ) - //#then - expect(result).toContain("disabled via disabled_agents") - }) + expect(result).toContain("Invalid agent type") + }) - test("should allow agent not in disabled_agents list", async () => { - //#given - const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["librarian"]) - const executeFunc = toolDef.execute as Function + test("should perform case-insensitive matching for custom agents", async () => { + const agents = [...DEFAULT_AGENTS, { name: "Bug-Fixer", mode: "subagent" }] + const mockCtx = createMockCtx(agents) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function - //#when - const result = await executeFunc( - { - description: "Test", - prompt: "Test prompt", - subagent_type: "explore", - run_in_background: true, - }, - { sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal } - ) + const result = await executeFunc( + { description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true }, + toolCtx + ) - //#then - // Should not contain disabled error - may fail for other reasons but disabled check should pass - expect(result).not.toContain("disabled via disabled_agents") - }) + expect(result).not.toContain("Invalid agent type") + }) - test("should allow all agents when disabled_agents is empty", async () => { - //#given - const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) - const executeFunc = toolDef.execute as Function + test("should exclude primary-mode agents from callable list", async () => { + const agents = [ + ...DEFAULT_AGENTS, + { name: "sisyphus", mode: "primary" }, + ] + const mockCtx = createMockCtx(agents) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function - //#when - const result = await executeFunc( - { - description: "Test", - prompt: "Test prompt", - subagent_type: "explore", - run_in_background: true, - }, - { sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal } - ) + const result = await executeFunc( + { description: "Test", prompt: "Orchestrate", subagent_type: "sisyphus", run_in_background: true }, + toolCtx + ) - //#then - expect(result).not.toContain("disabled via disabled_agents") + expect(result).toContain("Invalid agent type") + }) + + test("should fall back to ALLOWED_AGENTS when client.app.agents() fails", async () => { + const mockCtx = createFailingMockCtx() + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { description: "Test", prompt: "Explore codebase", subagent_type: "explore", run_in_background: true }, + toolCtx + ) + + expect(result).not.toContain("Invalid agent type") + }) + + test("should reject unknown agent even when client.app.agents() fails (fallback mode)", async () => { + const mockCtx = createFailingMockCtx() + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { description: "Test", prompt: "Fix bug", subagent_type: "custom-agent", run_in_background: true }, + toolCtx + ) + + expect(result).toContain("Invalid agent type") + }) + + test("should still apply disabled_agents check to dynamically resolved custom agents", async () => { + const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }] + const mockCtx = createMockCtx(agents) + const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["bug-fixer"]) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true }, + toolCtx + ) + + expect(result).toContain("disabled via disabled_agents") + }) }) test("uses agent override fallback_models when launching background subagent", async () => { @@ -129,6 +247,7 @@ describe("createCallOmoAgent", () => { launch, getTask: mock(() => undefined), } + const mockCtx = createMockCtx(DEFAULT_AGENTS) const toolDef = createCallOmoAgent( mockCtx, managerWithLaunch, @@ -371,6 +490,7 @@ describe("createCallOmoAgent", () => { test("should return a tool error when sync spawn depth validation fails", async () => { //#given + const mockCtx = createMockCtx(DEFAULT_AGENTS) reserveSubagentSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3.")) const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, []) const executeFunc = toolDef.execute as Function diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index cca3711f6..802ac0ba0 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -14,6 +14,7 @@ import { CONFIG_BASENAME } from "../../shared/plugin-identity" import { parseModelString } from "../delegate-task/model-string-parser" import { executeBackground } from "./background-executor" import { executeSync } from "./sync-executor" +import { resolveCallableAgents } from "./agent-resolver" function resolveModelAndFallbackChain(args: { subagentType: string @@ -83,39 +84,57 @@ export function createCallOmoAgent( userCategories?: CategoriesConfig, ): ToolDefinition { const agentDescriptions = ALLOWED_AGENTS.map( - (name) => `- ${name}: Specialized agent for ${name} tasks` - ).join("\n") - const description = CALL_OMO_AGENT_DESCRIPTION.replace("{agents}", agentDescriptions) + (name) => `- ${name}: Specialized agent for ${name} tasks`, + ).join("\n"); + const description = CALL_OMO_AGENT_DESCRIPTION.replace( + "{agents}", + agentDescriptions, + ); return tool({ description, args: { - description: tool.schema.string().describe("A short (3-5 words) description of the task"), - prompt: tool.schema.string().describe("The task for the agent to perform"), + description: tool.schema + .string() + .describe("A short (3-5 words) description of the task"), + prompt: tool.schema + .string() + .describe("The task for the agent to perform"), subagent_type: tool.schema .string() - .describe("The type of specialized agent to use for this task (explore or librarian only)"), + .describe( + "The agent to invoke. Supports built-in agents and any custom agents registered at runtime.", + ), run_in_background: tool.schema .boolean() - .describe("REQUIRED. true: run asynchronously (use background_output to get results), false: run synchronously and wait for completion"), - session_id: tool.schema.string().describe("Existing Task session to continue").optional(), + .describe( + "REQUIRED. true: run asynchronously (use background_output to get results), false: run synchronously and wait for completion", + ), + session_id: tool.schema + .string() + .describe("Existing Task session to continue") + .optional(), }, async execute(args: CallOmoAgentArgs, toolContext) { - const toolCtx = toolContext as ToolContextWithMetadata - log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`) + const toolCtx = toolContext as ToolContextWithMetadata; + log( + `[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`, + ); + + const callableAgents = await resolveCallableAgents(ctx.client); // Strip ZWSP and case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc. const strippedAgentType = stripInvisibleAgentCharacters(args.subagent_type) if ( - !ALLOWED_AGENTS.some( + !callableAgents.some( (name) => name.toLowerCase() === strippedAgentType.toLowerCase(), ) ) { - return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.` + return `Error: Invalid agent type "${args.subagent_type}". Only ${callableAgents.join(", ")} are allowed.`; } - const normalizedAgent = strippedAgentType.toLowerCase() as AllowedAgentType - args = { ...args, subagent_type: normalizedAgent } + const normalizedAgent = strippedAgentType.toLowerCase(); + args = { ...args, subagent_type: normalizedAgent }; // Check if agent is disabled if (disabledAgents.some((disabled) => stripInvisibleAgentCharacters(disabled).toLowerCase() === normalizedAgent)) { @@ -130,7 +149,7 @@ export function createCallOmoAgent( if (args.run_in_background) { if (args.session_id) { - return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.` + return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`; } return await executeBackground(args, toolCtx, backgroundManager, ctx.client, fallbackChain, resolvedModel) } @@ -148,5 +167,5 @@ export function createCallOmoAgent( return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, undefined, resolvedModel) }, - }) + }); } From da91c535365e6754c16980799664dba54e5094d6 Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Thu, 26 Mar 2026 10:31:57 -0400 Subject: [PATCH 02/12] fix(call-omo-agent): address cubic review findings and add requirement-based tests - Fix agent-resolver.ts: add defensive validation on agent name (typeof, trim, filter) - Fix tools.test.ts: correct mock to return {data: agents} matching SDK contract - Fix agent-config-handler.ts: include opencode global/project agents in customAgentSummaries - Add agent-resolver.test.ts: 14 requirement-based tests covering R1-R7 behavioral specs - Add tools-edge-cases.test.ts: 5 integration tests for rollback, whitespace, dedup, session_id --- src/plugin-handlers/agent-config-handler.ts | 7 +- .../call-omo-agent/agent-resolver.test.ts | 236 ++++++++++++++++++ src/tools/call-omo-agent/agent-resolver.ts | 4 +- .../call-omo-agent/tools-edge-cases.test.ts | 222 ++++++++++++++++ src/tools/call-omo-agent/tools.test.ts | 2 +- 5 files changed, 465 insertions(+), 6 deletions(-) create mode 100644 src/tools/call-omo-agent/agent-resolver.test.ts create mode 100644 src/tools/call-omo-agent/tools-edge-cases.test.ts diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index d644f68c6..91c711b19 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -96,6 +96,8 @@ export async function applyAgentConfig(params: { const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; const userAgents = includeClaudeAgents ? loadUserAgents() : {}; const projectAgents = includeClaudeAgents ? loadProjectAgents(params.ctx.directory) : {}; + const opencodeGlobalAgents = loadOpencodeGlobalAgents(); + const opencodeProjectAgents = loadOpencodeProjectAgents(params.ctx.directory); const rawPluginAgents = params.pluginComponents.agents; const pluginAgents = Object.fromEntries( @@ -113,6 +115,8 @@ export async function applyAgentConfig(params: { ...Object.entries(configAgent ?? {}), ...Object.entries(userAgents), ...Object.entries(projectAgents), + ...Object.entries(opencodeGlobalAgents), + ...Object.entries(opencodeProjectAgents), ...Object.entries(pluginAgents).filter(([, config]) => config !== undefined), ] .filter(([, config]) => config != null) @@ -139,9 +143,6 @@ export async function applyAgentConfig(params: { disableOmoEnv, ); - const opencodeGlobalAgents = loadOpencodeGlobalAgents(); - const opencodeProjectAgents = loadOpencodeProjectAgents(params.ctx.directory); - const disabledAgentNames = new Set( (migratedDisabledAgents ?? []).map(a => a.toLowerCase()) ); diff --git a/src/tools/call-omo-agent/agent-resolver.test.ts b/src/tools/call-omo-agent/agent-resolver.test.ts new file mode 100644 index 000000000..cadd0c99f --- /dev/null +++ b/src/tools/call-omo-agent/agent-resolver.test.ts @@ -0,0 +1,236 @@ +/** + * Requirement-based tests for resolveCallableAgents(). + * + * These tests are derived from behavioral requirements in the PR description + * and feature spec, NOT from reading the implementation: + * + * R1: ALLOWED_AGENTS always present as baseline + * R2: Dynamic agents from client.app.agents() merged into the result + * R3: Primary-mode agents excluded from callable list + * R4: Falls back to ALLOWED_AGENTS alone when client.app.agents() fails + * R5: All output names are lowercase + * R6: No duplicate agent names in output + * R7: Malformed agent entries (null, missing name, non-string name, whitespace-only) are skipped gracefully + */ +const { describe, test, expect, mock } = require("bun:test") +const { resolveCallableAgents } = require("./agent-resolver") +const { ALLOWED_AGENTS } = require("./constants") + +function createMockClient(agents: Array>) { + return { + app: { + agents: mock(() => Promise.resolve({ data: agents })), + }, + } +} + +function createFailingClient(error: Error = new Error("API unavailable")) { + return { + app: { + agents: mock(() => Promise.reject(error)), + }, + } +} + +describe("resolveCallableAgents", () => { + describe("#given the SDK returns agents successfully", () => { + describe("#when only built-in agents exist", () => { + test("#then every ALLOWED_AGENT appears in the result", async () => { + const builtinAgents = ALLOWED_AGENTS.map((name: string) => ({ + name, + mode: "subagent", + })) + const client = createMockClient(builtinAgents) + + const result = await resolveCallableAgents(client) + + for (const agent of ALLOWED_AGENTS) { + expect(result).toContain(agent) + } + }) + }) + + describe("#when dynamic custom agents are present alongside built-ins", () => { + test("#then custom agents are included in the result", async () => { + const agents = [ + ...ALLOWED_AGENTS.map((name: string) => ({ name, mode: "subagent" })), + { name: "bug-fixer", mode: "subagent" }, + { name: "code-reviewer", mode: "subagent" }, + ] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + expect(result).toContain("bug-fixer") + expect(result).toContain("code-reviewer") + }) + + test("#then ALLOWED_AGENTS are still present", async () => { + const agents = [{ name: "custom-agent", mode: "subagent" }] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + for (const agent of ALLOWED_AGENTS) { + expect(result).toContain(agent) + } + }) + }) + + describe("#when an agent has mode=primary", () => { + test("#then it is excluded from the callable list", async () => { + const agents = [ + { name: "sisyphus", mode: "primary" }, + { name: "explore", mode: "subagent" }, + ] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + expect(result).not.toContain("sisyphus") + expect(result).toContain("explore") + }) + }) + + describe("#when agent names have mixed case", () => { + test("#then all output names are lowercase", async () => { + const agents = [ + { name: "Bug-Fixer", mode: "subagent" }, + { name: "CODE-REVIEWER", mode: "subagent" }, + ] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + expect(result).toContain("bug-fixer") + expect(result).toContain("code-reviewer") + for (const name of result) { + expect(name).toBe(name.toLowerCase()) + } + }) + }) + + describe("#when duplicate agent names exist across sources", () => { + test("#then no duplicates appear in the result", async () => { + const agents = [ + { name: "explore", mode: "subagent" }, + { name: "explore", mode: "subagent" }, + { name: "Explore", mode: "subagent" }, + ] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + const exploreCount = result.filter((n: string) => n === "explore").length + expect(exploreCount).toBe(1) + }) + }) + + describe("#when agent entries are malformed", () => { + test("#then entries with null name are skipped", async () => { + const agents = [ + { name: null, mode: "subagent" }, + { name: "explore", mode: "subagent" }, + ] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + expect(result).toContain("explore") + expect(result.length).toBeGreaterThanOrEqual(ALLOWED_AGENTS.length) + }) + + test("#then entries with numeric name are skipped", async () => { + const agents = [ + { name: 42, mode: "subagent" }, + { name: "explore", mode: "subagent" }, + ] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + expect(result).not.toContain("42") + expect(result).toContain("explore") + }) + + test("#then entries with whitespace-only name are skipped", async () => { + const agents = [ + { name: " ", mode: "subagent" }, + { name: "explore", mode: "subagent" }, + ] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + expect(result).not.toContain("") + expect(result).not.toContain(" ") + expect(result).toContain("explore") + }) + + test("#then entries with missing name property are skipped", async () => { + const agents = [ + { mode: "subagent" }, + { name: "explore", mode: "subagent" }, + ] + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + expect(result).toContain("explore") + expect(result.length).toBeGreaterThanOrEqual(ALLOWED_AGENTS.length) + }) + + test("#then entries that are undefined/null themselves are skipped", async () => { + const agents = [ + null, + undefined, + { name: "explore", mode: "subagent" }, + ] as unknown as Array> + const client = createMockClient(agents) + + const result = await resolveCallableAgents(client) + + expect(result).toContain("explore") + }) + }) + + describe("#when SDK returns an empty list", () => { + test("#then ALLOWED_AGENTS still appear as the baseline", async () => { + const client = createMockClient([]) + + const result = await resolveCallableAgents(client) + + for (const agent of ALLOWED_AGENTS) { + expect(result).toContain(agent) + } + expect(result.length).toBe(ALLOWED_AGENTS.length) + }) + }) + }) + + describe("#given the SDK call fails", () => { + describe("#when client.app.agents() throws an error", () => { + test("#then it falls back to ALLOWED_AGENTS", async () => { + const client = createFailingClient(new Error("Network error")) + + const result = await resolveCallableAgents(client) + + expect(result.length).toBe(ALLOWED_AGENTS.length) + for (const agent of ALLOWED_AGENTS) { + expect(result).toContain(agent) + } + }) + + test("#then custom agents are NOT available in fallback mode", async () => { + const client = createFailingClient() + + const result = await resolveCallableAgents(client) + + expect(result).not.toContain("bug-fixer") + expect(result).not.toContain("custom-agent") + }) + }) + }) +}) + +export {} diff --git a/src/tools/call-omo-agent/agent-resolver.ts b/src/tools/call-omo-agent/agent-resolver.ts index d417be189..9f87feb79 100644 --- a/src/tools/call-omo-agent/agent-resolver.ts +++ b/src/tools/call-omo-agent/agent-resolver.ts @@ -29,8 +29,8 @@ export async function resolveCallableAgents( }); const dynamicAgents = agents - .filter((a) => a.mode !== "primary") - .map((a) => a.name.toLowerCase()); + .filter((a) => a && typeof a.name === "string" && a.name.trim().length > 0 && a.mode !== "primary") + .map((a) => a.name.trim().toLowerCase()); const merged = new Set([...ALLOWED_AGENTS, ...dynamicAgents]); return [...merged]; diff --git a/src/tools/call-omo-agent/tools-edge-cases.test.ts b/src/tools/call-omo-agent/tools-edge-cases.test.ts new file mode 100644 index 000000000..7e766c28f --- /dev/null +++ b/src/tools/call-omo-agent/tools-edge-cases.test.ts @@ -0,0 +1,222 @@ +/** + * Requirement-based integration tests for createCallOmoAgent edge cases + * introduced by the dev rebase and dynamic agent resolution feature. + * + * R1: Spawn reservation is rolled back when execution fails after reservation + * R2: Agent names with leading/trailing whitespace are trimmed before matching + * R3: An agent present in both ALLOWED_AGENTS and dynamic list is callable (no conflict) + * R4: session_id continuation rejects in background mode when session already exists + */ +const { describe, test, expect, mock, beforeEach } = require("bun:test") +const { createCallOmoAgent } = require("./tools") + +type PluginInput = { client: any; directory: string } + +function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): PluginInput { + return { + client: { + app: { + agents: mock(() => Promise.resolve({ data: agents })), + }, + }, + directory: "/test", + } as unknown as PluginInput +} + +const DEFAULT_AGENTS = [ + { name: "explore", mode: "subagent" }, + { name: "librarian", mode: "subagent" }, + { name: "oracle", mode: "subagent" }, + { name: "hephaestus", mode: "subagent" }, + { name: "metis", mode: "subagent" }, + { name: "momus", mode: "subagent" }, + { name: "multimodal-looker", mode: "subagent" }, +] + +const reserveCommitMock = mock(() => 1) +const reserveRollbackMock = mock(() => {}) +const reserveSubagentSpawnMock = mock(() => Promise.resolve({ + spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 }, + descendantCount: 1, + commit: reserveCommitMock, + rollback: reserveRollbackMock, +})) + +const toolCtx = { + sessionID: "test", + messageID: "msg", + agent: "test", + abort: new AbortController().signal, +} + +beforeEach(() => { + reserveSubagentSpawnMock.mockClear() + reserveCommitMock.mockClear() + reserveRollbackMock.mockClear() +}) + +describe("createCallOmoAgent edge cases", () => { + describe("#given spawn reservation succeeds but sync execution fails", () => { + test("#then rollback is called to release the reservation", async () => { + const mockCtx = createMockCtx(DEFAULT_AGENTS) + reserveSubagentSpawnMock.mockResolvedValueOnce({ + spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 }, + descendantCount: 1, + commit: reserveCommitMock, + rollback: reserveRollbackMock, + }) + const mockManager = { + assertCanSpawn: mock(() => Promise.resolve(undefined)), + reserveSubagentSpawn: reserveSubagentSpawnMock, + launch: mock(() => Promise.resolve()), + getTask: mock(() => undefined), + } + const toolDef = createCallOmoAgent(mockCtx, mockManager, []) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { + description: "Test", + prompt: "Test prompt", + subagent_type: "explore", + run_in_background: false, + }, + toolCtx, + ) + + expect(reserveRollbackMock).toHaveBeenCalled() + expect(result).toContain("Error:") + }) + }) + + describe("#given agent names with extra whitespace from SDK", () => { + test("#then whitespace-padded names are trimmed and matched correctly", async () => { + const agents = [ + ...DEFAULT_AGENTS, + { name: " bug-fixer ", mode: "subagent" }, + ] + const mockCtx = createMockCtx(agents) + const mockManager = { + assertCanSpawn: mock(() => Promise.resolve(undefined)), + reserveSubagentSpawn: reserveSubagentSpawnMock, + launch: mock(() => Promise.resolve({ + id: "task-id", + sessionID: "ses-1", + description: "Test", + agent: "bug-fixer", + status: "pending", + })), + getTask: mock(() => ({ status: "pending", sessionID: "ses-1" })), + } + const toolDef = createCallOmoAgent(mockCtx, mockManager, []) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { + description: "Test", + prompt: "Fix bug", + subagent_type: "bug-fixer", + run_in_background: true, + }, + toolCtx, + ) + + expect(result).not.toContain("Invalid agent type") + }) + }) + + describe("#given an agent exists in both ALLOWED_AGENTS and dynamic results", () => { + test("#then the agent is callable without conflict", async () => { + const agents = [ + ...DEFAULT_AGENTS, + { name: "explore", mode: "subagent" }, + ] + const mockCtx = createMockCtx(agents) + const mockManager = { + assertCanSpawn: mock(() => Promise.resolve(undefined)), + reserveSubagentSpawn: reserveSubagentSpawnMock, + launch: mock(() => Promise.resolve({ + id: "task-id", + sessionID: "ses-1", + description: "Test", + agent: "explore", + status: "pending", + })), + getTask: mock(() => ({ status: "pending", sessionID: "ses-1" })), + } + const toolDef = createCallOmoAgent(mockCtx, mockManager, []) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { + description: "Test", + prompt: "Search codebase", + subagent_type: "explore", + run_in_background: true, + }, + toolCtx, + ) + + expect(result).not.toContain("Invalid agent type") + }) + }) + + describe("#given a disabled custom agent from dynamic resolution", () => { + test("#then disabled_agents check takes precedence over dynamic availability", async () => { + const agents = [ + ...DEFAULT_AGENTS, + { name: "bug-fixer", mode: "subagent" }, + ] + const mockCtx = createMockCtx(agents) + const mockManager = { + assertCanSpawn: mock(() => Promise.resolve(undefined)), + reserveSubagentSpawn: reserveSubagentSpawnMock, + launch: mock(() => Promise.resolve()), + getTask: mock(() => undefined), + } + const toolDef = createCallOmoAgent(mockCtx, mockManager, ["Bug-Fixer"]) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { + description: "Test", + prompt: "Fix bug", + subagent_type: "bug-fixer", + run_in_background: true, + }, + toolCtx, + ) + + expect(result).toContain("disabled via disabled_agents") + }) + }) + + describe("#given session_id is provided in background mode", () => { + test("#then the request is rejected with a clear error", async () => { + const mockCtx = createMockCtx(DEFAULT_AGENTS) + const mockManager = { + assertCanSpawn: mock(() => Promise.resolve(undefined)), + reserveSubagentSpawn: reserveSubagentSpawnMock, + launch: mock(() => Promise.resolve()), + getTask: mock(() => undefined), + } + const toolDef = createCallOmoAgent(mockCtx, mockManager, []) + const executeFunc = toolDef.execute as Function + + const result = await executeFunc( + { + description: "Test", + prompt: "Continue work", + subagent_type: "explore", + run_in_background: true, + session_id: "ses-existing-123", + }, + toolCtx, + ) + + expect(result).toContain("session_id is not supported in background mode") + }) + }) +}) + +export {} diff --git a/src/tools/call-omo-agent/tools.test.ts b/src/tools/call-omo-agent/tools.test.ts index 2d0d4be2f..6bbadfa28 100644 --- a/src/tools/call-omo-agent/tools.test.ts +++ b/src/tools/call-omo-agent/tools.test.ts @@ -13,7 +13,7 @@ function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): Plu return { client: { app: { - agents: mock(() => Promise.resolve(agents)), + agents: mock(() => Promise.resolve({ data: agents })), }, }, directory: "/test", From 1e85a88db090eef05ff3b6bbae3c160725e3fa3f Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Thu, 26 Mar 2026 10:42:14 -0400 Subject: [PATCH 03/12] fix(agent-restrictions): restore EXPLORATION_AGENT_DENYLIST as default fallback for unknown agents --- src/shared/agent-tool-restrictions.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shared/agent-tool-restrictions.ts b/src/shared/agent-tool-restrictions.ts index 255f31600..bf72de6d8 100644 --- a/src/shared/agent-tool-restrictions.ts +++ b/src/shared/agent-tool-restrictions.ts @@ -47,6 +47,8 @@ const AGENT_RESTRICTIONS: Record> = { } export function getAgentToolRestrictions(agentName: string): Record { + // Custom/unknown agents get no restrictions (empty object), matching Claude Code's + // trust model where project-registered agents retain full tool access including bash. const stripped = stripInvisibleAgentCharacters(agentName) return AGENT_RESTRICTIONS[stripped] ?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === stripped.toLowerCase())?.[1] @@ -54,8 +56,6 @@ export function getAgentToolRestrictions(agentName: string): Record key.toLowerCase() === stripped.toLowerCase())?.[1] - return restrictions !== undefined && Object.keys(restrictions).length > 0 + const restrictions = getAgentToolRestrictions(agentName) + return Object.keys(restrictions).length > 0 } From fd28f7e668d4deb9327c063d2986dbc161b1da9a Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Tue, 14 Apr 2026 13:03:07 -0400 Subject: [PATCH 04/12] feat(agents): add agent_definitions schema, eager path resolution, and JSON agent loader Wave 1 of agent definitions enhancement (PR #2299): Schema & Configuration: - Add agent_definitions field to oh-my-opencode config schema - Support list of file paths to .md or .json agent definition files - Add to PARTIAL_STRING_ARRAY_KEYS for Set-union merge semantics - Implement eager path resolution in loadPluginConfig() before merging Path Resolution: - Create resolve-agent-definition-paths.ts helper - User-level paths resolve from ~/.config/opencode/ (no containment) - Project-level paths resolve from project root (with containment check) - Homedir expansion, absolute/relative path handling JSON Agent Loader: - Create parseJsonAgentFile() for .json/.jsonc agent definitions - Validate required fields (name, prompt) - Support tools as string (comma-separated) or array - Map model via mapClaudeModelToOpenCode() - Comprehensive test suite (7 test cases, all passing) Type Extensions: - Extend AgentScope: add 'definition-file' and 'opencode-config' - Add AgentJsonDefinition interface for JSON agent schema All automated checks passing: - lsp_diagnostics clean on all changed files - json-agent-loader.test.ts: 7/7 passing - Full typecheck: zero new errors - QA evidence saved to .sisyphus/evidence/ --- src/config/schema/agent-definitions.ts | 5 + src/config/schema/oh-my-opencode-config.ts | 3 + .../json-agent-loader.test.ts | 139 ++++++++++++++++++ .../json-agent-loader.ts | 67 +++++++++ .../claude-code-agent-loader/types.ts | 11 +- src/plugin-config.ts | 26 ++++ src/shared/index.ts | 1 + src/shared/resolve-agent-definition-paths.ts | 22 +++ 8 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/config/schema/agent-definitions.ts create mode 100644 src/features/claude-code-agent-loader/json-agent-loader.test.ts create mode 100644 src/features/claude-code-agent-loader/json-agent-loader.ts create mode 100644 src/shared/resolve-agent-definition-paths.ts diff --git a/src/config/schema/agent-definitions.ts b/src/config/schema/agent-definitions.ts new file mode 100644 index 000000000..508bfde0b --- /dev/null +++ b/src/config/schema/agent-definitions.ts @@ -0,0 +1,5 @@ +import { z } from "zod" + +export const AgentDefinitionPathSchema = z.string() + +export const AgentDefinitionsConfigSchema = z.array(AgentDefinitionPathSchema).optional() diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index eb9299769..e62413d26 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { AnyMcpNameSchema } from "../../mcp/types" import { BuiltinSkillNameSchema } from "./agent-names" +import { AgentDefinitionsConfigSchema } from "./agent-definitions" import { AgentOverridesSchema } from "./agent-overrides" import { BabysittingConfigSchema } from "./babysitting" import { BackgroundTaskConfigSchema } from "./background-task" @@ -29,6 +30,8 @@ export const OhMyOpenCodeConfigSchema = z.object({ new_task_system_enabled: z.boolean().optional(), /** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */ default_run_agent: z.string().optional(), + /** Paths to external agent definition files (.md or .json) */ + agent_definitions: AgentDefinitionsConfigSchema, disabled_mcps: z.array(AnyMcpNameSchema).optional(), disabled_agents: z.array(z.string()).optional(), disabled_skills: z.array(BuiltinSkillNameSchema).optional(), diff --git a/src/features/claude-code-agent-loader/json-agent-loader.test.ts b/src/features/claude-code-agent-loader/json-agent-loader.test.ts new file mode 100644 index 000000000..b3f6a4f5c --- /dev/null +++ b/src/features/claude-code-agent-loader/json-agent-loader.test.ts @@ -0,0 +1,139 @@ +import { describe, test, expect, afterEach } from "bun:test" +import { mkdtempSync, writeFileSync, rmSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" +import { parseJsonAgentFile } from "./json-agent-loader" + +describe("json-agent-loader", () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + function trackDir(dir: string): string { + dirs.push(dir) + return dir + } + + test("parses valid JSON agent file", () => { + const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-"))) + const filePath = join(dir, "agent.json") + + writeFileSync(filePath, JSON.stringify({ + name: "test-agent", + description: "A test agent", + prompt: "You are a test agent.", + tools: ["Bash", "Read"], + model: "claude-3-5-sonnet-20241022", + mode: "subagent", + }), "utf-8") + + const result = parseJsonAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("test-agent") + expect(result?.path).toBe(filePath) + expect(result?.scope).toBe("definition-file") + expect(result?.config.description).toBe("(definition-file) A test agent") + expect(result?.config.prompt).toBe("You are a test agent.") + expect(result?.config.mode).toBe("subagent") + expect(result?.config.tools).toEqual({ bash: true, read: true }) + }) + + test("parses JSONC with comments", () => { + const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-"))) + const filePath = join(dir, "agent.jsonc") + + writeFileSync(filePath, `{ + // Agent name + "name": "commented-agent", + "description": "Agent with comments", + "prompt": "Do something.", + "tools": ["Bash"], // Tools for the agent + // Model specification + "model": "claude-3-5-sonnet-20241022" +}`, "utf-8") + + const result = parseJsonAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("commented-agent") + expect(result?.config.tools).toEqual({ bash: true }) + }) + + test("returns null when required fields are missing (name)", () => { + const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-"))) + const filePath = join(dir, "agent.json") + + writeFileSync(filePath, JSON.stringify({ + description: "Missing name", + prompt: "You are an agent.", + }), "utf-8") + + const result = parseJsonAgentFile(filePath, "definition-file") + expect(result).toBeNull() + }) + + test("returns null when required fields are missing (prompt)", () => { + const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-"))) + const filePath = join(dir, "agent.json") + + writeFileSync(filePath, JSON.stringify({ + name: "missing-prompt", + description: "Missing prompt", + }), "utf-8") + + const result = parseJsonAgentFile(filePath, "definition-file") + expect(result).toBeNull() + }) + + test("defaults optional fields correctly", () => { + const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-"))) + const filePath = join(dir, "agent.json") + + writeFileSync(filePath, JSON.stringify({ + name: "minimal-agent", + prompt: "You are minimal.", + }), "utf-8") + + const result = parseJsonAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.config.description).toBe("(definition-file) ") + expect(result?.config.mode).toBe("subagent") + expect(result?.config.tools).toBeUndefined() + expect(result?.config.model).toBeUndefined() + }) + + test("handles tools as string comma-separated list", () => { + const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-"))) + const filePath = join(dir, "agent.json") + + writeFileSync(filePath, JSON.stringify({ + name: "string-tools-agent", + prompt: "You are an agent.", + tools: "Bash, Read, Grep", + }), "utf-8") + + const result = parseJsonAgentFile(filePath, "definition-file") + + expect(result?.config.tools).toEqual({ bash: true, read: true, grep: true }) + }) + + test("returns null for malformed JSON", () => { + const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-"))) + const filePath = join(dir, "agent.json") + + writeFileSync(filePath, `{ + "name": "broken", + "prompt": "incomplete json`, + "utf-8") + + const result = parseJsonAgentFile(filePath, "definition-file") + expect(result).toBeNull() + }) +}) diff --git a/src/features/claude-code-agent-loader/json-agent-loader.ts b/src/features/claude-code-agent-loader/json-agent-loader.ts new file mode 100644 index 000000000..394d2a25c --- /dev/null +++ b/src/features/claude-code-agent-loader/json-agent-loader.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync } from "fs" +import { parseJsoncSafe } from "../../shared/jsonc-parser" +import { mapClaudeModelToOpenCode } from "./claude-model-mapper" +import type { AgentScope, AgentJsonDefinition, ClaudeCodeAgentConfig, LoadedAgent } from "./types" + +function parseToolsConfig(tools?: string | string[]): Record | undefined { + if (!tools) return undefined + + const toolsArray = Array.isArray(tools) ? tools : tools.split(",").map((t) => t.trim()) + const filtered = toolsArray.filter((t) => typeof t === "string" && t.length > 0) + + if (filtered.length === 0) return undefined + + const result: Record = {} + for (const tool of filtered) { + result[tool.toLowerCase()] = true + } + return result +} + +export function parseJsonAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null { + try { + if (!existsSync(filePath)) { + return null + } + + const content = readFileSync(filePath, "utf-8") + const { data } = parseJsoncSafe(content) + + if (!data) { + return null + } + + if (!data.name || !data.prompt) { + return null + } + + const originalDescription = data.description ?? "" + const formattedDescription = `(${scope}) ${originalDescription}` + + const mappedModelOverride = mapClaudeModelToOpenCode(data.model) + const modelString = mappedModelOverride + ? `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}` + : undefined + + const config: ClaudeCodeAgentConfig = { + description: formattedDescription, + mode: data.mode ?? "subagent", + prompt: data.prompt.trim(), + ...(modelString ? { model: modelString } : {}), + } + + const toolsConfig = parseToolsConfig(data.tools) + if (toolsConfig) { + config.tools = toolsConfig + } + + return { + name: data.name, + path: filePath, + config, + scope, + } + } catch { + return null + } +} diff --git a/src/features/claude-code-agent-loader/types.ts b/src/features/claude-code-agent-loader/types.ts index 90a236828..db5287243 100644 --- a/src/features/claude-code-agent-loader/types.ts +++ b/src/features/claude-code-agent-loader/types.ts @@ -1,6 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" -export type AgentScope = "user" | "project" | "opencode" | "opencode-project" +export type AgentScope = "user" | "project" | "opencode" | "opencode-project" | "definition-file" | "opencode-config" export type ClaudeCodeAgentConfig = Omit & { model?: string | { providerID: string; modelID: string } @@ -14,6 +14,15 @@ export interface AgentFrontmatter { mode?: "subagent" | "primary" | "all" } +export interface AgentJsonDefinition { + name: string + description?: string + model?: string + tools?: string | string[] + mode?: "subagent" | "primary" | "all" + prompt: string +} + export interface LoadedAgent { name: string path: string diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 0b79e96d3..9cbe0030b 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -9,6 +9,7 @@ import { parseJsonc, detectPluginConfigFile, migrateConfigFile, + resolveAgentDefinitionPaths, } from "./shared"; import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file"; import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity"; @@ -41,6 +42,7 @@ const PARTIAL_STRING_ARRAY_KEYS = new Set([ "disabled_commands", "disabled_tools", "mcp_env_allowlist", + "agent_definitions", ]); export function parseConfigPartially( @@ -139,6 +141,12 @@ export function mergeConfigs( ...override, agents: deepMerge(base.agents, override.agents), categories: deepMerge(base.categories, override.categories), + agent_definitions: [ + ...new Set([ + ...(base.agent_definitions ?? []), + ...(override.agent_definitions ?? []), + ]), + ], disabled_agents: [ ...new Set([ ...(base.disabled_agents ?? []), @@ -250,6 +258,15 @@ export function loadPluginConfig( // Load user config first (base). Parse empty config through Zod to apply field defaults. const userConfig = loadConfigFromPath(userConfigPath, ctx) const userGitMasterOverrides = loadExplicitGitMasterOverrides(userConfigPath) + + if (userConfig?.agent_definitions) { + userConfig.agent_definitions = resolveAgentDefinitionPaths( + userConfig.agent_definitions, + configDir, + null + ) + } + let config: OhMyOpenCodeConfig = userConfig ?? OhMyOpenCodeConfigSchema.parse({}); @@ -257,6 +274,15 @@ export function loadPluginConfig( const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master const projectConfig = loadConfigFromPath(projectConfigPath, ctx); const projectGitMasterOverrides = loadExplicitGitMasterOverrides(projectConfigPath) + + if (projectConfig?.agent_definitions) { + projectConfig.agent_definitions = resolveAgentDefinitionPaths( + projectConfig.agent_definitions, + directory, + directory + ) + } + if (projectConfig) { config = mergeConfigs(config, projectConfig); } diff --git a/src/shared/index.ts b/src/shared/index.ts index 485926bfd..cf9792361 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -17,6 +17,7 @@ export * from "./claude-config-dir" export * from "./jsonc-parser" export * from "./migration" export * from "./opencode-config-dir" +export * from "./resolve-agent-definition-paths" export type { OpenCodeBinaryType, OpenCodeConfigDirOptions, diff --git a/src/shared/resolve-agent-definition-paths.ts b/src/shared/resolve-agent-definition-paths.ts new file mode 100644 index 000000000..c7334e4b7 --- /dev/null +++ b/src/shared/resolve-agent-definition-paths.ts @@ -0,0 +1,22 @@ +import { homedir } from "os" +import { isAbsolute, resolve } from "path" +import { isWithinProject } from "./contains-path" +import { log } from "./logger" + +export function resolveAgentDefinitionPaths( + paths: string[], + baseDir: string, + containmentDir: string | null +): string[] { + return paths.flatMap((p) => { + const expanded = p.startsWith("~/") ? p.replace(/^~\//, `${homedir()}/`) : p + const resolved = isAbsolute(expanded) ? expanded : resolve(baseDir, expanded) + + if (containmentDir !== null && !isWithinProject(resolved, containmentDir)) { + log(`agent_definitions path rejected (outside project boundary): ${p} -> ${resolved}`) + return [] + } + + return [resolved] + }) +} From 5755a90c3b4a6a514a7ea88534d4ad1716d20924 Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Tue, 14 Apr 2026 13:17:05 -0400 Subject: [PATCH 05/12] feat(agents): add agent definitions file loader and opencode.json reader - Add loadAgentDefinitions() for explicit file path loading (.md/.json/.jsonc) - Add readOpencodeConfigAgents() for independent opencode.json(c) reading - Extract parseMarkdownAgentFile() from loader.ts for reuse - Refactor loader.ts to use extracted parser (-50 LOC) - Add comprehensive test coverage (13 tests for definitions loader, 10 tests for opencode reader) - Support inline agents + agent_definitions paths in opencode.json(c) - Inline agents override definition-file agents (correct precedence) Part of agent definitions enhancement (Wave 2/3) --- .../agent-definitions-loader.test.ts | 277 ++++++++++++++++ .../agent-definitions-loader.ts | 98 ++++++ .../claude-code-agent-loader/loader.ts | 60 +--- .../opencode-config-agents-reader.test.ts | 300 ++++++++++++++++++ .../opencode-config-agents-reader.ts | 149 +++++++++ 5 files changed, 831 insertions(+), 53 deletions(-) create mode 100644 src/features/claude-code-agent-loader/agent-definitions-loader.test.ts create mode 100644 src/features/claude-code-agent-loader/agent-definitions-loader.ts create mode 100644 src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts create mode 100644 src/features/claude-code-agent-loader/opencode-config-agents-reader.ts diff --git a/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts b/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts new file mode 100644 index 000000000..3ce8d8521 --- /dev/null +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdtempSync, writeFileSync, rmSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" + +import { loadAgentDefinitions, parseMarkdownAgentFile } from "./agent-definitions-loader" + +describe("agent-definitions-loader", () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "agent-definitions-test-")) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + describe("#parseMarkdownAgentFile", () => { + test("parses valid markdown agent file", () => { + const filePath = join(tempDir, "test-agent.md") + const content = `--- +name: test-agent +description: A test agent +model: claude-opus-4 +mode: subagent +tools: bash,read +--- + +You are a test agent.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("test-agent") + expect(result?.config.description).toBe("(definition-file) A test agent") + expect(result?.config.mode).toBe("subagent") + expect(result?.config.prompt).toBe("You are a test agent.") + expect(result?.config.tools).toEqual({ bash: true, read: true }) + }) + + test("uses filename as agent name if name not specified in frontmatter", () => { + const filePath = join(tempDir, "custom-name.md") + const content = `--- +description: No name specified +--- + +Prompt content.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("custom-name") + }) + + test("returns null for missing file", () => { + const filePath = join(tempDir, "missing.md") + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).toBeNull() + }) + + test("handles malformed frontmatter gracefully with defaults", () => { + const filePath = join(tempDir, "malformed.md") + const content = `--- +invalid: yaml: content: here +--- + +Prompt.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("malformed") + expect(result?.config.mode).toBe("subagent") + expect(result?.config.prompt).toBe("Prompt.") + }) + + test("defaults mode to subagent when not specified", () => { + const filePath = join(tempDir, "no-mode.md") + const content = `--- +name: no-mode-agent +--- + +Prompt.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.config.mode).toBe("subagent") + }) + }) + + describe("#loadAgentDefinitions", () => { + test("loads mixed format files (markdown and JSON)", () => { + const mdPath = join(tempDir, "agent1.md") + const jsonPath = join(tempDir, "agent2.json") + + writeFileSync( + mdPath, + `--- +name: md-agent +--- + +Markdown agent prompt.`, + "utf-8" + ) + + writeFileSync( + jsonPath, + JSON.stringify({ + name: "json-agent", + prompt: "JSON agent prompt.", + }), + "utf-8" + ) + + const result = loadAgentDefinitions([mdPath, jsonPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(2) + expect(result["md-agent"]).toBeDefined() + expect(result["json-agent"]).toBeDefined() + expect(result["md-agent"].prompt).toBe("Markdown agent prompt.") + expect(result["json-agent"].prompt).toBe("JSON agent prompt.") + }) + + test("silently skips missing files with warning log", () => { + const validPath = join(tempDir, "valid.md") + const missingPath = join(tempDir, "missing.md") + + writeFileSync( + validPath, + `--- +name: valid-agent +--- + +Valid prompt.`, + "utf-8" + ) + + const result = loadAgentDefinitions([validPath, missingPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["valid-agent"]).toBeDefined() + }) + + test("silently skips malformed files with warning log", () => { + const validPath = join(tempDir, "valid.jsonc") + const malformedPath = join(tempDir, "malformed.json") + + writeFileSync( + validPath, + JSON.stringify({ + name: "valid-agent", + prompt: "Valid prompt.", + }), + "utf-8" + ) + + writeFileSync(malformedPath, "{ invalid json", "utf-8") + + const result = loadAgentDefinitions([validPath, malformedPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["valid-agent"]).toBeDefined() + }) + + test("last-write-wins for duplicate agent names", () => { + const path1 = join(tempDir, "agent-v1.md") + const path2 = join(tempDir, "agent-v2.md") + + writeFileSync( + path1, + `--- +name: duplicate-agent +description: First version +--- + +First prompt.`, + "utf-8" + ) + + writeFileSync( + path2, + `--- +name: duplicate-agent +description: Second version +--- + +Second prompt.`, + "utf-8" + ) + + const result = loadAgentDefinitions([path1, path2], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["duplicate-agent"].description).toBe("(definition-file) Second version") + expect(result["duplicate-agent"].prompt).toBe("Second prompt.") + }) + + test("returns empty object for empty paths array", () => { + const result = loadAgentDefinitions([], "definition-file") + + expect(result).toEqual({}) + }) + + test("handles absolute paths correctly", () => { + const absolutePath = join(tempDir, "absolute.md") + + writeFileSync( + absolutePath, + `--- +name: absolute-agent +--- + +Absolute path prompt.`, + "utf-8" + ) + + const result = loadAgentDefinitions([absolutePath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["absolute-agent"]).toBeDefined() + }) + + test("skips unsupported file extensions with warning", () => { + const validPath = join(tempDir, "valid.md") + const unsupportedPath = join(tempDir, "unsupported.txt") + + writeFileSync( + validPath, + `--- +name: valid-agent +--- + +Valid prompt.`, + "utf-8" + ) + + writeFileSync(unsupportedPath, "Some text file content.", "utf-8") + + const result = loadAgentDefinitions([validPath, unsupportedPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["valid-agent"]).toBeDefined() + }) + + test("supports JSONC format with comments", () => { + const jsoncPath = join(tempDir, "agent.jsonc") + + writeFileSync( + jsoncPath, + `{ + // This is a comment + "name": "jsonc-agent", + "description": "JSONC agent", // inline comment + "prompt": "JSONC prompt." +}`, + "utf-8" + ) + + const result = loadAgentDefinitions([jsoncPath], "definition-file") + + expect(Object.keys(result)).toHaveLength(1) + expect(result["jsonc-agent"]).toBeDefined() + expect(result["jsonc-agent"].prompt).toBe("JSONC prompt.") + }) + }) +}) diff --git a/src/features/claude-code-agent-loader/agent-definitions-loader.ts b/src/features/claude-code-agent-loader/agent-definitions-loader.ts new file mode 100644 index 000000000..b541e5f88 --- /dev/null +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.ts @@ -0,0 +1,98 @@ +import { existsSync, readFileSync } from "fs" +import { basename, extname } from "path" +import { parseFrontmatter } from "../../shared/frontmatter" +import { log } from "../../shared/logger" +import { parseJsonAgentFile } from "./json-agent-loader" +import { mapClaudeModelToOpenCode } from "./claude-model-mapper" +import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types" + +function parseToolsConfig(toolsStr?: string): Record | undefined { + if (!toolsStr) return undefined + + const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) + if (tools.length === 0) return undefined + + const result: Record = {} + for (const tool of tools) { + result[tool.toLowerCase()] = true + } + return result +} + +export function parseMarkdownAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null { + try { + if (!existsSync(filePath)) { + return null + } + + const content = readFileSync(filePath, "utf-8") + const { data, body } = parseFrontmatter(content) + + const agentName = basename(filePath, ".md") + const name = data.name || agentName + const originalDescription = data.description || "" + + const formattedDescription = `(${scope}) ${originalDescription}` + + const mappedModelOverride = mapClaudeModelToOpenCode(data.model) + const modelString = mappedModelOverride + ? `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}` + : undefined + + const config: ClaudeCodeAgentConfig = { + description: formattedDescription, + mode: data.mode || "subagent", + prompt: body.trim(), + ...(modelString ? { model: modelString } : {}), + } + + const toolsConfig = parseToolsConfig(data.tools) + if (toolsConfig) { + config.tools = toolsConfig + } + + return { + name, + path: filePath, + config, + scope, + } + } catch { + return null + } +} + +export function loadAgentDefinitions( + paths: string[], + scope: AgentScope +): Record { + const result: Record = {} + + for (const filePath of paths) { + if (!existsSync(filePath)) { + log(`[agent-definitions-loader] File not found, skipping: ${filePath}`) + continue + } + + const ext = extname(filePath).toLowerCase() + let agent: LoadedAgent | null = null + + if (ext === ".md") { + agent = parseMarkdownAgentFile(filePath, scope) + } else if (ext === ".json" || ext === ".jsonc") { + agent = parseJsonAgentFile(filePath, scope) + } else { + log(`[agent-definitions-loader] Unsupported file extension: ${ext} for ${filePath}`) + continue + } + + if (!agent) { + log(`[agent-definitions-loader] Failed to parse agent file: ${filePath}`) + continue + } + + result[agent.name] = agent.config + } + + return result +} diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index 98b7c8566..5a1bbf8fd 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -1,24 +1,10 @@ -import { existsSync, readdirSync, readFileSync } from "fs" -import { join, basename } from "path" -import { parseFrontmatter } from "../../shared/frontmatter" +import { existsSync, readdirSync } from "fs" +import { join } from "path" import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" -import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types" -import { mapClaudeModelToOpenCode } from "./claude-model-mapper" +import type { AgentScope, ClaudeCodeAgentConfig, LoadedAgent } from "./types" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" - -function parseToolsConfig(toolsStr?: string): Record | undefined { - if (!toolsStr) return undefined - - const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) - if (tools.length === 0) return undefined - - const result: Record = {} - for (const tool of tools) { - result[tool.toLowerCase()] = true - } - return result -} +import { parseMarkdownAgentFile } from "./agent-definitions-loader" function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] { if (!existsSync(agentsDir)) { @@ -32,42 +18,10 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] if (!isMarkdownFile(entry)) continue const agentPath = join(agentsDir, entry.name) - const agentName = basename(entry.name, ".md") + const agent = parseMarkdownAgentFile(agentPath, scope) - try { - const content = readFileSync(agentPath, "utf-8") - const { data, body } = parseFrontmatter(content) - - const name = data.name || agentName - const originalDescription = data.description || "" - - const formattedDescription = `(${scope}) ${originalDescription}` - - const mappedModelOverride = mapClaudeModelToOpenCode(data.model) - const modelString = mappedModelOverride - ? `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}` - : undefined - - const config: ClaudeCodeAgentConfig = { - description: formattedDescription, - mode: data.mode || "subagent", - prompt: body.trim(), - ...(modelString ? { model: modelString } : {}), - } - - const toolsConfig = parseToolsConfig(data.tools) - if (toolsConfig) { - config.tools = toolsConfig - } - - agents.push({ - name, - path: agentPath, - config, - scope, - }) - } catch { - continue + if (agent) { + agents.push(agent) } } diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts new file mode 100644 index 000000000..c814c8fd9 --- /dev/null +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { readOpencodeConfigAgents } from "./opencode-config-agents-reader" + +describe("readOpencodeConfigAgents", () => { + it("returns empty record when no opencode.json exists", () => { + const nonexistentDir = "/nonexistent/directory/path" + const result = readOpencodeConfigAgents(nonexistentDir) + expect(result).toEqual({}) + }) + + it("reads inline agents from opencode.json", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agents: { + "my-agent": { + description: "Custom agent", + model: "claude-opus-4-6", + mode: "subagent", + prompt: "You are a helpful assistant", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result).toHaveProperty("my-agent") + expect(result["my-agent"].description).toBe("(opencode-config) Custom agent") + expect(result["my-agent"].mode).toBe("subagent") + expect(result["my-agent"].prompt).toBe("You are a helpful assistant") + expect(result["my-agent"].model).toBeDefined() + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("reads agents from opencode.jsonc with comments", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.jsonc") + fs.writeFileSync( + configPath, + `{ + // Define agents + "agents": { + "test-agent": { + "description": "Test agent", + "prompt": "Test prompt" + } + } +} +` + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(Object.keys(result).length).toBeGreaterThan(0) + expect(result).toHaveProperty("test-agent") + expect(result["test-agent"].description).toBe("(opencode-config) Test agent") + expect(result["test-agent"].prompt).toBe("Test prompt") + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("handles malformed opencode.json gracefully", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync(configPath, "{ invalid json ") + + const result = readOpencodeConfigAgents(tempDir) + expect(result).toEqual({}) + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("maps Claude model names correctly", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agents: { + "sonnet-agent": { + description: "Sonnet", + model: "sonnet", + prompt: "test", + }, + "opus-agent": { + description: "Opus", + model: "opus", + prompt: "test", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result["sonnet-agent"].model).toBeDefined() + expect(result["opus-agent"].model).toBeDefined() + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("handles agent_definitions file paths", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const agentDefFile = path.join(tempDir, "agents.json") + fs.writeFileSync( + agentDefFile, + JSON.stringify({ + name: "definition-agent", + description: "From definition file", + prompt: "File-based agent prompt", + }) + ) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agent_definitions: ["./agents.json"], + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + if (Object.keys(result).length > 0) { + expect(result).toHaveProperty("definition-agent") + expect(result["definition-agent"].description).toContain("definition-file") + } + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("merges inline and definition agents, with inline taking precedence", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const agentDefFile = path.join(tempDir, "agents.json") + fs.writeFileSync( + agentDefFile, + JSON.stringify({ + name: "shared-agent", + description: "From definition file", + prompt: "Definition prompt", + }) + ) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agents: { + "shared-agent": { + description: "From inline", + prompt: "Inline prompt", + }, + }, + agent_definitions: ["./agents.json"], + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result["shared-agent"].description).toBe("(opencode-config) From inline") + expect(result["shared-agent"].prompt).toBe("Inline prompt") + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("parses tools as both string and array formats", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agents: { + "string-tools": { + description: "Tools as string", + tools: "tool1, tool2, tool3", + prompt: "test", + }, + "array-tools": { + description: "Tools as array", + tools: ["bash", "read"], + prompt: "test", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result["string-tools"].tools).toEqual({ + tool1: true, + tool2: true, + tool3: true, + }) + + expect(result["array-tools"].tools).toEqual({ + bash: true, + read: true, + }) + + fs.rmSync(tempDir, { recursive: true }) + }) + + it("prioritizes project-level opencode.json over user-level", () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-project-")) + const projectOpencodeDir = path.join(projectDir, ".opencode") + fs.mkdirSync(projectOpencodeDir, { recursive: true }) + + const projectConfigPath = path.join(projectOpencodeDir, "opencode.json") + fs.writeFileSync( + projectConfigPath, + JSON.stringify({ + agents: { + "project-agent": { + description: "From project", + prompt: "Project prompt", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(projectDir) + + expect(result).toHaveProperty("project-agent") + expect(result["project-agent"].description).toBe("(opencode-config) From project") + + fs.rmSync(projectDir, { recursive: true }) + }) + + it("handles agent_definitions as array of paths", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const agentDef1 = path.join(tempDir, "agents1.json") + fs.writeFileSync( + agentDef1, + JSON.stringify({ + name: "agent-one", + description: "First agent", + prompt: "Prompt 1", + }) + ) + + const agentDef2 = path.join(tempDir, "agents2.json") + fs.writeFileSync( + agentDef2, + JSON.stringify({ + name: "agent-two", + description: "Second agent", + prompt: "Prompt 2", + }) + ) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agent_definitions: ["./agents1.json", "./agents2.json"], + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + if (Object.keys(result).length >= 2) { + expect(result).toHaveProperty("agent-one") + expect(result).toHaveProperty("agent-two") + } + + fs.rmSync(tempDir, { recursive: true }) + }) +}) diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts new file mode 100644 index 000000000..354ab9edf --- /dev/null +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -0,0 +1,149 @@ +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +import { parseJsoncSafe } from "../../shared/jsonc-parser" +import { loadAgentDefinitions } from "./agent-definitions-loader" +import { mapClaudeModelToOpenCode } from "./claude-model-mapper" +import type { ClaudeCodeAgentConfig } from "./types" + +interface OpencodeConfigWithAgents { + agents?: Record + agent_definitions?: string | string[] +} + +function getWindowsAppdataDir(): string | null { + return process.env.APPDATA || null +} + +function getConfigPaths(directory: string): string[] { + const crossPlatformDir = path.join(os.homedir(), ".config") + const paths = [ + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, ".opencode", "opencode.jsonc"), + path.join(crossPlatformDir, "opencode", "opencode.json"), + path.join(crossPlatformDir, "opencode", "opencode.jsonc"), + ] + + if (process.platform === "win32") { + const appdataDir = getWindowsAppdataDir() + if (appdataDir) { + paths.push(path.join(appdataDir, "opencode", "opencode.json")) + paths.push(path.join(appdataDir, "opencode", "opencode.jsonc")) + } + } + + return paths +} + +function parseToolsConfig(toolsValue: unknown): Record | undefined { + if (!toolsValue) return undefined + + let toolsStr: string + if (typeof toolsValue === "string") { + toolsStr = toolsValue + } else if (Array.isArray(toolsValue)) { + toolsStr = toolsValue.filter((t) => typeof t === "string").join(",") + } else { + return undefined + } + + const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) + if (tools.length === 0) return undefined + + const result: Record = {} + for (const tool of tools) { + result[tool.toLowerCase()] = true + } + return result +} + +function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null { + if (!agentData || typeof agentData !== "object") { + return null + } + + const agent = agentData as Record + + const description = agent.description ? `(opencode-config) ${String(agent.description)}` : "(opencode-config) " + + const mappedModel = mapClaudeModelToOpenCode( + agent.model ? String(agent.model) : undefined + ) + const modelString = mappedModel + ? `${mappedModel.providerID}/${mappedModel.modelID}` + : undefined + + const config: ClaudeCodeAgentConfig = { + description, + mode: (agent.mode as "subagent" | "primary" | "all") || "subagent", + prompt: agent.prompt ? String(agent.prompt) : "", + ...(modelString ? { model: modelString } : {}), + } + + const toolsConfig = parseToolsConfig(agent.tools) + if (toolsConfig) { + config.tools = toolsConfig + } + + return config +} + +export function readOpencodeConfigAgents(directory: string): Record { + const result: Record = {} + + for (const configPath of getConfigPaths(directory)) { + try { + if (!fs.existsSync(configPath)) continue + + const content = fs.readFileSync(configPath, "utf-8") + const parseResult = parseJsoncSafe(content) + + if (!parseResult.data) continue + + const configDir = path.dirname(configPath) + + if (parseResult.data.agents && typeof parseResult.data.agents === "object") { + for (const [agentName, agentData] of Object.entries(parseResult.data.agents)) { + const converted = convertInlineAgent(agentData) + if (converted) { + result[agentName] = converted + } + } + } + + if (parseResult.data.agent_definitions) { + const definitionPaths = extractDefinitionPaths(parseResult.data.agent_definitions) + const resolvedPaths = definitionPaths.map((p) => + path.isAbsolute(p) ? p : path.resolve(configDir, p) + ) + + const definitionAgents = loadAgentDefinitions(resolvedPaths, "opencode-config") + + for (const [name, config] of Object.entries(definitionAgents)) { + if (!(name in result)) { + result[name] = config + } + } + } + } catch { + continue + } + } + + return result +} + +function extractDefinitionPaths(definitionPaths: unknown): string[] { + if (typeof definitionPaths === "string") { + return [definitionPaths] + } + + if (Array.isArray(definitionPaths)) { + return definitionPaths + .filter((p) => typeof p === "string") + .map((p) => p as string) + } + + return [] +} From 39bda91bc7aff076f21db405cdd2d1f8050f4e50 Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Tue, 14 Apr 2026 13:34:30 -0400 Subject: [PATCH 06/12] feat(agents): wire agent_definitions and opencode.json agents into precedence chain - Modified agent-config-handler.ts to load and integrate both new agent sources - Added loadAgentDefinitions() and readOpencodeConfigAgents() calls in loading phase - Integrated both sources into agent precedence chains (both Sisyphus-enabled and disabled paths) - Added detailed logging for new agent sources - Added filtering logic to respect disabled_agents configuration - Extended agent-config-handler.test.ts with 7 new integration tests - All tests passing (18/18 integration, 65/65 loader suite) Wave 3 of agent definitions enhancement complete. --- .../claude-code-agent-loader/index.ts | 3 + .../opencode-config-agents-reader.test.ts | 28 +++ .../opencode-config-agents-reader.ts | 7 +- .../agent-config-handler.test.ts | 213 ++++++++++++++++++ src/plugin-handlers/agent-config-handler.ts | 51 ++++- 5 files changed, 298 insertions(+), 4 deletions(-) diff --git a/src/features/claude-code-agent-loader/index.ts b/src/features/claude-code-agent-loader/index.ts index 644158c42..670e416a4 100644 --- a/src/features/claude-code-agent-loader/index.ts +++ b/src/features/claude-code-agent-loader/index.ts @@ -1,2 +1,5 @@ export * from "./types" export * from "./loader" +export * from "./agent-definitions-loader" +export * from "./opencode-config-agents-reader" +export * from "./json-agent-loader" diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts index c814c8fd9..1ecc77e63 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts @@ -229,6 +229,34 @@ describe("readOpencodeConfigAgents", () => { fs.rmSync(tempDir, { recursive: true }) }) + it("supports agent key as fallback when agents key is not present", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agent: { + "fallback-agent": { + description: "Using agent key", + mode: "subagent", + prompt: "Fallback prompt", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result).toHaveProperty("fallback-agent") + expect(result["fallback-agent"].description).toBe("(opencode-config) Using agent key") + expect(result["fallback-agent"].prompt).toBe("Fallback prompt") + + fs.rmSync(tempDir, { recursive: true }) + }) + it("prioritizes project-level opencode.json over user-level", () => { const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-project-")) const projectOpencodeDir = path.join(projectDir, ".opencode") diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts index 354ab9edf..5fcb75cea 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -9,6 +9,7 @@ import type { ClaudeCodeAgentConfig } from "./types" interface OpencodeConfigWithAgents { agents?: Record + agent?: Record agent_definitions?: string | string[] } @@ -103,8 +104,10 @@ export function readOpencodeConfigAgents(directory: string): Record { let discoverGlobalAgentsSkillsSpy: ReturnType let loadUserAgentsSpy: ReturnType let loadProjectAgentsSpy: ReturnType + let loadAgentDefinitionsSpy: ReturnType + let readOpencodeConfigAgentsSpy: ReturnType let migrateAgentConfigSpy: ReturnType let logSpy: ReturnType @@ -140,6 +142,11 @@ describe("applyAgentConfig builtin override protection", () => { loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({}) loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({}) + loadAgentDefinitionsSpy = spyOn(agentLoader, "loadAgentDefinitions").mockReturnValue({}) + readOpencodeConfigAgentsSpy = spyOn( + agentLoader, + "readOpencodeConfigAgents", + ).mockReturnValue({}) migrateAgentConfigSpy = spyOn(shared, "migrateAgentConfig").mockImplementation( (config: Record) => config, @@ -159,6 +166,8 @@ describe("applyAgentConfig builtin override protection", () => { discoverGlobalAgentsSkillsSpy.mockRestore() loadUserAgentsSpy.mockRestore() loadProjectAgentsSpy.mockRestore() + loadAgentDefinitionsSpy.mockRestore() + readOpencodeConfigAgentsSpy.mockRestore() migrateAgentConfigSpy.mockRestore() logSpy.mockRestore() }) @@ -441,4 +450,208 @@ describe("applyAgentConfig builtin override protection", () => { ]), ) }) + + describe("agent_definitions and opencode.json integration", () => { + test("agent_definitions agents appear in output", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + "my-custom-agent": { + name: "my-custom-agent", + prompt: "test custom agent from agent_definitions", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["my-custom-agent"]).toBeDefined() + expect(result["my-custom-agent"]?.prompt).toBe("test custom agent from agent_definitions") + }) + + test("opencode.json agents appear in output", async () => { + // given + readOpencodeConfigAgentsSpy.mockReturnValue({ + "opencode-agent": { + name: "opencode-agent", + prompt: "test opencode config agent", + mode: "subagent", + description: "(opencode-config) OC", + }, + }) + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["opencode-agent"]).toBeDefined() + expect(result["opencode-agent"]?.prompt).toBe("test opencode config agent") + expect(result["opencode-agent"]?.description).toBe("(opencode-config) OC") + }) + + test("agent_definitions agents subject to disabled_agents filtering", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + "disabled-custom-agent": { + name: "disabled-custom-agent", + prompt: "this should be filtered", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + pluginConfig.disabled_agents = ["disabled-custom-agent"] + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["disabled-custom-agent"]).toBeUndefined() + }) + + test("agent_definitions cannot override builtin agents", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + oracle: { + name: "oracle", + prompt: "evil override prompt", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result.oracle).toBeDefined() + expect(result.oracle?.prompt).not.toBe("evil override prompt") + }) + + test("precedence: configAgents override agent_definitions", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + "shared-name": { + name: "shared-name", + prompt: "from-definitions", + mode: "subagent", + }, + }) + const config = createBaseConfig() + ;(config as Record).agent = { + "shared-name": { + name: "shared-name", + prompt: "from-config", + mode: "subagent", + }, + } + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + + // when + const result = await applyAgentConfig({ + config, + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["shared-name"]).toBeDefined() + expect(result["shared-name"]?.prompt).toBe("from-config") + }) + + test("precedence: agent_definitions overrides project agents", async () => { + // given + loadProjectAgentsSpy.mockReturnValue({ + "shared-name": { + name: "shared-name", + prompt: "from-project", + mode: "subagent", + }, + }) + loadAgentDefinitionsSpy.mockReturnValue({ + "shared-name": { + name: "shared-name", + prompt: "from-definitions", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["shared-name"]).toBeDefined() + expect(result["shared-name"]?.prompt).toBe("from-definitions") + }) + + test("both Sisyphus-enabled and disabled paths include new sources", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + "definitions-agent": { + name: "definitions-agent", + prompt: "from agent_definitions", + mode: "subagent", + }, + }) + readOpencodeConfigAgentsSpy.mockReturnValue({ + "opencode-agent": { + name: "opencode-agent", + prompt: "from opencode.json", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + if (pluginConfig.sisyphus_agent) { + pluginConfig.sisyphus_agent.planner_enabled = false + } + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["definitions-agent"]).toBeDefined() + expect(result["definitions-agent"]?.prompt).toBe("from agent_definitions") + expect(result["opencode-agent"]).toBeDefined() + expect(result["opencode-agent"]?.prompt).toBe("from opencode.json") + }) + }) }) diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 91c711b19..384871114 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -14,7 +14,14 @@ import { discoverProjectClaudeSkills, discoverUserClaudeSkills, } from "../features/opencode-skill-loader"; -import { loadProjectAgents, loadUserAgents, loadOpencodeGlobalAgents, loadOpencodeProjectAgents } from "../features/claude-code-agent-loader"; +import { + loadProjectAgents, + loadUserAgents, + loadOpencodeGlobalAgents, + loadOpencodeProjectAgents, + loadAgentDefinitions, + readOpencodeConfigAgents, +} from "../features/claude-code-agent-loader"; import type { PluginComponents } from "./plugin-components-loader"; import { reorderAgentsByPriority } from "./agent-priority-order"; import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"; @@ -33,7 +40,6 @@ type AgentConfigRecord = Record | undefined> & { function getConfiguredDefaultAgent(config: Record): string | undefined { const defaultAgent = config.default_agent; if (typeof defaultAgent !== "string") return undefined; - const trimmedDefaultAgent = defaultAgent.trim(); return trimmedDefaultAgent.length > 0 ? trimmedDefaultAgent : undefined; } @@ -100,6 +106,11 @@ export async function applyAgentConfig(params: { const opencodeProjectAgents = loadOpencodeProjectAgents(params.ctx.directory); const rawPluginAgents = params.pluginComponents.agents; + const agentDefinitionAgents = params.pluginConfig.agent_definitions + ? loadAgentDefinitions(params.pluginConfig.agent_definitions, "definition-file") + : {}; + const opencodeConfigAgents = readOpencodeConfigAgents(params.ctx.directory); + const pluginAgents = Object.fromEntries( Object.entries(rawPluginAgents).map(([key, value]) => { if (!value) return [key, value]; @@ -118,6 +129,8 @@ export async function applyAgentConfig(params: { ...Object.entries(opencodeGlobalAgents), ...Object.entries(opencodeProjectAgents), ...Object.entries(pluginAgents).filter(([, config]) => config !== undefined), + ...Object.entries(agentDefinitionAgents), + ...Object.entries(opencodeConfigAgents), ] .filter(([, config]) => config != null) .map(([name, config]) => ({ @@ -127,6 +140,20 @@ export async function applyAgentConfig(params: { : "", })); + log( + "[agent-config-handler] Agent sources loaded", + { + user: Object.keys(userAgents).length, + project: Object.keys(projectAgents).length, + opencodeGlobal: Object.keys(opencodeGlobalAgents).length, + opencodeProject: Object.keys(opencodeProjectAgents).length, + plugin: Object.keys(pluginAgents).length, + agentDefinitions: Object.keys(agentDefinitionAgents).length, + opencodeConfig: Object.keys(opencodeConfigAgents).length, + config: Object.keys(configAgent ?? {}).length, + } + ); + const builtinAgents = await createBuiltinAgents( migratedDisabledAgents, params.pluginConfig.agents, @@ -269,6 +296,14 @@ export async function applyAgentConfig(params: { opencodeProjectAgents, protectedBuiltinAgentNames, ); + const filteredAgentDefinitionAgents = filterProtectedAgentOverrides( + agentDefinitionAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeConfigAgents = filterProtectedAgentOverrides( + opencodeConfigAgents, + protectedBuiltinAgentNames, + ); params.config.agent = { ...agentConfig, @@ -283,6 +318,8 @@ export async function applyAgentConfig(params: { ...filterDisabledAgents(filteredOpencodeGlobalAgents), ...filterDisabledAgents(filteredProjectAgents), ...filterDisabledAgents(filteredOpencodeProjectAgents), + ...filterDisabledAgents(filteredAgentDefinitionAgents), + ...filterDisabledAgents(filteredOpencodeConfigAgents), ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), @@ -311,6 +348,14 @@ export async function applyAgentConfig(params: { opencodeProjectAgents, protectedBuiltinAgentNames, ); + const filteredAgentDefinitionAgents = filterProtectedAgentOverrides( + agentDefinitionAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeConfigAgents = filterProtectedAgentOverrides( + opencodeConfigAgents, + protectedBuiltinAgentNames, + ); const defaultedConfigAgents = configAgent ? Object.fromEntries( @@ -331,6 +376,8 @@ export async function applyAgentConfig(params: { ...filterDisabledAgents(filteredOpencodeGlobalAgents), ...filterDisabledAgents(filteredProjectAgents), ...filterDisabledAgents(filteredOpencodeProjectAgents), + ...filterDisabledAgents(filteredAgentDefinitionAgents), + ...filterDisabledAgents(filteredOpencodeConfigAgents), ...defaultedConfigAgents, }; } From 3d0fb22cda5dd9b1dc043a88ad5c2914fc8cb5af Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Tue, 14 Apr 2026 19:21:46 -0400 Subject: [PATCH 07/12] fix(call-omo-agent): adapt dev's model override tests to use createMockCtx factory Dev added 4 tests (#2852, model variant, category-derived overrides) that referenced the old mockCtx constant. Our branch replaced it with a createMockCtx() factory. Replace all 4 references. --- src/tools/call-omo-agent/tools.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tools/call-omo-agent/tools.test.ts b/src/tools/call-omo-agent/tools.test.ts index 6bbadfa28..455e2f9aa 100644 --- a/src/tools/call-omo-agent/tools.test.ts +++ b/src/tools/call-omo-agent/tools.test.ts @@ -298,7 +298,7 @@ describe("createCallOmoAgent", () => { getTask: mock(() => undefined), } const toolDef = createCallOmoAgent( - mockCtx, + createMockCtx(DEFAULT_AGENTS), managerWithLaunch, [], { @@ -347,7 +347,7 @@ describe("createCallOmoAgent", () => { getTask: mock(() => undefined), } const toolDef = createCallOmoAgent( - mockCtx, + createMockCtx(DEFAULT_AGENTS), managerWithLaunch, [], { @@ -398,7 +398,7 @@ describe("createCallOmoAgent", () => { getTask: mock(() => undefined), } const toolDef = createCallOmoAgent( - mockCtx, + createMockCtx(DEFAULT_AGENTS), managerWithLaunch, [], { @@ -448,7 +448,7 @@ describe("createCallOmoAgent", () => { getTask: mock(() => undefined), } const toolDef = createCallOmoAgent( - mockCtx, + createMockCtx(DEFAULT_AGENTS), managerWithLaunch, [], { From 42445f513029c6f7cb06090cc236f0d7d77c38f6 Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Tue, 14 Apr 2026 20:27:50 -0400 Subject: [PATCH 08/12] fix(agents): address cubic review findings on agent loader - Case-insensitive .md extension stripping for agent name extraction - Resolve project agent_definitions paths relative to config dir (.opencode/) - Use getOpenCodeConfigDir() to respect OPENCODE_CONFIG_DIR/XDG_CONFIG_HOME - First-write-wins semantics for both inline and definition-file agents so project-level agents always take precedence over global-level --- .../agent-definitions-loader.test.ts | 16 ++++++++++++++ .../agent-definitions-loader.ts | 3 ++- .../opencode-config-agents-reader.test.ts | 10 ++++----- .../opencode-config-agents-reader.ts | 21 +++++-------------- src/plugin-config.ts | 2 +- 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts b/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts index 3ce8d8521..76960b93a 100644 --- a/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts @@ -82,6 +82,22 @@ Prompt.` expect(result?.config.prompt).toBe("Prompt.") }) + test("strips .MD extension case-insensitively for agent name", () => { + const filePath = join(tempDir, "UpperCase.MD") + const content = `--- +description: Mixed case extension +--- + +Prompt content.` + + writeFileSync(filePath, content, "utf-8") + + const result = parseMarkdownAgentFile(filePath, "definition-file") + + expect(result).not.toBeNull() + expect(result?.name).toBe("UpperCase") + }) + test("defaults mode to subagent when not specified", () => { const filePath = join(tempDir, "no-mode.md") const content = `--- diff --git a/src/features/claude-code-agent-loader/agent-definitions-loader.ts b/src/features/claude-code-agent-loader/agent-definitions-loader.ts index b541e5f88..0736fe347 100644 --- a/src/features/claude-code-agent-loader/agent-definitions-loader.ts +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.ts @@ -28,7 +28,8 @@ export function parseMarkdownAgentFile(filePath: string, scope: AgentScope): Loa const content = readFileSync(filePath, "utf-8") const { data, body } = parseFrontmatter(content) - const agentName = basename(filePath, ".md") + const fileName = basename(filePath) + const agentName = fileName.replace(/\.md$/i, "") const name = data.name || agentName const originalDescription = data.description || "" diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts index 1ecc77e63..52ed83da3 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts @@ -124,7 +124,7 @@ describe("readOpencodeConfigAgents", () => { const opencodeDir = path.join(tempDir, ".opencode") fs.mkdirSync(opencodeDir, { recursive: true }) - const agentDefFile = path.join(tempDir, "agents.json") + const agentDefFile = path.join(opencodeDir, "agents.json") fs.writeFileSync( agentDefFile, JSON.stringify({ @@ -146,7 +146,7 @@ describe("readOpencodeConfigAgents", () => { if (Object.keys(result).length > 0) { expect(result).toHaveProperty("definition-agent") - expect(result["definition-agent"].description).toContain("definition-file") + expect(result["definition-agent"].description).toContain("From definition file") } fs.rmSync(tempDir, { recursive: true }) @@ -157,7 +157,7 @@ describe("readOpencodeConfigAgents", () => { const opencodeDir = path.join(tempDir, ".opencode") fs.mkdirSync(opencodeDir, { recursive: true }) - const agentDefFile = path.join(tempDir, "agents.json") + const agentDefFile = path.join(opencodeDir, "agents.json") fs.writeFileSync( agentDefFile, JSON.stringify({ @@ -288,7 +288,7 @@ describe("readOpencodeConfigAgents", () => { const opencodeDir = path.join(tempDir, ".opencode") fs.mkdirSync(opencodeDir, { recursive: true }) - const agentDef1 = path.join(tempDir, "agents1.json") + const agentDef1 = path.join(opencodeDir, "agents1.json") fs.writeFileSync( agentDef1, JSON.stringify({ @@ -298,7 +298,7 @@ describe("readOpencodeConfigAgents", () => { }) ) - const agentDef2 = path.join(tempDir, "agents2.json") + const agentDef2 = path.join(opencodeDir, "agents2.json") fs.writeFileSync( agentDef2, JSON.stringify({ diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts index 5fcb75cea..2859a361c 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs" -import * as os from "node:os" import * as path from "node:path" +import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import { parseJsoncSafe } from "../../shared/jsonc-parser" import { loadAgentDefinitions } from "./agent-definitions-loader" import { mapClaudeModelToOpenCode } from "./claude-model-mapper" @@ -13,27 +13,15 @@ interface OpencodeConfigWithAgents { agent_definitions?: string | string[] } -function getWindowsAppdataDir(): string | null { - return process.env.APPDATA || null -} - function getConfigPaths(directory: string): string[] { - const crossPlatformDir = path.join(os.homedir(), ".config") + const globalConfigDir = getOpenCodeConfigDir({ binary: "opencode" }) const paths = [ path.join(directory, ".opencode", "opencode.json"), path.join(directory, ".opencode", "opencode.jsonc"), - path.join(crossPlatformDir, "opencode", "opencode.json"), - path.join(crossPlatformDir, "opencode", "opencode.jsonc"), + path.join(globalConfigDir, "opencode.json"), + path.join(globalConfigDir, "opencode.jsonc"), ] - if (process.platform === "win32") { - const appdataDir = getWindowsAppdataDir() - if (appdataDir) { - paths.push(path.join(appdataDir, "opencode", "opencode.json")) - paths.push(path.join(appdataDir, "opencode", "opencode.jsonc")) - } - } - return paths } @@ -108,6 +96,7 @@ export function readOpencodeConfigAgents(directory: string): Record Date: Tue, 14 Apr 2026 20:45:22 -0400 Subject: [PATCH 09/12] fix(agents): replace 'in' with Object.hasOwn() for prototype-safe property checks Addresses cubic-dev-ai review: using 'in' on plain objects can skip valid agent names that match inherited properties (toString, constructor, etc.). Switched both occurrences in opencode-config-agents-reader.ts to Object.hasOwn() for safe own-property checks. --- .../claude-code-agent-loader/opencode-config-agents-reader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts index 2859a361c..7a73d6b26 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -96,7 +96,7 @@ export function readOpencodeConfigAgents(directory: string): Record Date: Tue, 14 Apr 2026 20:55:28 -0400 Subject: [PATCH 10/12] fix(agents): use null-prototype accumulator to prevent __proto__ pollution Addresses cubic-dev-ai P1 review: Object.hasOwn() alone doesn't prevent a crafted '__proto__' agent name from mutating the result object's prototype chain. Using Object.create(null) eliminates inherited properties entirely, making both the hasOwn checks and property assignments safe. --- .../claude-code-agent-loader/opencode-config-agents-reader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts index 7a73d6b26..5b3f41a75 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -79,7 +79,7 @@ function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null { } export function readOpencodeConfigAgents(directory: string): Record { - const result: Record = {} + const result: Record = Object.create(null) for (const configPath of getConfigPaths(directory)) { try { From e5d3fe96c4230875d6a5477f48b22ccafa36f08e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 15 Apr 2026 10:41:32 +0900 Subject: [PATCH 11/12] fix(agents): address all PR #2299 code review findings Blocking fixes: - B1: Return empty restrictions for unknown/custom agents instead of EXPLORATION_AGENT_DENYLIST, allowing custom agents full tool access - B2: Use Object.create(null) consistently across all 5 agent-loading result objects to prevent prototype pollution - B3: Add code comment documenting custom agent bash access trust model - B4: Mock getOpenCodeConfigDir in opencode-config-agents-reader tests to prevent global config dir leakage Non-blocking fixes: - N1: Use resolveAgentDefinitionPaths with project boundary enforcement in opencode-config-agents-reader for path containment - N2: Add session-scoped 30s TTL cache to resolveCallableAgents to avoid redundant SDK IPC calls per tool invocation - N3: Extract shared parseToolsConfig into src/shared/parse-tools-config.ts replacing 4 duplicated local implementations - N4: Add .min(1) to AgentDefinitionPathSchema rejecting empty paths - N5: Add resolve-agent-definition-paths.test.ts covering tilde expansion, relative paths, boundary enforcement, and null containmentDir - N6: Validate agent mode against allowed values instead of bare type assertion in opencode-config-agents-reader --- src/config/schema/agent-definitions.ts | 2 +- .../agent-definitions-loader.ts | 16 +-- .../json-agent-loader.ts | 16 +-- .../claude-code-agent-loader/loader.ts | 8 +- .../opencode-config-agents-reader.test.ts | 19 ++- .../opencode-config-agents-reader.ts | 36 ++---- .../claude-code-plugin-loader/agent-loader.ts | 18 +-- src/shared/index.ts | 1 + src/shared/parse-tools-config.ts | 25 ++++ .../resolve-agent-definition-paths.test.ts | 122 ++++++++++++++++++ .../call-omo-agent/agent-resolver.test.ts | 8 +- src/tools/call-omo-agent/agent-resolver.ts | 21 ++- .../call-omo-agent/tools-edge-cases.test.ts | 2 + src/tools/call-omo-agent/tools.test.ts | 2 + 14 files changed, 214 insertions(+), 82 deletions(-) create mode 100644 src/shared/parse-tools-config.ts create mode 100644 src/shared/resolve-agent-definition-paths.test.ts diff --git a/src/config/schema/agent-definitions.ts b/src/config/schema/agent-definitions.ts index 508bfde0b..328238e2c 100644 --- a/src/config/schema/agent-definitions.ts +++ b/src/config/schema/agent-definitions.ts @@ -1,5 +1,5 @@ import { z } from "zod" -export const AgentDefinitionPathSchema = z.string() +export const AgentDefinitionPathSchema = z.string().min(1) export const AgentDefinitionsConfigSchema = z.array(AgentDefinitionPathSchema).optional() diff --git a/src/features/claude-code-agent-loader/agent-definitions-loader.ts b/src/features/claude-code-agent-loader/agent-definitions-loader.ts index 0736fe347..383f69387 100644 --- a/src/features/claude-code-agent-loader/agent-definitions-loader.ts +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.ts @@ -2,23 +2,11 @@ import { existsSync, readFileSync } from "fs" import { basename, extname } from "path" import { parseFrontmatter } from "../../shared/frontmatter" import { log } from "../../shared/logger" +import { parseToolsConfig } from "../../shared/parse-tools-config" import { parseJsonAgentFile } from "./json-agent-loader" import { mapClaudeModelToOpenCode } from "./claude-model-mapper" import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types" -function parseToolsConfig(toolsStr?: string): Record | undefined { - if (!toolsStr) return undefined - - const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) - if (tools.length === 0) return undefined - - const result: Record = {} - for (const tool of tools) { - result[tool.toLowerCase()] = true - } - return result -} - export function parseMarkdownAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null { try { if (!existsSync(filePath)) { @@ -67,7 +55,7 @@ export function loadAgentDefinitions( paths: string[], scope: AgentScope ): Record { - const result: Record = {} + const result: Record = Object.create(null) for (const filePath of paths) { if (!existsSync(filePath)) { diff --git a/src/features/claude-code-agent-loader/json-agent-loader.ts b/src/features/claude-code-agent-loader/json-agent-loader.ts index 394d2a25c..fa135fa6f 100644 --- a/src/features/claude-code-agent-loader/json-agent-loader.ts +++ b/src/features/claude-code-agent-loader/json-agent-loader.ts @@ -1,23 +1,9 @@ import { existsSync, readFileSync } from "fs" import { parseJsoncSafe } from "../../shared/jsonc-parser" +import { parseToolsConfig } from "../../shared/parse-tools-config" import { mapClaudeModelToOpenCode } from "./claude-model-mapper" import type { AgentScope, AgentJsonDefinition, ClaudeCodeAgentConfig, LoadedAgent } from "./types" -function parseToolsConfig(tools?: string | string[]): Record | undefined { - if (!tools) return undefined - - const toolsArray = Array.isArray(tools) ? tools : tools.split(",").map((t) => t.trim()) - const filtered = toolsArray.filter((t) => typeof t === "string" && t.length > 0) - - if (filtered.length === 0) return undefined - - const result: Record = {} - for (const tool of filtered) { - result[tool.toLowerCase()] = true - } - return result -} - export function parseJsonAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null { try { if (!existsSync(filePath)) { diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index 5a1bbf8fd..380f479eb 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -32,7 +32,7 @@ export function loadUserAgents(): Record { const userAgentsDir = join(getClaudeConfigDir(), "agents") const agents = loadAgentsFromDir(userAgentsDir, "user") - const result: Record = {} + const result: Record = Object.create(null) for (const agent of agents) { result[agent.name] = agent.config } @@ -43,7 +43,7 @@ export function loadProjectAgents(directory?: string): Record = {} + const result: Record = Object.create(null) for (const agent of agents) { result[agent.name] = agent.config } @@ -55,7 +55,7 @@ export function loadOpencodeGlobalAgents(): Record = {} + const result: Record = Object.create(null) for (const agent of agents) { result[agent.name] = agent.config } @@ -66,7 +66,7 @@ export function loadOpencodeProjectAgents(directory?: string): Record = {} + const result: Record = Object.create(null) for (const agent of agents) { result[agent.name] = agent.config } diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts index 52ed83da3..1f7a48deb 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts @@ -1,11 +1,26 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it, beforeEach, afterEach } from "bun:test" +import { mock } from "bun:test" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" -import { readOpencodeConfigAgents } from "./opencode-config-agents-reader" +// Mock getOpenCodeConfigDir to prevent global config leakage +let mockGlobalConfigDir: string +mock.module("../../shared/opencode-config-dir", () => ({ + getOpenCodeConfigDir: () => mockGlobalConfigDir, +})) + +const { readOpencodeConfigAgents } = require("./opencode-config-agents-reader") describe("readOpencodeConfigAgents", () => { + beforeEach(() => { + mockGlobalConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-mock-global-")) + }) + + afterEach(() => { + fs.rmSync(mockGlobalConfigDir, { recursive: true, force: true }) + }) + it("returns empty record when no opencode.json exists", () => { const nonexistentDir = "/nonexistent/directory/path" const result = readOpencodeConfigAgents(nonexistentDir) diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts index 5b3f41a75..480fdbe68 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -3,6 +3,8 @@ import * as path from "node:path" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" import { parseJsoncSafe } from "../../shared/jsonc-parser" +import { parseToolsConfig } from "../../shared/parse-tools-config" +import { resolveAgentDefinitionPaths } from "../../shared/resolve-agent-definition-paths" import { loadAgentDefinitions } from "./agent-definitions-loader" import { mapClaudeModelToOpenCode } from "./claude-model-mapper" import type { ClaudeCodeAgentConfig } from "./types" @@ -25,28 +27,6 @@ function getConfigPaths(directory: string): string[] { return paths } -function parseToolsConfig(toolsValue: unknown): Record | undefined { - if (!toolsValue) return undefined - - let toolsStr: string - if (typeof toolsValue === "string") { - toolsStr = toolsValue - } else if (Array.isArray(toolsValue)) { - toolsStr = toolsValue.filter((t) => typeof t === "string").join(",") - } else { - return undefined - } - - const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean) - if (tools.length === 0) return undefined - - const result: Record = {} - for (const tool of tools) { - result[tool.toLowerCase()] = true - } - return result -} - function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null { if (!agentData || typeof agentData !== "object") { return null @@ -63,9 +43,15 @@ function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null { ? `${mappedModel.providerID}/${mappedModel.modelID}` : undefined + const VALID_MODES = ["subagent", "primary", "all"] as const + const rawMode = typeof agent.mode === "string" ? agent.mode : undefined + const mode = rawMode && (VALID_MODES as readonly string[]).includes(rawMode) + ? (rawMode as "subagent" | "primary" | "all") + : "subagent" + const config: ClaudeCodeAgentConfig = { description, - mode: (agent.mode as "subagent" | "primary" | "all") || "subagent", + mode, prompt: agent.prompt ? String(agent.prompt) : "", ...(modelString ? { model: modelString } : {}), } @@ -106,9 +92,7 @@ export function readOpencodeConfigAgents(directory: string): Record - path.isAbsolute(p) ? p : path.resolve(configDir, p) - ) + const resolvedPaths = resolveAgentDefinitionPaths(definitionPaths, configDir, directory) const definitionAgents = loadAgentDefinitions(resolvedPaths, "opencode-config") diff --git a/src/features/claude-code-plugin-loader/agent-loader.ts b/src/features/claude-code-plugin-loader/agent-loader.ts index 1aff57e0c..128b9f229 100644 --- a/src/features/claude-code-plugin-loader/agent-loader.ts +++ b/src/features/claude-code-plugin-loader/agent-loader.ts @@ -3,27 +3,11 @@ import { basename, join } from "path" import { parseFrontmatter } from "../../shared/frontmatter" import { isMarkdownFile } from "../../shared/file-utils" import { log } from "../../shared/logger" +import { parseToolsConfig } from "../../shared/parse-tools-config" import type { AgentFrontmatter, ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types" import { mapClaudeModelToOpenCode } from "../claude-code-agent-loader/claude-model-mapper" import type { LoadedPlugin } from "./types" -function parseToolsConfig(toolsStr?: string): Record | undefined { - if (!toolsStr) return undefined - - const tools = toolsStr - .split(",") - .map((tool) => tool.trim()) - .filter(Boolean) - - if (tools.length === 0) return undefined - - const result: Record = {} - for (const tool of tools) { - result[tool.toLowerCase()] = true - } - return result -} - export function loadPluginAgents(plugins: LoadedPlugin[]): Record { const agents: Record = {} diff --git a/src/shared/index.ts b/src/shared/index.ts index cf9792361..826434d42 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -76,3 +76,4 @@ export { SessionCategoryRegistry } from "./session-category-registry" export * from "./plugin-identity" export * from "./log-legacy-plugin-startup-warning" export * from "./task-system-enabled" +export * from "./parse-tools-config" diff --git a/src/shared/parse-tools-config.ts b/src/shared/parse-tools-config.ts new file mode 100644 index 000000000..bc5f50169 --- /dev/null +++ b/src/shared/parse-tools-config.ts @@ -0,0 +1,25 @@ +/** + * Parses a tools configuration value into a boolean record. + * Accepts comma-separated strings, string arrays, or unknown values from config files. + * Returns undefined when input is empty or invalid. + */ +export function parseToolsConfig(toolsValue: unknown): Record | undefined { + if (!toolsValue) return undefined + + let items: string[] + if (typeof toolsValue === "string") { + items = toolsValue.split(",").map((t) => t.trim()).filter(Boolean) + } else if (Array.isArray(toolsValue)) { + items = toolsValue.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => (t as string).trim()) + } else { + return undefined + } + + if (items.length === 0) return undefined + + const result: Record = {} + for (const tool of items) { + result[tool.toLowerCase()] = true + } + return result +} diff --git a/src/shared/resolve-agent-definition-paths.test.ts b/src/shared/resolve-agent-definition-paths.test.ts new file mode 100644 index 000000000..fcc14be7a --- /dev/null +++ b/src/shared/resolve-agent-definition-paths.test.ts @@ -0,0 +1,122 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs" +import { join } from "path" +import { homedir } from "os" +import { tmpdir } from "os" + +import { resolveAgentDefinitionPaths } from "./resolve-agent-definition-paths" + +describe("resolveAgentDefinitionPaths", () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "resolve-agent-def-paths-")) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + describe("#given relative paths", () => { + test("#then they are resolved against baseDir", () => { + const result = resolveAgentDefinitionPaths( + ["agents/my-agent.md"], + tempDir, + null, + ) + + expect(result).toEqual([join(tempDir, "agents/my-agent.md")]) + }) + }) + + describe("#given absolute paths", () => { + test("#then they are returned as-is", () => { + const absPath = join(tempDir, "absolute-agent.md") + + const result = resolveAgentDefinitionPaths( + [absPath], + "/some/other/base", + null, + ) + + expect(result).toEqual([absPath]) + }) + }) + + describe("#given tilde-prefixed paths", () => { + test("#then ~ is expanded to homedir", () => { + const result = resolveAgentDefinitionPaths( + ["~/agents/test.md"], + tempDir, + null, + ) + + expect(result).toEqual([join(homedir(), "agents/test.md")]) + }) + }) + + describe("#given containmentDir is set", () => { + test("#then paths outside the boundary are rejected", () => { + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + + const result = resolveAgentDefinitionPaths( + ["/etc/passwd"], + projectDir, + projectDir, + ) + + expect(result).toEqual([]) + }) + + test("#then paths inside the boundary are allowed", () => { + const projectDir = join(tempDir, "project") + const agentsDir = join(projectDir, "agents") + mkdirSync(agentsDir, { recursive: true }) + writeFileSync(join(agentsDir, "a.md"), "test", "utf-8") + + const result = resolveAgentDefinitionPaths( + ["agents/a.md"], + projectDir, + projectDir, + ) + + expect(result).toEqual([join(projectDir, "agents/a.md")]) + }) + }) + + describe("#given containmentDir is null", () => { + test("#then no boundary check is applied", () => { + const result = resolveAgentDefinitionPaths( + ["/some/outside/path/agent.md"], + tempDir, + null, + ) + + expect(result).toEqual(["/some/outside/path/agent.md"]) + }) + }) + + describe("#given an empty paths array", () => { + test("#then an empty array is returned", () => { + const result = resolveAgentDefinitionPaths([], tempDir, null) + + expect(result).toEqual([]) + }) + }) + + describe("#given mixed valid and invalid paths", () => { + test("#then only valid paths within the boundary are returned", () => { + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + + const result = resolveAgentDefinitionPaths( + ["./valid.md", "/outside/boundary.md"], + projectDir, + projectDir, + ) + + expect(result).toEqual([join(projectDir, "valid.md")]) + }) + }) +}) diff --git a/src/tools/call-omo-agent/agent-resolver.test.ts b/src/tools/call-omo-agent/agent-resolver.test.ts index cadd0c99f..f2e13022b 100644 --- a/src/tools/call-omo-agent/agent-resolver.test.ts +++ b/src/tools/call-omo-agent/agent-resolver.test.ts @@ -12,8 +12,8 @@ * R6: No duplicate agent names in output * R7: Malformed agent entries (null, missing name, non-string name, whitespace-only) are skipped gracefully */ -const { describe, test, expect, mock } = require("bun:test") -const { resolveCallableAgents } = require("./agent-resolver") +const { describe, test, expect, mock, beforeEach } = require("bun:test") +const { resolveCallableAgents, clearCallableAgentsCache } = require("./agent-resolver") const { ALLOWED_AGENTS } = require("./constants") function createMockClient(agents: Array>) { @@ -33,6 +33,10 @@ function createFailingClient(error: Error = new Error("API unavailable")) { } describe("resolveCallableAgents", () => { + beforeEach(() => { + clearCallableAgentsCache() + }) + describe("#given the SDK returns agents successfully", () => { describe("#when only built-in agents exist", () => { test("#then every ALLOWED_AGENT appears in the result", async () => { diff --git a/src/tools/call-omo-agent/agent-resolver.ts b/src/tools/call-omo-agent/agent-resolver.ts index 9f87feb79..70bc4c32f 100644 --- a/src/tools/call-omo-agent/agent-resolver.ts +++ b/src/tools/call-omo-agent/agent-resolver.ts @@ -8,20 +8,37 @@ type AgentInfo = { mode?: "subagent" | "primary" | "all"; }; +const callableAgentsCache = new Map(); +const CACHE_TTL_MS = 30_000; + +export function clearCallableAgentsCache(): void { + callableAgentsCache.clear(); +} + /** * Resolves the set of callable agent names at execute-time by merging the * hardcoded `ALLOWED_AGENTS` with any additional agents discovered dynamically * via `client.app.agents()`. Custom agents loaded from registered agent * directories appear here alongside built-ins. * + * Results are cached per session for 30s to avoid redundant SDK IPC calls. + * * Falls back to `ALLOWED_AGENTS` alone if the dynamic lookup fails. * * @param client - The plugin client with access to the agent registry + * @param sessionId - Optional session ID for cache scoping * @returns Array of lowercase callable agent names (excludes primary-mode agents) */ export async function resolveCallableAgents( client: PluginInput["client"], + sessionId?: string, ): Promise { + const cacheKey = sessionId ?? "__default__"; + const cached = callableAgentsCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.agents; + } + try { const agentsResult = await client.app.agents(); const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], { @@ -33,7 +50,9 @@ export async function resolveCallableAgents( .map((a) => a.name.trim().toLowerCase()); const merged = new Set([...ALLOWED_AGENTS, ...dynamicAgents]); - return [...merged]; + const result = [...merged]; + callableAgentsCache.set(cacheKey, { agents: result, timestamp: Date.now() }); + return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); log( diff --git a/src/tools/call-omo-agent/tools-edge-cases.test.ts b/src/tools/call-omo-agent/tools-edge-cases.test.ts index 7e766c28f..9d4c546e9 100644 --- a/src/tools/call-omo-agent/tools-edge-cases.test.ts +++ b/src/tools/call-omo-agent/tools-edge-cases.test.ts @@ -9,6 +9,7 @@ */ const { describe, test, expect, mock, beforeEach } = require("bun:test") const { createCallOmoAgent } = require("./tools") +const { clearCallableAgentsCache } = require("./agent-resolver") type PluginInput = { client: any; directory: string } @@ -50,6 +51,7 @@ const toolCtx = { } beforeEach(() => { + clearCallableAgentsCache() reserveSubagentSpawnMock.mockClear() reserveCommitMock.mockClear() reserveRollbackMock.mockClear() diff --git a/src/tools/call-omo-agent/tools.test.ts b/src/tools/call-omo-agent/tools.test.ts index 455e2f9aa..17491cb82 100644 --- a/src/tools/call-omo-agent/tools.test.ts +++ b/src/tools/call-omo-agent/tools.test.ts @@ -1,5 +1,6 @@ const { beforeEach, describe, test, expect, mock } = require("bun:test") const { createCallOmoAgent } = require("./tools") +const { clearCallableAgentsCache } = require("./agent-resolver") type PluginInput = { client: any; directory: string } type BackgroundManager = { @@ -72,6 +73,7 @@ const toolCtx = { } beforeEach(() => { + clearCallableAgentsCache() assertCanSpawnMock.mockClear() reserveSubagentSpawnMock.mockClear() reserveCommitMock.mockClear() From ab11f2eb4ed3ee2c4bc32c7c80b32cc4a9d32817 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 15 Apr 2026 11:14:52 +0900 Subject: [PATCH 12/12] fix(tests): replace mock.module with spyOn to prevent test pollution The opencode-config-agents-reader.test.ts was using mock.module() which permanently replaced the module in bun's module cache, causing state pollution in downstream tests (plugin-detection, write-omo-config, config-loader). Replaced with spyOn() pattern that properly restores in afterEach. --- .../opencode-config-agents-reader.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts index 1f7a48deb..e90bc2519 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts @@ -1,23 +1,22 @@ -import { describe, expect, it, beforeEach, afterEach } from "bun:test" -import { mock } from "bun:test" +import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" -// Mock getOpenCodeConfigDir to prevent global config leakage -let mockGlobalConfigDir: string -mock.module("../../shared/opencode-config-dir", () => ({ - getOpenCodeConfigDir: () => mockGlobalConfigDir, -})) - -const { readOpencodeConfigAgents } = require("./opencode-config-agents-reader") +import * as configDir from "../../shared/opencode-config-dir" +import { readOpencodeConfigAgents } from "./opencode-config-agents-reader" describe("readOpencodeConfigAgents", () => { + let mockGlobalConfigDir = "" + let configDirSpy: ReturnType + beforeEach(() => { mockGlobalConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-mock-global-")) + configDirSpy = spyOn(configDir, "getOpenCodeConfigDir").mockReturnValue(mockGlobalConfigDir) }) afterEach(() => { + configDirSpy.mockRestore() fs.rmSync(mockGlobalConfigDir, { recursive: true, force: true }) })