Merge pull request #4117 from ririnto/fix/plan-subagent-hidden-registry
fix(delegate-task): restore hidden plan subagent delegation
This commit is contained in:
@@ -196,7 +196,7 @@ export function buildNonClaudePlannerSection(model: string): string {
|
|||||||
Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan.
|
Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan.
|
||||||
|
|
||||||
- Single-file fix or trivial change → proceed directly
|
- Single-file fix or trivial change → proceed directly
|
||||||
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="prometheus", ...)\` FIRST
|
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST
|
||||||
- Use \`task_id\` to resume the same Plan Agent - ask follow-up questions aggressively
|
- Use \`task_id\` to resume the same Plan Agent - ask follow-up questions aggressively
|
||||||
- If ANY part of the task is ambiguous, ask Plan Agent before guessing
|
- If ANY part of the task is ambiguous, ask Plan Agent before guessing
|
||||||
|
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ Every implementation task follows this cycle. No exceptions.
|
|||||||
Follow \`<explore>\` protocol for tool usage and agent prompts.
|
Follow \`<explore>\` protocol for tool usage and agent prompts.
|
||||||
|
|
||||||
2. PLAN - List files to modify, specific changes, dependencies, complexity estimate.
|
2. PLAN - List files to modify, specific changes, dependencies, complexity estimate.
|
||||||
Multi-step (2+) → consult Plan Agent via \`task(subagent_type="prometheus", ...)\`.
|
Multi-step (2+) → consult Plan Agent via \`task(subagent_type="plan", ...)\`.
|
||||||
Single-step → mental plan is sufficient.
|
Single-step → mental plan is sufficient.
|
||||||
|
|
||||||
<dependency_checks>
|
<dependency_checks>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "./validator"
|
} from "./validator"
|
||||||
|
|
||||||
const PROMETHEUS_REJECTION_MESSAGE =
|
const PROMETHEUS_REJECTION_MESSAGE =
|
||||||
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead."
|
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead."
|
||||||
|
|
||||||
function createCategoryMember(name: string): Member {
|
function createCategoryMember(name: string): Member {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ describe("team-mode types", () => {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.",
|
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead.",
|
||||||
],
|
],
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ describe("team-mode types", () => {
|
|||||||
"Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.",
|
"Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.",
|
||||||
)
|
)
|
||||||
expect(AGENT_ELIGIBILITY_REGISTRY.prometheus.rejectionMessage).toBe(
|
expect(AGENT_ELIGIBILITY_REGISTRY.prometheus.rejectionMessage).toBe(
|
||||||
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.",
|
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead.",
|
||||||
)
|
)
|
||||||
expect(CategoryMemberSchema).toBeDefined()
|
expect(CategoryMemberSchema).toBeDefined()
|
||||||
expect(SubagentMemberSchema).toBeDefined()
|
expect(SubagentMemberSchema).toBeDefined()
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const AGENT_ELIGIBILITY_REGISTRY: Readonly<Record<string, {
|
|||||||
prometheus: {
|
prometheus: {
|
||||||
verdict: "hard-reject",
|
verdict: "hard-reject",
|
||||||
rejectionMessage:
|
rejectionMessage:
|
||||||
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.",
|
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead.",
|
||||||
},
|
},
|
||||||
"sisyphus-junior": { verdict: "eligible" },
|
"sisyphus-junior": { verdict: "eligible" },
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { loadProjectAgents, loadUserAgents } from "../../features/claude-code-agent-loader"
|
||||||
import {
|
import {
|
||||||
getAgentConfigKey,
|
getAgentConfigKey,
|
||||||
getAgentDisplayName,
|
getAgentDisplayName,
|
||||||
stripAgentListSortPrefix,
|
stripAgentListSortPrefix,
|
||||||
stripInvisibleAgentCharacters,
|
|
||||||
} from "../../shared/agent-display-names"
|
} from "../../shared/agent-display-names"
|
||||||
import { loadUserAgents, loadProjectAgents } from "../../features/claude-code-agent-loader"
|
|
||||||
|
|
||||||
export type AgentMode = "subagent" | "primary" | "all" | undefined
|
export type AgentMode = "subagent" | "primary" | "all" | undefined
|
||||||
|
|
||||||
@@ -16,7 +15,7 @@ export type AgentInfo = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeSubagentType(subagentType: string): string {
|
export function sanitizeSubagentType(subagentType: string): string {
|
||||||
return subagentType.trim().replace(/^[\\\/"']+|[\\\/"']+$/g, "").trim()
|
return subagentType.trim().replace(/^[\\/"']+|[\\/"']+$/g, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeWithClaudeCodeAgents(
|
export function mergeWithClaudeCodeAgents(
|
||||||
@@ -69,10 +68,10 @@ export function isTaskCallableAgentMode(mode: AgentMode): boolean {
|
|||||||
return mode === "all" || mode === "subagent"
|
return mode === "all" || mode === "subagent"
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDemotedPlanAgent(agent: AgentInfo): boolean {
|
export function isDemotedPlanAgent(agent: AgentInfo): boolean {
|
||||||
return agent.hidden === true
|
return agent.hidden === true
|
||||||
&& agent.mode === "subagent"
|
&& agent.mode === "subagent"
|
||||||
&& stripInvisibleAgentCharacters(agent.name).trim().toLowerCase() === "plan"
|
&& stripAgentListSortPrefix(agent.name).trim().toLowerCase() === "plan"
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVisibleToTask(agent: AgentInfo): boolean {
|
function isVisibleToTask(agent: AgentInfo): boolean {
|
||||||
|
|||||||
@@ -1,30 +1,65 @@
|
|||||||
import type { DelegateTaskArgs } from "./types"
|
import type { DelegateTaskArgs } from "./types"
|
||||||
import type { ExecutorContext } from "./executor-types"
|
import type { ExecutorContext } from "./executor-types"
|
||||||
import type { DelegatedModelConfig } from "./types"
|
import type { DelegatedModelConfig } from "./types"
|
||||||
import { isPlanFamily } from "./constants"
|
import { isPlanAgent, isPlanFamily } from "./constants"
|
||||||
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
|
||||||
import { applyCategoryParams } from "./delegated-model-config"
|
import { applyCategoryParams } from "./delegated-model-config"
|
||||||
|
import { getAvailableModelsForDelegateTask } from "./available-models"
|
||||||
import { resolveEffectiveFallbackEntry } from "./fallback-entry-resolution"
|
import { resolveEffectiveFallbackEntry } from "./fallback-entry-resolution"
|
||||||
import { applyFallbackEntrySettings } from "./fallback-entry-settings"
|
import { applyFallbackEntrySettings } from "./fallback-entry-settings"
|
||||||
|
import type { AgentInfo } from "./subagent-discovery"
|
||||||
import {
|
import {
|
||||||
type AgentInfo,
|
|
||||||
sanitizeSubagentType,
|
|
||||||
mergeWithClaudeCodeAgents,
|
|
||||||
findPrimaryAgentMatch,
|
findPrimaryAgentMatch,
|
||||||
findCallableAgentMatch,
|
findCallableAgentMatch,
|
||||||
|
sanitizeSubagentType,
|
||||||
listCallableAgentNames,
|
listCallableAgentNames,
|
||||||
|
mergeWithClaudeCodeAgents,
|
||||||
|
isDemotedPlanAgent,
|
||||||
} from "./subagent-discovery"
|
} from "./subagent-discovery"
|
||||||
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
|
|
||||||
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
|
||||||
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
|
||||||
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
|
|
||||||
import { getAgentConfigKey, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import { getAvailableModelsForDelegateTask } from "./available-models"
|
|
||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
|
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||||
import { resolveModelForDelegateTask } from "./model-selection"
|
import { resolveModelForDelegateTask } from "./model-selection"
|
||||||
import { fuzzyMatchModel } from "../../shared/model-availability"
|
import { fuzzyMatchModel } from "../../shared/model-availability"
|
||||||
|
import { getAgentConfigKey, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
|
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
|
||||||
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
|
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
|
||||||
|
import { flattenToFallbackModelStrings, normalizeFallbackModels } from "../../shared/model-resolver"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
const DEFAULT_PLAN_FALLBACK_AGENT = "plan"
|
||||||
|
const RESERVED_HIDDEN_NATIVE_AGENTS = new Set(["build"])
|
||||||
|
|
||||||
|
function isReservedHiddenNativeAgent(agentName: string): boolean {
|
||||||
|
return RESERVED_HIDDEN_NATIVE_AGENTS.has(getAgentConfigKey(agentName))
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseHiddenPlanAgent(
|
||||||
|
requestedAgent: string,
|
||||||
|
serverPrimaryAgent: AgentInfo | undefined,
|
||||||
|
serverMatchedAgent: AgentInfo | undefined,
|
||||||
|
sisyphusAgentConfig: ExecutorContext["sisyphusAgentConfig"],
|
||||||
|
hasDemotedPlan: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (serverPrimaryAgent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDemotedPlan) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverMatchedAgent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlanAgent(requestedAgent)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return sisyphusAgentConfig?.planner_enabled !== false
|
||||||
|
&& sisyphusAgentConfig?.replace_plan !== false
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolveSubagentExecutionOptions {
|
export interface ResolveSubagentExecutionOptions {
|
||||||
allowSisyphusJuniorDirect?: boolean
|
allowSisyphusJuniorDirect?: boolean
|
||||||
@@ -74,18 +109,36 @@ Create the work plan directly - that's your job as the planning agent.`,
|
|||||||
|
|
||||||
let agentToUse = agentName
|
let agentToUse = agentName
|
||||||
let categoryModel: DelegatedModelConfig | undefined
|
let categoryModel: DelegatedModelConfig | undefined
|
||||||
let fallbackChain: FallbackEntry[] | undefined = undefined
|
let fallbackChain: FallbackEntry[] | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agentsResult = await client.app.agents()
|
const agentsResult = await client.app.agents()
|
||||||
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
|
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
|
||||||
preferResponseOnMissingData: true,
|
preferResponseOnMissingData: true,
|
||||||
})
|
})
|
||||||
|
const hasDemotedPlan = agents.some(isDemotedPlanAgent)
|
||||||
|
const serverPrimaryAgent = findPrimaryAgentMatch(agents, agentToUse)
|
||||||
|
const serverMatchedAgent = findCallableAgentMatch(agents, agentToUse)
|
||||||
|
|
||||||
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
|
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
|
||||||
const matchedPrimaryAgent = findPrimaryAgentMatch(mergedAgents, agentToUse)
|
const matchedPrimaryAgent = findPrimaryAgentMatch(mergedAgents, agentToUse)
|
||||||
|
const useHiddenPlanFallback = shouldUseHiddenPlanAgent(
|
||||||
|
agentToUse,
|
||||||
|
serverPrimaryAgent,
|
||||||
|
serverMatchedAgent,
|
||||||
|
executorCtx.sisyphusAgentConfig,
|
||||||
|
hasDemotedPlan,
|
||||||
|
)
|
||||||
|
|
||||||
if (matchedPrimaryAgent && !options.allowPrimaryAgentDelegation) {
|
if (isReservedHiddenNativeAgent(agentToUse) && !serverPrimaryAgent && !serverMatchedAgent) {
|
||||||
|
return {
|
||||||
|
agentToUse: "",
|
||||||
|
categoryModel: undefined,
|
||||||
|
error: `Unknown agent: "${agentToUse}". Available agents: ${listCallableAgentNames(agents)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedPrimaryAgent && !options.allowPrimaryAgentDelegation && !useHiddenPlanFallback) {
|
||||||
return {
|
return {
|
||||||
agentToUse: "",
|
agentToUse: "",
|
||||||
categoryModel: undefined,
|
categoryModel: undefined,
|
||||||
@@ -94,10 +147,17 @@ Create the work plan directly - that's your job as the planning agent.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const usePrimary = options.allowPrimaryAgentDelegation && matchedPrimaryAgent !== undefined
|
const usePrimary = options.allowPrimaryAgentDelegation && matchedPrimaryAgent !== undefined
|
||||||
const matchedAgent = usePrimary
|
let matchedAgent = usePrimary
|
||||||
? matchedPrimaryAgent
|
? matchedPrimaryAgent
|
||||||
: findCallableAgentMatch(mergedAgents, agentToUse)
|
: findCallableAgentMatch(mergedAgents, agentToUse)
|
||||||
|
|
||||||
|
if (useHiddenPlanFallback) {
|
||||||
|
matchedAgent = {
|
||||||
|
name: DEFAULT_PLAN_FALLBACK_AGENT,
|
||||||
|
mode: "subagent",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!matchedAgent) {
|
if (!matchedAgent) {
|
||||||
return {
|
return {
|
||||||
agentToUse: "",
|
agentToUse: "",
|
||||||
@@ -153,7 +213,8 @@ Create the work plan directly - that's your job as the planning agent.`,
|
|||||||
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
|
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
|
||||||
}
|
}
|
||||||
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
|
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
|
||||||
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
|
const explicitModel = agentOverride?.model ?? agentCategoryModel
|
||||||
|
const normalized = explicitModel ? normalizeModelFormat(explicitModel) : undefined
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
const variantToUse = agentOverride?.variant ?? agentCategoryConfig?.variant
|
const variantToUse = agentOverride?.variant ?? agentCategoryConfig?.variant
|
||||||
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
|
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import type { DelegateTaskArgs } from "../types"
|
import type { DelegateTaskArgs } from "../types"
|
||||||
import type { ExecutorContext } from "../executor-types"
|
import type { ExecutorContext } from "../executor-types"
|
||||||
|
|
||||||
@@ -282,6 +282,270 @@ describe("resolveSubagentExecution", () => {
|
|||||||
expect(result.categoryModel).toBeUndefined()
|
expect(result.categoryModel).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves hidden sort-prefixed plan agent model instead of using fallback", async () => {
|
||||||
|
//#given
|
||||||
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
|
models: { anthropic: ["claude-opus-4-7"] },
|
||||||
|
connected: ["anthropic"],
|
||||||
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
const args = createBaseArgs({ subagent_type: "plan" })
|
||||||
|
const executorCtx = createExecutorContext(async () => ([
|
||||||
|
{ name: "1|plan", mode: "subagent", hidden: true, model: "anthropic/claude-opus-4-7" },
|
||||||
|
{ name: "oracle", mode: "subagent" },
|
||||||
|
]))
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
expect(result.agentToUse).toBe("plan")
|
||||||
|
expect(result.categoryModel).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows OpenCode-hidden-list plan fallback when planner_enabled and replace_plan are true", async () => {
|
||||||
|
//#given
|
||||||
|
const args = createBaseArgs({ subagent_type: "plan" })
|
||||||
|
const executorCtx = createExecutorContext(async () => ([
|
||||||
|
{ name: "oracle", mode: "subagent" },
|
||||||
|
]), {
|
||||||
|
sisyphusAgentConfig: {
|
||||||
|
planner_enabled: true,
|
||||||
|
replace_plan: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
expect(result.agentToUse).toBe("plan")
|
||||||
|
expect(result.categoryModel).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ loader: "user", aliasName: "plan" },
|
||||||
|
{ loader: "user", aliasName: '"plan"' },
|
||||||
|
{ loader: "project", aliasName: "plan" },
|
||||||
|
{ loader: "project", aliasName: '"plan"' },
|
||||||
|
])(
|
||||||
|
"uses built-in hidden plan fallback when a $loader $aliasName alias exists",
|
||||||
|
async ({ loader, aliasName }) => {
|
||||||
|
//#given
|
||||||
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
|
models: { openai: ["gpt-5.3-codex"] },
|
||||||
|
connected: ["openai"],
|
||||||
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
loadUserAgentsMock.mockImplementation(() => {
|
||||||
|
if (loader === "user") {
|
||||||
|
return {
|
||||||
|
[aliasName]: {
|
||||||
|
description: "Colliding plan alias from user agents",
|
||||||
|
mode: "subagent",
|
||||||
|
model: "openai/gpt-5.3-codex",
|
||||||
|
},
|
||||||
|
} satisfies ClaudeCodeAgentRecord
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
loadProjectAgentsMock.mockImplementation(() => {
|
||||||
|
if (loader === "project") {
|
||||||
|
return {
|
||||||
|
[aliasName]: {
|
||||||
|
description: "Colliding plan alias from project agents",
|
||||||
|
mode: "subagent",
|
||||||
|
model: "openai/gpt-5.3-codex",
|
||||||
|
},
|
||||||
|
} satisfies ClaudeCodeAgentRecord
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const args = createBaseArgs({ subagent_type: "plan" })
|
||||||
|
const executorCtx = createExecutorContext(async () => ([
|
||||||
|
{ name: "oracle", mode: "subagent" },
|
||||||
|
]), {
|
||||||
|
sisyphusAgentConfig: {
|
||||||
|
planner_enabled: true,
|
||||||
|
replace_plan: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
expect(result.agentToUse).toBe("plan")
|
||||||
|
expect(result.categoryModel).toBeUndefined()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ loader: "user", aliasName: "plan" },
|
||||||
|
{ loader: "user", aliasName: '"plan"' },
|
||||||
|
{ loader: "project", aliasName: "plan" },
|
||||||
|
{ loader: "project", aliasName: '"plan"' },
|
||||||
|
])(
|
||||||
|
"uses built-in hidden plan fallback when a $loader primary $aliasName alias exists",
|
||||||
|
async ({ loader, aliasName }) => {
|
||||||
|
//#given
|
||||||
|
loadUserAgentsMock.mockImplementation(() => {
|
||||||
|
if (loader === "user") {
|
||||||
|
return {
|
||||||
|
[aliasName]: {
|
||||||
|
description: "Colliding primary plan alias from user agents",
|
||||||
|
mode: "primary",
|
||||||
|
model: "openai/gpt-5.3-codex",
|
||||||
|
},
|
||||||
|
} satisfies ClaudeCodeAgentRecord
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
loadProjectAgentsMock.mockImplementation(() => {
|
||||||
|
if (loader === "project") {
|
||||||
|
return {
|
||||||
|
[aliasName]: {
|
||||||
|
description: "Colliding primary plan alias from project agents",
|
||||||
|
mode: "primary",
|
||||||
|
model: "openai/gpt-5.3-codex",
|
||||||
|
},
|
||||||
|
} satisfies ClaudeCodeAgentRecord
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const args = createBaseArgs({ subagent_type: "plan" })
|
||||||
|
const executorCtx = createExecutorContext(async () => ([
|
||||||
|
{ name: "oracle", mode: "subagent" },
|
||||||
|
]), {
|
||||||
|
sisyphusAgentConfig: {
|
||||||
|
planner_enabled: true,
|
||||||
|
replace_plan: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
expect(result.agentToUse).toBe("plan")
|
||||||
|
expect(result.categoryModel).toBeUndefined()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ loader: "user", aliasName: "build" },
|
||||||
|
{ loader: "user", aliasName: '"build"' },
|
||||||
|
{ loader: "user", aliasName: "1|build" },
|
||||||
|
{ loader: "user", aliasName: "\u200Bbuild" },
|
||||||
|
{ loader: "project", aliasName: "build" },
|
||||||
|
{ loader: "project", aliasName: '"build"' },
|
||||||
|
{ loader: "project", aliasName: "1|build" },
|
||||||
|
{ loader: "project", aliasName: "\u200Bbuild" },
|
||||||
|
])(
|
||||||
|
"rejects omitted hidden build when a $loader $aliasName alias exists",
|
||||||
|
async ({ loader, aliasName }) => {
|
||||||
|
//#given
|
||||||
|
loadUserAgentsMock.mockImplementation(() => {
|
||||||
|
if (loader === "user") {
|
||||||
|
return {
|
||||||
|
[aliasName]: {
|
||||||
|
description: "Colliding hidden build alias from user agents",
|
||||||
|
mode: "subagent",
|
||||||
|
model: "openai/gpt-5.3-codex",
|
||||||
|
},
|
||||||
|
} satisfies ClaudeCodeAgentRecord
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
loadProjectAgentsMock.mockImplementation(() => {
|
||||||
|
if (loader === "project") {
|
||||||
|
return {
|
||||||
|
[aliasName]: {
|
||||||
|
description: "Colliding hidden build alias from project agents",
|
||||||
|
mode: "subagent",
|
||||||
|
model: "openai/gpt-5.3-codex",
|
||||||
|
},
|
||||||
|
} satisfies ClaudeCodeAgentRecord
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const args = createBaseArgs({ subagent_type: "build" })
|
||||||
|
const executorCtx = createExecutorContext(async () => ([
|
||||||
|
{ 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("preserves a visible server plan agent instead of using fallback", async () => {
|
||||||
|
//#given
|
||||||
|
readProviderModelsCacheMock.mockReturnValue({
|
||||||
|
models: { openai: ["gpt-5.3-codex"] },
|
||||||
|
connected: ["openai"],
|
||||||
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||||
|
})
|
||||||
|
const args = createBaseArgs({ subagent_type: "plan" })
|
||||||
|
const executorCtx = createExecutorContext(async () => ([
|
||||||
|
{ name: "plan", mode: "subagent", model: "openai/gpt-5.3-codex" },
|
||||||
|
{ name: "oracle", mode: "subagent" },
|
||||||
|
]), {
|
||||||
|
sisyphusAgentConfig: {
|
||||||
|
planner_enabled: true,
|
||||||
|
replace_plan: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
expect(result.agentToUse).toBe("plan")
|
||||||
|
expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[{ planner_enabled: false, replace_plan: true }],
|
||||||
|
[{ planner_enabled: true, replace_plan: false }],
|
||||||
|
])(
|
||||||
|
"does not allow hidden plan fallback when planner config blocks replacement (%j)",
|
||||||
|
async (sisyphusAgentConfig) => {
|
||||||
|
//#given
|
||||||
|
const args = createBaseArgs({ subagent_type: "plan" })
|
||||||
|
const executorCtx = createExecutorContext(async () => ([
|
||||||
|
{ name: "oracle", mode: "subagent" },
|
||||||
|
]), {
|
||||||
|
sisyphusAgentConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#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 error hints except callable demoted plan", async () => {
|
test("hidden agents are excluded from error hints except callable demoted plan", async () => {
|
||||||
//#given
|
//#given
|
||||||
const args = createBaseArgs({ subagent_type: "nonexistent" })
|
const args = createBaseArgs({ subagent_type: "nonexistent" })
|
||||||
|
|||||||
Reference in New Issue
Block a user