fix: include user/project agents in task(subagent_type) resolution

Re-implement PR #2690 against current upstream/dev since original
branch became incompatible with codebase refactoring.

Changes:
- Import loadUserAgents and loadProjectAgents from claude-code-agent-loader
- Merge user/project agents with server agents in resolveSubagentExecution()
- Server agents take precedence on name collisions
- Primary-mode agents from user/project are filtered out

Tests: 4 new test cases covering user agent resolution, project
agent resolution, server precedence, and primary agent filtering.
This commit is contained in:
trafgals
2026-04-03 11:51:19 +10:00
parent 7f36011913
commit ad85e43809
2 changed files with 186 additions and 7 deletions
@@ -1,11 +1,19 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
const { describe, test, expect, beforeEach, afterEach, spyOn, mock, vi } = require("bun:test")
import { resolveSubagentExecution } from "./subagent-resolver"
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import * as logger from "../../shared/logger"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
const mockLoadUserAgents = vi.fn().mockReturnValue({})
const mockLoadProjectAgents = vi.fn().mockReturnValue({})
vi.mock("../../features/claude-code-agent-loader", () => ({
loadUserAgents: mockLoadUserAgents,
loadProjectAgents: mockLoadProjectAgents,
}))
function createBaseArgs(overrides?: Partial<DelegateTaskArgs>): DelegateTaskArgs {
return {
description: "Run review",
@@ -507,4 +515,132 @@ describe("resolveSubagentExecution", () => {
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("resolves user agent from loadUserAgents when calling task(subagent_type=...)", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
mockLoadUserAgents.mockReturnValue({
"my-user-agent": {
description: "A user agent",
mode: "subagent",
prompt: "Do something",
model: "openai/gpt-5.4",
},
})
mockLoadProjectAgents.mockReturnValue({})
const args = createBaseArgs({ subagent_type: "my-user-agent" })
const executorCtx = createExecutorContext(async () => [])
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-user-agent")
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("resolves project agent from loadProjectAgents when calling task(subagent_type=...)", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { anthropic: ["claude-sonnet-4"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
mockLoadUserAgents.mockReturnValue({})
mockLoadProjectAgents.mockReturnValue({
"my-project-agent": {
description: "A project agent",
mode: "subagent",
prompt: "Do project work",
model: "anthropic/claude-sonnet-4",
},
})
const args = createBaseArgs({ subagent_type: "my-project-agent" })
const executorCtx = createExecutorContext(async () => [])
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-project-agent")
expect(result.categoryModel?.modelID).toBe("claude-sonnet-4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("server agent takes precedence over user agent with same name", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
mockLoadUserAgents.mockReturnValue({
"explore": {
description: "User explore agent",
mode: "subagent",
prompt: "User prompt",
model: "openai/gpt-3.5",
},
})
mockLoadProjectAgents.mockReturnValue({})
// Server has "explore" agent
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(async () => ([
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("explore")
// Should use server's model, not user's
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("filters out primary agents from user/project when resolving", async () => {
//#given
mockLoadUserAgents.mockReturnValue({
"my-primary-agent": {
description: "A primary agent",
mode: "primary",
prompt: "I am primary",
},
})
mockLoadProjectAgents.mockReturnValue({})
const args = createBaseArgs({ subagent_type: "my-primary-agent" })
const executorCtx = createExecutorContext(async () => [])
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toContain("Unknown agent")
expect(result.agentToUse).toBe("")
})
})
+49 -6
View File
@@ -14,6 +14,7 @@ import { getAvailableModelsForDelegateTask } from "./available-models"
import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveModelForDelegateTask } from "./model-selection"
import { fuzzyMatchModel } from "../../shared/model-availability"
import { loadUserAgents, loadProjectAgents } from "../../features/claude-code-agent-loader"
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
@@ -53,18 +54,60 @@ Create the work plan directly - that's your job as the planning agent.`,
let categoryModel: DelegatedModelConfig | undefined
let fallbackChain: FallbackEntry[] | undefined = undefined
type AgentInfo = {
name: string
mode?: "subagent" | "primary" | "all"
model?: string | { providerID: string; modelID: string }
}
try {
const agentsResult = await client.app.agents()
type AgentInfo = {
name: string
mode?: "subagent" | "primary" | "all"
model?: string | { providerID: string; modelID: string }
}
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
preferResponseOnMissingData: true,
})
const callableAgents = agents.filter((a) => a.mode !== "primary")
// Load user and project agents
const userAgentsRecord = loadUserAgents()
const projectAgentsRecord = loadProjectAgents(executorCtx.directory)
// Convert user/project agent configs to AgentInfo format
const userAgentsList: AgentInfo[] = Object.entries(userAgentsRecord).map(([name, config]) => ({
name,
mode: config.mode as "subagent" | "primary" | "all",
model: config.model,
}))
const projectAgentsList: AgentInfo[] = Object.entries(projectAgentsRecord).map(([name, config]) => ({
name,
mode: config.mode as "subagent" | "primary" | "all",
model: config.model,
}))
// Merge user and project agents into the server's agent list
// Server agents take precedence; user/project agents fill in gaps
const mergedAgentMap = new Map<string, AgentInfo>()
// First add server agents (they take precedence)
for (const agent of agents) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
// Then add user agents (server wins on collision)
for (const agent of userAgentsList) {
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
}
// Then add project agents (server wins on collision)
for (const agent of projectAgentsList) {
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
}
const mergedAgents = Array.from(mergedAgentMap.values())
const callableAgents = mergedAgents.filter((a) => a.mode !== "primary")
const resolvedDisplayName = getAgentDisplayName(agentToUse)
const matchedAgent = callableAgents.find(