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:
@@ -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("")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user