Merge pull request #2974 from trafgals/fix/user-agents-callable-v2

fix: include user/project agents in task(subagent_type) resolution
This commit is contained in:
YeonGyu-Kim
2026-04-12 06:17:26 +09:00
committed by GitHub
3 changed files with 259 additions and 17 deletions
+33 -11
View File
@@ -30,17 +30,17 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.16.0",
"oh-my-opencode-darwin-x64": "3.16.0",
"oh-my-opencode-darwin-x64-baseline": "3.16.0",
"oh-my-opencode-linux-arm64": "3.16.0",
"oh-my-opencode-linux-arm64-musl": "3.16.0",
"oh-my-opencode-linux-x64": "3.16.0",
"oh-my-opencode-linux-x64-baseline": "3.16.0",
"oh-my-opencode-linux-x64-musl": "3.16.0",
"oh-my-opencode-linux-x64-musl-baseline": "3.16.0",
"oh-my-opencode-windows-x64": "3.16.0",
"oh-my-opencode-windows-x64-baseline": "3.16.0",
"oh-my-opencode-darwin-arm64": "3.17.0",
"oh-my-opencode-darwin-x64": "3.17.0",
"oh-my-opencode-darwin-x64-baseline": "3.17.0",
"oh-my-opencode-linux-arm64": "3.17.0",
"oh-my-opencode-linux-arm64-musl": "3.17.0",
"oh-my-opencode-linux-x64": "3.17.0",
"oh-my-opencode-linux-x64-baseline": "3.17.0",
"oh-my-opencode-linux-x64-musl": "3.17.0",
"oh-my-opencode-linux-x64-musl-baseline": "3.17.0",
"oh-my-opencode-windows-x64": "3.17.0",
"oh-my-opencode-windows-x64-baseline": "3.17.0",
},
},
},
@@ -238,6 +238,28 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.17.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-d8VHKSjR4gWwQ7rvYn2bU+v+I3KlcgqGc0R38WCn4ZiyfTJECcOVzhOVKt4hKfJgbH2uNjpJ5eM41jPP6oJRjA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.17.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m4ir/TpacyobUFQ9xcKHq1Tn4JLHvwrOWmoJk59VQPDMUIaGWhfafgBuRFFKIkXIXRKBP1pEnV+PYGolPRBUGg=="],
"oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.17.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ej4XZdt3aRpUL3mSIp5SHRDmoTdeojdgkxqF5s/6Gv79NomCIhQLNVs6yRhUihrWkVwclAkKXHM6+UkGNbWQQw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.17.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-xww4j2wRwxA0M7YpM5DT9ZScEajW9aDr5+NNoIqJQwIRCkKShmDvyhxbi/zTXLiyhu0HNySoLfN5iqfhIvNl9w=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.17.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oR0EwN9jbhshhdomnV9zv5KNfsv0LWP3G21yhBkTPc2DtzUPQ0WEhG0qgbVPV8z9rq3HsB/L0CIg+/D/CGavPQ=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.17.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9uNbkDhJIsaTCxF+A0KOUxES0vasw+8LD9uEdN0BBgjtvQZ67XaHsPUZkSGBYzeTJgJVOImq/fwzINhqxfXahw=="],
"oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.17.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-FaWPECkzdnT5CkHPgWsMbpsXK8vC+YgqxmBJ5SdwRb8aeRk0+ySF96dTp4rz6xkegmkwC7XXfmmW5bu9yQjzyg=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.17.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-JHEIjWvhB0Z5kHNhXirTsW7YzTb4IbV//LVCmQuhpoyDC7GeN3Wka3OPSBnTgnqw1SMvm0c6G7gNvDqeZNgzUg=="],
"oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.17.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-WxwUW0VWDle78U0xtF7lrcYUWB6EXf+lLBZJue3HWS4wpyTAYlynGgnZJCnG0l8bc9RLJIe4VvvmiZSFuxNIEg=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.17.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-XnIR++Kw1s+5MOZLqoBTCWyYaXT03JAR+5/7zYU1b5JEbQuM5L/zKXjUScXnVUHMkqtYpxUZLE7Nk6ldUyWNaA=="],
"oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.17.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-wyv/OHC/SrJVYGWMu9wD7+PUcmp87v38zBgMduqOn5PIUkmwIPOXF7tOdPeIQsch3/m/CK01RuZJ8NHAMA0bGA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+47 -6
View File
@@ -15,9 +15,16 @@ import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveModelForDelegateTask } from "./model-selection"
import { fuzzyMatchModel } from "../../shared/model-availability"
import type { CategoryConfig } from "../../config/schema"
import { loadUserAgents, loadProjectAgents } from "../../features/claude-code-agent-loader"
type AgentMode = "subagent" | "primary" | "all" | undefined
type AgentInfo = {
name: string
mode?: "subagent" | "primary" | "all"
model?: string | { providerID: string; modelID: string }
}
function applyCategoryParams(
base: DelegatedModelConfig,
config: CategoryConfig | undefined,
@@ -36,6 +43,44 @@ function applyCategoryParams(
}
}
function mergeWithClaudeCodeAgents(
serverAgents: AgentInfo[],
directory: string | undefined,
): AgentInfo[] {
const userAgentsRecord = loadUserAgents()
const projectAgentsRecord = loadProjectAgents(directory)
const toAgentInfoList = (record: Record<string, { mode?: string; model?: AgentInfo["model"] }>): AgentInfo[] =>
Object.entries(record).map(([name, config]) => ({
name,
mode: config.mode as AgentInfo["mode"],
model: config.model,
}))
const projectAgentsList = toAgentInfoList(projectAgentsRecord)
const userAgentsList = toAgentInfoList(userAgentsRecord)
const mergedAgentMap = new Map<string, AgentInfo>()
const addIfAbsent = (agent: AgentInfo): void => {
const key = agent.name.toLowerCase()
if (!mergedAgentMap.has(key)) {
mergedAgentMap.set(key, agent)
}
}
for (const agent of serverAgents) {
addIfAbsent(agent)
}
for (const agent of projectAgentsList) {
addIfAbsent(agent)
}
for (const agent of userAgentsList) {
addIfAbsent(agent)
}
return Array.from(mergedAgentMap.values())
}
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
@@ -78,16 +123,12 @@ Create the work plan directly - that's your job as the planning agent.`,
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((agent) => isTaskCallableAgentMode(agent.mode))
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
const callableAgents = mergedAgents.filter((agent) => isTaskCallableAgentMode(agent.mode))
const resolvedDisplayName = stripAgentListSortPrefix(getAgentDisplayName(agentToUse))
const normalizedAgentToUse = stripAgentListSortPrefix(agentToUse)
@@ -15,6 +15,19 @@ const readProviderModelsCacheMock = mock(
} | null,
)
type ClaudeCodeAgentRecord = Record<
string,
{
description?: string
mode?: string
prompt?: string
model?: string | { providerID: string; modelID: string }
}
>
const loadUserAgentsMock = mock((): ClaudeCodeAgentRecord => ({}))
const loadProjectAgentsMock = mock((_directory?: string): ClaudeCodeAgentRecord => ({}))
async function importFreshSubagentResolverModule(): Promise<SubagentResolverModule> {
return await import(`../subagent-resolver?test=${Date.now()}-${Math.random()}`)
}
@@ -58,6 +71,10 @@ describe("resolveSubagentExecution", () => {
readProviderModelsCacheMock.mockReset()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
loadUserAgentsMock.mockReset()
loadProjectAgentsMock.mockReset()
loadUserAgentsMock.mockImplementation(() => ({}))
loadProjectAgentsMock.mockImplementation(() => ({}))
mock.module("../../../shared/logger", () => ({
log: logMock,
}))
@@ -68,6 +85,14 @@ describe("resolveSubagentExecution", () => {
hasProviderModelsCache: () => readProviderModelsCacheMock() !== null,
_resetMemCacheForTesting: () => {},
}))
mock.module("../../../features/claude-code-agent-loader/loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
mock.module("../../../features/claude-code-agent-loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
})
@@ -659,6 +684,148 @@ describe("resolveSubagentExecution", () => {
thinking: { type: "disabled" },
})
})
test("resolves user agent from loadUserAgents when calling task(subagent_type=...)", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
loadUserAgentsMock.mockImplementation(() => ({
"my-user-agent": {
description: "A user agent",
mode: "subagent",
prompt: "Do something",
model: "openai/gpt-5.4",
},
}))
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")
})
test("resolves project agent from loadProjectAgents when calling task(subagent_type=...)", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-sonnet-4"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
readConnectedProvidersCacheMock.mockReturnValue(["anthropic"])
loadProjectAgentsMock.mockImplementation(() => ({
"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")
})
test("server agent takes precedence over user agent with same name", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4", "gpt-3.5"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
loadUserAgentsMock.mockImplementation(() => ({
"explore": {
description: "User explore agent",
mode: "subagent",
prompt: "User prompt",
model: "openai/gpt-3.5",
},
}))
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")
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
})
test("project agent takes precedence over user agent with same name", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { minimaxi: ["MiniMax-M2.7-highspeed", "claude-3-haiku"] },
connected: ["minimaxi"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
loadUserAgentsMock.mockImplementation(() => ({
"my-custom-agent": {
description: "User agent",
mode: "subagent",
prompt: "User prompt",
model: "minimaxi/claude-3-haiku",
},
}))
loadProjectAgentsMock.mockImplementation(() => ({
"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")
expect(result.categoryModel?.modelID).toBe("MiniMax-M2.7-highspeed")
})
test("filters out primary agents from user/project when resolving", async () => {
//#given
loadUserAgentsMock.mockImplementation(() => ({
"my-primary-agent": {
description: "A primary agent",
mode: "primary",
prompt: "I am primary",
},
}))
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("")
})
})
describe("resolveSubagentExecution - agent name sanitization", () => {
@@ -671,6 +838,10 @@ describe("resolveSubagentExecution - agent name sanitization", () => {
readProviderModelsCacheMock.mockReset()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
loadUserAgentsMock.mockReset()
loadProjectAgentsMock.mockReset()
loadUserAgentsMock.mockImplementation(() => ({}))
loadProjectAgentsMock.mockImplementation(() => ({}))
mock.module("../../../shared/logger", () => ({
log: logMock,
}))
@@ -681,6 +852,14 @@ describe("resolveSubagentExecution - agent name sanitization", () => {
hasProviderModelsCache: () => readProviderModelsCacheMock() !== null,
_resetMemCacheForTesting: () => {},
}))
mock.module("../../../features/claude-code-agent-loader/loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
mock.module("../../../features/claude-code-agent-loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
})