diff --git a/src/config/schema/agent-definitions.ts b/src/config/schema/agent-definitions.ts new file mode 100644 index 000000000..328238e2c --- /dev/null +++ b/src/config/schema/agent-definitions.ts @@ -0,0 +1,5 @@ +import { z } from "zod" + +export const AgentDefinitionPathSchema = z.string().min(1) + +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/agent-definitions-loader.test.ts b/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts new file mode 100644 index 000000000..76960b93a --- /dev/null +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.test.ts @@ -0,0 +1,293 @@ +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("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 = `--- +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..383f69387 --- /dev/null +++ b/src/features/claude-code-agent-loader/agent-definitions-loader.ts @@ -0,0 +1,87 @@ +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" + +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 fileName = basename(filePath) + const agentName = fileName.replace(/\.md$/i, "") + 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 = Object.create(null) + + 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/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/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..fa135fa6f --- /dev/null +++ b/src/features/claude-code-agent-loader/json-agent-loader.ts @@ -0,0 +1,53 @@ +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" + +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/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..380f479eb 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -1,23 +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" - -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 type { AgentScope, ClaudeCodeAgentConfig, LoadedAgent } from "./types" +import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" +import { parseMarkdownAgentFile } from "./agent-definitions-loader" function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] { if (!existsSync(agentsDir)) { @@ -31,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) } } @@ -77,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 } @@ -88,7 +43,30 @@ export function loadProjectAgents(directory?: string): Record = {} + const result: Record = Object.create(null) + for (const agent of agents) { + result[agent.name] = agent.config + } + return result +} + +export function loadOpencodeGlobalAgents(): Record { + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) + const opencodeAgentsDir = join(configDir, "agents") + const agents = loadAgentsFromDir(opencodeAgentsDir, "opencode") + + const result: Record = Object.create(null) + 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 = 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 new file mode 100644 index 000000000..e90bc2519 --- /dev/null +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts @@ -0,0 +1,342 @@ +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" + +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 }) + }) + + 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(opencodeDir, "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("From 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(opencodeDir, "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("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") + 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(opencodeDir, "agents1.json") + fs.writeFileSync( + agentDef1, + JSON.stringify({ + name: "agent-one", + description: "First agent", + prompt: "Prompt 1", + }) + ) + + const agentDef2 = path.join(opencodeDir, "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..480fdbe68 --- /dev/null +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -0,0 +1,125 @@ +import * as fs from "node:fs" +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" + +interface OpencodeConfigWithAgents { + agents?: Record + agent?: Record + agent_definitions?: string | string[] +} + +function getConfigPaths(directory: string): string[] { + const globalConfigDir = getOpenCodeConfigDir({ binary: "opencode" }) + const paths = [ + path.join(directory, ".opencode", "opencode.json"), + path.join(directory, ".opencode", "opencode.jsonc"), + path.join(globalConfigDir, "opencode.json"), + path.join(globalConfigDir, "opencode.jsonc"), + ] + + return paths +} + +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 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, + 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 = Object.create(null) + + 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) + + const agentsToLoad = parseResult.data.agents || parseResult.data.agent + + if (agentsToLoad && typeof agentsToLoad === "object") { + for (const [agentName, agentData] of Object.entries(agentsToLoad)) { + if (Object.hasOwn(result, agentName)) continue + const converted = convertInlineAgent(agentData) + if (converted) { + result[agentName] = converted + } + } + } + + if (parseResult.data.agent_definitions) { + const definitionPaths = extractDefinitionPaths(parseResult.data.agent_definitions) + const resolvedPaths = resolveAgentDefinitionPaths(definitionPaths, configDir, directory) + + const definitionAgents = loadAgentDefinitions(resolvedPaths, "opencode-config") + + for (const [name, config] of Object.entries(definitionAgents)) { + if (!Object.hasOwn(result, name)) { + 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 [] +} diff --git a/src/features/claude-code-agent-loader/types.ts b/src/features/claude-code-agent-loader/types.ts index 7db570637..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" +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/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/plugin-config.ts b/src/plugin-config.ts index 0b79e96d3..a5853af72 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, + projectBasePath, + directory + ) + } + if (projectConfig) { config = mergeConfigs(config, projectConfig); } diff --git a/src/plugin-handlers/agent-config-handler.test.ts b/src/plugin-handlers/agent-config-handler.test.ts index 8e06d7fca..8e2571e34 100644 --- a/src/plugin-handlers/agent-config-handler.test.ts +++ b/src/plugin-handlers/agent-config-handler.test.ts @@ -61,6 +61,8 @@ describe("applyAgentConfig builtin override protection", () => { 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 b8c7a9ee6..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 } 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; } @@ -96,8 +102,15 @@ 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 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]; @@ -113,7 +126,11 @@ 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), + ...Object.entries(agentDefinitionAgents), + ...Object.entries(opencodeConfigAgents), ] .filter(([, config]) => config != null) .map(([name, config]) => ({ @@ -123,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, @@ -257,6 +288,22 @@ export async function applyAgentConfig(params: { pluginAgents, protectedBuiltinAgentNames, ); + const filteredOpencodeGlobalAgents = filterProtectedAgentOverrides( + opencodeGlobalAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeProjectAgents = filterProtectedAgentOverrides( + opencodeProjectAgents, + protectedBuiltinAgentNames, + ); + const filteredAgentDefinitionAgents = filterProtectedAgentOverrides( + agentDefinitionAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeConfigAgents = filterProtectedAgentOverrides( + opencodeConfigAgents, + protectedBuiltinAgentNames, + ); params.config.agent = { ...agentConfig, @@ -265,9 +312,14 @@ 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), + ...filterDisabledAgents(filteredAgentDefinitionAgents), + ...filterDisabledAgents(filteredOpencodeConfigAgents), ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), @@ -288,6 +340,22 @@ export async function applyAgentConfig(params: { pluginAgents, protectedBuiltinAgentNames, ); + const filteredOpencodeGlobalAgents = filterProtectedAgentOverrides( + opencodeGlobalAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeProjectAgents = filterProtectedAgentOverrides( + opencodeProjectAgents, + protectedBuiltinAgentNames, + ); + const filteredAgentDefinitionAgents = filterProtectedAgentOverrides( + agentDefinitionAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeConfigAgents = filterProtectedAgentOverrides( + opencodeConfigAgents, + protectedBuiltinAgentNames, + ); const defaultedConfigAgents = configAgent ? Object.fromEntries( @@ -302,9 +370,14 @@ 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), + ...filterDisabledAgents(filteredAgentDefinitionAgents), + ...filterDisabledAgents(filteredOpencodeConfigAgents), ...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/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 } diff --git a/src/shared/index.ts b/src/shared/index.ts index 485926bfd..826434d42 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, @@ -75,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/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] + }) +} 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..f2e13022b --- /dev/null +++ b/src/tools/call-omo-agent/agent-resolver.test.ts @@ -0,0 +1,240 @@ +/** + * 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, beforeEach } = require("bun:test") +const { resolveCallableAgents, clearCallableAgentsCache } = 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", () => { + 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 () => { + 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 new file mode 100644 index 000000000..70bc4c32f --- /dev/null +++ b/src/tools/call-omo-agent/agent-resolver.ts @@ -0,0 +1,64 @@ +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"; +}; + +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[], { + preferResponseOnMissingData: true, + }); + + const dynamicAgents = agents + .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]); + 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( + "[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-edge-cases.test.ts b/src/tools/call-omo-agent/tools-edge-cases.test.ts new file mode 100644 index 000000000..9d4c546e9 --- /dev/null +++ b/src/tools/call-omo-agent/tools-edge-cases.test.ts @@ -0,0 +1,224 @@ +/** + * 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") +const { clearCallableAgentsCache } = require("./agent-resolver") + +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(() => { + clearCallableAgentsCache() + 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 5d499b7bb..17491cb82 100644 --- a/src/tools/call-omo-agent/tools.test.ts +++ b/src/tools/call-omo-agent/tools.test.ts @@ -1,119 +1,239 @@ 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 = { + assertCanSpawn: Function + reserveSubagentSpawn: Function + launch: Function + getTask: Function +} + +function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): PluginInput { + return { + client: { + app: { + agents: mock(() => Promise.resolve({ data: agents })), + }, + }, + directory: "/test", + } as unknown as PluginInput +} + +function createFailingMockCtx(error: Error = new Error("API unavailable")): PluginInput { + return { + client: { + app: { + agents: mock(() => Promise.reject(error)), + }, + }, + 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 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(() => { + clearCallableAgentsCache() + assertCanSpawnMock.mockClear() + reserveSubagentSpawnMock.mockClear() + reserveCommitMock.mockClear() + reserveRollbackMock.mockClear() +}) 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: {}, - directory: "/test", - } + 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 mockBackgroundManager = { - assertCanSpawn: assertCanSpawnMock, - reserveSubagentSpawn: reserveSubagentSpawnMock, - launch: mock(() => Promise.resolve({ - id: "test-task-id", - sessionID: null, - description: "Test task", - agent: "test-agent", - status: "pending", - })), - } + const result = await executeFunc( + { description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true }, + toolCtx + ) - beforeEach(() => { - assertCanSpawnMock.mockClear() - reserveSubagentSpawnMock.mockClear() - reserveCommitMock.mockClear() - reserveRollbackMock.mockClear() + 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 +249,7 @@ describe("createCallOmoAgent", () => { launch, getTask: mock(() => undefined), } + const mockCtx = createMockCtx(DEFAULT_AGENTS) const toolDef = createCallOmoAgent( mockCtx, managerWithLaunch, @@ -179,7 +300,7 @@ describe("createCallOmoAgent", () => { getTask: mock(() => undefined), } const toolDef = createCallOmoAgent( - mockCtx, + createMockCtx(DEFAULT_AGENTS), managerWithLaunch, [], { @@ -228,7 +349,7 @@ describe("createCallOmoAgent", () => { getTask: mock(() => undefined), } const toolDef = createCallOmoAgent( - mockCtx, + createMockCtx(DEFAULT_AGENTS), managerWithLaunch, [], { @@ -279,7 +400,7 @@ describe("createCallOmoAgent", () => { getTask: mock(() => undefined), } const toolDef = createCallOmoAgent( - mockCtx, + createMockCtx(DEFAULT_AGENTS), managerWithLaunch, [], { @@ -329,7 +450,7 @@ describe("createCallOmoAgent", () => { getTask: mock(() => undefined), } const toolDef = createCallOmoAgent( - mockCtx, + createMockCtx(DEFAULT_AGENTS), managerWithLaunch, [], { @@ -371,6 +492,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) }, - }) + }); }