From 76c5356a805340bc4e6452b20ed39c50a6af7b7d Mon Sep 17 00:00:00 2001 From: Brandon Webb Date: Sun, 8 Mar 2026 13:50:32 -0400 Subject: [PATCH] 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) }, - }) + }); }