test(agent-config): add regression tests for agent merge priority order
This commit is contained in:
committed by
YeonGyu-Kim
parent
1d8f8a03ca
commit
76c5356a80
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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.`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user