diff --git a/bun.lock b/bun.lock index 32e143f44..f7e37f78b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 949e7ee2b..41eaae1f7 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -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): 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() + 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) diff --git a/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts b/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts index 4f84fc01f..08286ba26 100644 --- a/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts +++ b/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts @@ -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 { 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()) })