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