test(agent-config): add regression tests for agent merge priority order

This commit is contained in:
Brandon Webb
2026-03-08 13:50:32 -04:00
committed by YeonGyu-Kim
parent 1d8f8a03ca
commit 76c5356a80
9 changed files with 806 additions and 121 deletions
@@ -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")
})
})
});
@@ -5,6 +5,7 @@ import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
@@ -94,3 +95,26 @@ export function loadProjectAgents(directory?: string): Record<string, ClaudeCode
}
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> = {}
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> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}
@@ -1,6 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export type AgentScope = "user" | "project"
export type AgentScope = "user" | "project" | "opencode" | "opencode-project"
export type ClaudeCodeAgentConfig = Omit<AgentConfig, "model"> & {
model?: string | { providerID: string; modelID: string }
+30 -5
View File
@@ -14,7 +14,7 @@ import {
discoverProjectClaudeSkills,
discoverUserClaudeSkills,
} from "../features/opencode-skill-loader";
import { loadProjectAgents, loadUserAgents } from "../features/claude-code-agent-loader";
import { loadProjectAgents, loadUserAgents, loadOpencodeGlobalAgents, loadOpencodeProjectAgents } from "../features/claude-code-agent-loader";
import type { PluginComponents } from "./plugin-components-loader";
import { reorderAgentsByPriority } from "./agent-priority-order";
import { remapAgentKeysToDisplayNames } from "./agent-key-remapper";
@@ -139,6 +139,9 @@ export async function applyAgentConfig(params: {
disableOmoEnv,
);
const opencodeGlobalAgents = loadOpencodeGlobalAgents();
const opencodeProjectAgents = loadOpencodeProjectAgents(params.ctx.directory);
const disabledAgentNames = new Set(
(migratedDisabledAgents ?? []).map(a => a.toLowerCase())
);
@@ -257,6 +260,14 @@ export async function applyAgentConfig(params: {
pluginAgents,
protectedBuiltinAgentNames,
);
const filteredOpencodeGlobalAgents = filterProtectedAgentOverrides(
opencodeGlobalAgents,
protectedBuiltinAgentNames,
);
const filteredOpencodeProjectAgents = filterProtectedAgentOverrides(
opencodeProjectAgents,
protectedBuiltinAgentNames,
);
params.config.agent = {
...agentConfig,
@@ -265,9 +276,12 @@ export async function applyAgentConfig(params: {
([key]) => key !== "sisyphus" && key !== "hephaestus" && key !== "atlas",
),
),
...filterDisabledAgents(filteredUserAgents),
...filterDisabledAgents(filteredProjectAgents),
// Precedence: later entries override earlier (project > global > user > plugin)
...filterDisabledAgents(filteredPluginAgents),
...filterDisabledAgents(filteredUserAgents),
...filterDisabledAgents(filteredOpencodeGlobalAgents),
...filterDisabledAgents(filteredProjectAgents),
...filterDisabledAgents(filteredOpencodeProjectAgents),
...filteredConfigAgents,
build: { ...migratedBuild, mode: "subagent", hidden: true },
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
@@ -288,6 +302,14 @@ export async function applyAgentConfig(params: {
pluginAgents,
protectedBuiltinAgentNames,
);
const filteredOpencodeGlobalAgents = filterProtectedAgentOverrides(
opencodeGlobalAgents,
protectedBuiltinAgentNames,
);
const filteredOpencodeProjectAgents = filterProtectedAgentOverrides(
opencodeProjectAgents,
protectedBuiltinAgentNames,
);
const defaultedConfigAgents = configAgent
? Object.fromEntries(
@@ -302,9 +324,12 @@ export async function applyAgentConfig(params: {
params.config.agent = {
...builtinAgents,
...filterDisabledAgents(filteredUserAgents),
...filterDisabledAgents(filteredProjectAgents),
// Precedence: later entries override earlier (project > global > user > plugin)
...filterDisabledAgents(filteredPluginAgents),
...filterDisabledAgents(filteredUserAgents),
...filterDisabledAgents(filteredOpencodeGlobalAgents),
...filterDisabledAgents(filteredProjectAgents),
...filterDisabledAgents(filteredOpencodeProjectAgents),
...defaultedConfigAgents,
};
}
+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")
})
})
@@ -0,0 +1,45 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { ALLOWED_AGENTS } from "./constants";
import { normalizeSDKResponse } from "../../shared";
import { log } from "../../shared/logger";
type AgentInfo = {
name: string;
mode?: "subagent" | "primary" | "all";
};
/**
* Resolves the set of callable agent names at execute-time by merging the
* hardcoded `ALLOWED_AGENTS` with any additional agents discovered dynamically
* via `client.app.agents()`. Custom agents loaded from registered agent
* directories appear here alongside built-ins.
*
* Falls back to `ALLOWED_AGENTS` alone if the dynamic lookup fails.
*
* @param client - The plugin client with access to the agent registry
* @returns Array of lowercase callable agent names (excludes primary-mode agents)
*/
export async function resolveCallableAgents(
client: PluginInput["client"],
): Promise<string[]> {
try {
const agentsResult = await client.app.agents();
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
preferResponseOnMissingData: true,
});
const dynamicAgents = agents
.filter((a) => a.mode !== "primary")
.map((a) => a.name.toLowerCase());
const merged = new Set([...ALLOWED_AGENTS, ...dynamicAgents]);
return [...merged];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(
"[call_omo_agent] Failed to resolve dynamic agents, falling back to built-in list",
{ error: message },
);
return [...ALLOWED_AGENTS];
}
}
+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.`
+217 -97
View File
@@ -1,119 +1,237 @@
const { beforeEach, describe, test, expect, mock } = require("bun:test")
const { createCallOmoAgent } = require("./tools")
describe("createCallOmoAgent", () => {
const assertCanSpawnMock = mock(() => Promise.resolve(undefined))
const reserveCommitMock = mock(() => 1)
const reserveRollbackMock = mock(() => {})
const reserveSubagentSpawnMock = mock(() => Promise.resolve({
spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 },
descendantCount: 1,
commit: reserveCommitMock,
rollback: reserveRollbackMock,
}))
const mockCtx = {
client: {},
type PluginInput = { client: any; directory: string }
type BackgroundManager = {
assertCanSpawn: Function
reserveSubagentSpawn: Function
launch: Function
getTask: Function
}
function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): PluginInput {
return {
client: {
app: {
agents: mock(() => Promise.resolve(agents)),
},
},
directory: "/test",
}
} as unknown as PluginInput
}
const mockBackgroundManager = {
assertCanSpawn: assertCanSpawnMock,
reserveSubagentSpawn: reserveSubagentSpawnMock,
launch: mock(() => Promise.resolve({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})),
}
function createFailingMockCtx(error: Error = new Error("API unavailable")): PluginInput {
return {
client: {
app: {
agents: mock(() => Promise.reject(error)),
},
},
directory: "/test",
} as unknown as PluginInput
}
beforeEach(() => {
assertCanSpawnMock.mockClear()
reserveSubagentSpawnMock.mockClear()
reserveCommitMock.mockClear()
reserveRollbackMock.mockClear()
const DEFAULT_AGENTS = [
{ name: "explore", mode: "subagent" },
{ name: "librarian", mode: "subagent" },
{ name: "oracle", mode: "subagent" },
{ name: "hephaestus", mode: "subagent" },
{ name: "metis", mode: "subagent" },
{ name: "momus", mode: "subagent" },
{ name: "multimodal-looker", mode: "subagent" },
]
const assertCanSpawnMock = mock(() => Promise.resolve(undefined))
const reserveCommitMock = mock(() => 1)
const reserveRollbackMock = mock(() => {})
const reserveSubagentSpawnMock = mock(() => Promise.resolve({
spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 },
descendantCount: 1,
commit: reserveCommitMock,
rollback: reserveRollbackMock,
}))
const mockBackgroundManager = {
assertCanSpawn: assertCanSpawnMock,
reserveSubagentSpawn: reserveSubagentSpawnMock,
launch: mock(() => Promise.resolve({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})),
getTask: mock(() => ({ status: "pending", sessionID: "ses-123" })),
} as unknown as BackgroundManager
const toolCtx = {
sessionID: "test",
messageID: "msg",
agent: "test",
abort: new AbortController().signal,
}
beforeEach(() => {
assertCanSpawnMock.mockClear()
reserveSubagentSpawnMock.mockClear()
reserveCommitMock.mockClear()
reserveRollbackMock.mockClear()
})
describe("createCallOmoAgent", () => {
describe("disabled_agents validation", () => {
test("should reject agent in disabled_agents list", async () => {
const mockCtx = createMockCtx(DEFAULT_AGENTS)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["explore"])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true },
toolCtx
)
expect(result).toContain("disabled via disabled_agents")
})
test("should reject agent in disabled_agents list with case-insensitive matching", async () => {
const mockCtx = createMockCtx(DEFAULT_AGENTS)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["Explore"])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true },
toolCtx
)
expect(result).toContain("disabled via disabled_agents")
})
test("should allow agent not in disabled_agents list", async () => {
const mockCtx = createMockCtx(DEFAULT_AGENTS)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["librarian"])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true },
toolCtx
)
expect(result).not.toContain("disabled via disabled_agents")
})
test("should allow all agents when disabled_agents is empty", async () => {
const mockCtx = createMockCtx(DEFAULT_AGENTS)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true },
toolCtx
)
expect(result).not.toContain("disabled via disabled_agents")
})
})
test("should reject agent in disabled_agents list", async () => {
//#given
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["explore"])
const executeFunc = toolDef.execute as Function
describe("dynamic custom agent resolution", () => {
test("should accept a custom agent returned by client.app.agents()", async () => {
const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }]
const mockCtx = createMockCtx(agents)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
const result = await executeFunc(
{ description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true },
toolCtx
)
//#then
expect(result).toContain("disabled via disabled_agents")
})
expect(result).not.toContain("Invalid agent type")
expect(result).not.toContain("not found")
})
test("should reject agent in disabled_agents list with case-insensitive matching", async () => {
//#given
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["Explore"])
const executeFunc = toolDef.execute as Function
test("should reject a custom agent NOT returned by client.app.agents()", async () => {
const mockCtx = createMockCtx(DEFAULT_AGENTS)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
const result = await executeFunc(
{ description: "Test", prompt: "Fix bug", subagent_type: "nonexistent-agent", run_in_background: true },
toolCtx
)
//#then
expect(result).toContain("disabled via disabled_agents")
})
expect(result).toContain("Invalid agent type")
})
test("should allow agent not in disabled_agents list", async () => {
//#given
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["librarian"])
const executeFunc = toolDef.execute as Function
test("should perform case-insensitive matching for custom agents", async () => {
const agents = [...DEFAULT_AGENTS, { name: "Bug-Fixer", mode: "subagent" }]
const mockCtx = createMockCtx(agents)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
const result = await executeFunc(
{ description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true },
toolCtx
)
//#then
// Should not contain disabled error - may fail for other reasons but disabled check should pass
expect(result).not.toContain("disabled via disabled_agents")
})
expect(result).not.toContain("Invalid agent type")
})
test("should allow all agents when disabled_agents is empty", async () => {
//#given
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
test("should exclude primary-mode agents from callable list", async () => {
const agents = [
...DEFAULT_AGENTS,
{ name: "sisyphus", mode: "primary" },
]
const mockCtx = createMockCtx(agents)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
const result = await executeFunc(
{ description: "Test", prompt: "Orchestrate", subagent_type: "sisyphus", run_in_background: true },
toolCtx
)
//#then
expect(result).not.toContain("disabled via disabled_agents")
expect(result).toContain("Invalid agent type")
})
test("should fall back to ALLOWED_AGENTS when client.app.agents() fails", async () => {
const mockCtx = createFailingMockCtx()
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Explore codebase", subagent_type: "explore", run_in_background: true },
toolCtx
)
expect(result).not.toContain("Invalid agent type")
})
test("should reject unknown agent even when client.app.agents() fails (fallback mode)", async () => {
const mockCtx = createFailingMockCtx()
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Fix bug", subagent_type: "custom-agent", run_in_background: true },
toolCtx
)
expect(result).toContain("Invalid agent type")
})
test("should still apply disabled_agents check to dynamically resolved custom agents", async () => {
const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }]
const mockCtx = createMockCtx(agents)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["bug-fixer"])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true },
toolCtx
)
expect(result).toContain("disabled via disabled_agents")
})
})
test("uses agent override fallback_models when launching background subagent", async () => {
@@ -129,6 +247,7 @@ describe("createCallOmoAgent", () => {
launch,
getTask: mock(() => undefined),
}
const mockCtx = createMockCtx(DEFAULT_AGENTS)
const toolDef = createCallOmoAgent(
mockCtx,
managerWithLaunch,
@@ -371,6 +490,7 @@ describe("createCallOmoAgent", () => {
test("should return a tool error when sync spawn depth validation fails", async () => {
//#given
const mockCtx = createMockCtx(DEFAULT_AGENTS)
reserveSubagentSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
+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)
},
})
});
}