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,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)
},
})
});
}