diff --git a/src/tools/delegate-task/subagent-discovery.ts b/src/tools/delegate-task/subagent-discovery.ts index 340ef7c22..fc67432e3 100644 --- a/src/tools/delegate-task/subagent-discovery.ts +++ b/src/tools/delegate-task/subagent-discovery.ts @@ -6,6 +6,7 @@ export type AgentMode = "subagent" | "primary" | "all" | undefined export type AgentInfo = { name: string mode?: "subagent" | "primary" | "all" + hidden?: boolean model?: string | { providerID: string; modelID: string } } @@ -20,16 +21,17 @@ export function mergeWithClaudeCodeAgents( const userAgentsRecord = loadUserAgents() const projectAgentsRecord = loadProjectAgents(directory) - const toAgentInfoList = (record: Record): AgentInfo[] => + const toAgentInfoList = (record: Record): AgentInfo[] => Object.entries(record).map(([name, config]) => ({ name, mode: config.mode as AgentInfo["mode"], + hidden: config.hidden, model: config.model, })) const mergedAgentMap = new Map() const addIfAbsent = (agent: AgentInfo): void => { - const key = agent.name.toLowerCase() + const key = stripAgentListSortPrefix(agent.name).trim().toLowerCase() if (!mergedAgentMap.has(key)) { mergedAgentMap.set(key, agent) } @@ -73,12 +75,12 @@ export function findCallableAgentMatch( agents: AgentInfo[], requestedAgentName: string, ): AgentInfo | undefined { - return agents.find(agent => isTaskCallableAgentMode(agent.mode) && matchesRequestedAgent(agent, requestedAgentName)) + return agents.find(agent => isTaskCallableAgentMode(agent.mode) && agent.hidden !== true && matchesRequestedAgent(agent, requestedAgentName)) } export function listCallableAgentNames(agents: AgentInfo[]): string { return agents - .filter(agent => isTaskCallableAgentMode(agent.mode)) + .filter(agent => isTaskCallableAgentMode(agent.mode) && agent.hidden !== true) .map(agent => stripAgentListSortPrefix(agent.name)) .sort() .join(", ") diff --git a/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts b/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts index 05e988716..39f3e3f16 100644 --- a/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts +++ b/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts @@ -248,6 +248,133 @@ describe("resolveSubagentExecution", () => { expect(result.error).toBe('Unknown agent: "custom-worker". Available agents: oracle') }) + test("rejects delegation to hidden native execution agents (regression #3957)", async () => { + //#given + const args = createBaseArgs({ subagent_type: "build" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "build", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "build". Available agents: oracle') + }) + + test("rejects delegation to hidden plan agent demoted to subagent (regression #3957)", async () => { + //#given + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "plan", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "plan". Available agents: oracle') + }) + + test("hidden agents are excluded from listCallableAgentNames in error messages (regression #3957)", async () => { + //#given + const args = createBaseArgs({ subagent_type: "nonexistent" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "build", mode: "subagent", hidden: true }, + { name: "plan", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + { name: "explore", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.error).toBeDefined() + expect(result.error).toContain('Available agents: explore, oracle') + expect(result.error).not.toContain("build") + expect(result.error).not.toContain("plan") + }) + + test("rejects ZWSP-prefixed project agent that canonicalizes to hidden build (regression #3957 canonical-key bypass)", async () => { + //#given + loadProjectAgentsMock.mockImplementation(() => ({ + "\u200Bbuild": { + description: "Aliases hidden build via zero-width prefix", + mode: "subagent", + prompt: "rogue", + }, + })) + const args = createBaseArgs({ subagent_type: "build" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "build", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "build". Available agents: oracle') + }) + + test("rejects quoted user agent that canonicalizes to hidden plan (regression #3957 canonical-key bypass)", async () => { + //#given + loadUserAgentsMock.mockImplementation(() => ({ + '"plan"': { + description: "Aliases hidden plan via quote wrappers", + mode: "subagent", + prompt: "rogue", + }, + })) + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "plan", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "plan". Available agents: oracle') + }) + + test("rejects sort-prefixed project agent that canonicalizes to hidden build (regression #3957 canonical-key bypass)", async () => { + //#given + loadProjectAgentsMock.mockImplementation(() => ({ + "1|build": { + description: "Aliases hidden build via sort prefix", + mode: "subagent", + prompt: "rogue", + }, + })) + const args = createBaseArgs({ subagent_type: "build" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "build", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "build". Available agents: oracle') + }) + test("normalizes matched agent model string before returning categoryModel", async () => { //#given readProviderModelsCacheMock.mockReturnValue({