Merge pull request #2299 from brandonwebb-vista/feat/dynamic-custom-agent-support

feat(call-omo-agent): support custom agents via dynamic resolution
This commit is contained in:
YeonGyu-Kim
2026-04-15 11:18:04 +09:00
committed by GitHub
28 changed files with 2827 additions and 202 deletions
+5
View File
@@ -0,0 +1,5 @@
import { z } from "zod"
export const AgentDefinitionPathSchema = z.string().min(1)
export const AgentDefinitionsConfigSchema = z.array(AgentDefinitionPathSchema).optional()
@@ -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(),
@@ -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.")
})
})
})
@@ -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<AgentFrontmatter>(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<string, ClaudeCodeAgentConfig> {
const result: Record<string, ClaudeCodeAgentConfig> = 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
}
@@ -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"
@@ -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()
})
})
@@ -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<AgentJsonDefinition>(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
}
}
@@ -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 <directory>/.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 <directory>/.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")
})
})
});
+33 -55
View File
@@ -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<string, boolean> | undefined {
if (!toolsStr) return undefined
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
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<AgentFrontmatter>(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<string, ClaudeCodeAgentConfig> {
const userAgentsDir = join(getClaudeConfigDir(), "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")
const result: Record<string, ClaudeCodeAgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const agent of agents) {
result[agent.name] = agent.config
}
@@ -88,7 +43,30 @@ export function loadProjectAgents(directory?: string): Record<string, ClaudeCode
const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents")
const agents = loadAgentsFromDir(projectAgentsDir, "project")
const result: Record<string, ClaudeCodeAgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}
export function loadOpencodeGlobalAgents(): Record<string, ClaudeCodeAgentConfig> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const opencodeAgentsDir = join(configDir, "agents")
const agents = loadAgentsFromDir(opencodeAgentsDir, "opencode")
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}
export function loadOpencodeProjectAgents(directory?: string): Record<string, ClaudeCodeAgentConfig> {
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "agents")
const agents = loadAgentsFromDir(opencodeProjectDir, "opencode-project")
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
for (const agent of agents) {
result[agent.name] = agent.config
}
@@ -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<typeof spyOn>
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 })
})
})
@@ -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<string, unknown>
agent?: Record<string, unknown>
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<string, unknown>
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<string, ClaudeCodeAgentConfig> {
const result: Record<string, ClaudeCodeAgentConfig> = 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<OpencodeConfigWithAgents>(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 []
}
+10 -1
View File
@@ -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<AgentConfig, "model"> & {
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
@@ -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<string, boolean> | undefined {
if (!toolsStr) return undefined
const tools = toolsStr
.split(",")
.map((tool) => tool.trim())
.filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of tools) {
result[tool.toLowerCase()] = true
}
return result
}
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, ClaudeCodeAgentConfig> {
const agents: Record<string, ClaudeCodeAgentConfig> = {}
+26
View File
@@ -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);
}
@@ -61,6 +61,8 @@ describe("applyAgentConfig builtin override protection", () => {
let discoverGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>
let loadUserAgentsSpy: ReturnType<typeof spyOn>
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
let loadAgentDefinitionsSpy: ReturnType<typeof spyOn>
let readOpencodeConfigAgentsSpy: ReturnType<typeof spyOn>
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
let logSpy: ReturnType<typeof spyOn>
@@ -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<string, unknown>) => 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<string, unknown>).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")
})
})
})
+79 -6
View File
@@ -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<string, Record<string, unknown> | undefined> & {
function getConfiguredDefaultAgent(config: Record<string, unknown>): 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,
};
}
+174
View File
@@ -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<string, unknown> = {
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<string, { description?: string; prompt?: string }>
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<string, unknown> = {
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<string, { description?: string; prompt?: string }>
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<string, unknown> = {
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<string, { description?: string; prompt?: string }>
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<string, unknown> = {
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<string, { description?: string; prompt?: string }>
expect(agentConfig["my-custom-agent"]?.description).toBe("(user) global version")
expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the user agent")
})
})
+4 -4
View File
@@ -47,6 +47,8 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
}
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
// 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<string, bool
}
export function hasAgentToolRestrictions(agentName: string): boolean {
const stripped = stripInvisibleAgentCharacters(agentName)
const restrictions = AGENT_RESTRICTIONS[stripped]
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === stripped.toLowerCase())?.[1]
return restrictions !== undefined && Object.keys(restrictions).length > 0
const restrictions = getAgentToolRestrictions(agentName)
return Object.keys(restrictions).length > 0
}
+2
View File
@@ -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"
+25
View File
@@ -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<string, boolean> | 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<string, boolean> = {}
for (const tool of items) {
result[tool.toLowerCase()] = true
}
return result
}
@@ -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")])
})
})
})
@@ -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]
})
}
@@ -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<Record<string, unknown>>) {
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<Record<string, unknown>>
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 {}
@@ -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<string, { agents: string[]; timestamp: number }>();
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<string[]> {
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];
}
}
+5 -2
View File
@@ -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=<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.`
@@ -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 {}
+223 -101
View File
@@ -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
+35 -16
View File
@@ -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)
},
})
});
}