Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

179 changes: 178 additions & 1 deletion src/tools/delegate-task/subagent-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -507,4 +515,173 @@ 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("project agent takes precedence over user agent with same name", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { minimaxi: ["MiniMax-M2.7-highspeed", "claude-3-haiku"] },
connected: ["minimaxi"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])

mockLoadUserAgents.mockReturnValue({
"my-custom-agent": {
description: "User agent",
mode: "subagent",
prompt: "User prompt",
model: "minimaxi/claude-3-haiku",
},
})
mockLoadProjectAgents.mockReturnValue({
"my-custom-agent": {
description: "Project agent",
mode: "subagent",
prompt: "Project prompt",
model: "minimaxi/MiniMax-M2.7-highspeed",
},
})

const args = createBaseArgs({ subagent_type: "my-custom-agent" })
const executorCtx = createExecutorContext(async () => [])

//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")

//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-custom-agent")
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect(result.categoryModel?.modelID).toBe("MiniMax-M2.7-highspeed")

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("")
})
})
55 changes: 49 additions & 6 deletions src/tools/delegate-task/subagent-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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; project agents override user agents
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 project agents (overrides user agents, server wins on collision)
for (const agent of projectAgentsList) {
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
}

// Then add user agents (only if not already added by server or project)
for (const agent of userAgentsList) {
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(
Expand Down
Loading