From 321b319b586e5e274c8f22f5438b0c3bd7a70234 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 15:34:47 +0900 Subject: [PATCH] fix(agents): use config data instead of client API to avoid init deadlock (#1623) --- src/agents/utils.test.ts | 196 ++++++++++++++++++++++++-- src/agents/utils.ts | 43 +++--- src/plugin-handlers/config-handler.ts | 82 +++++++---- 3 files changed, 257 insertions(+), 64 deletions(-) diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index a101840f..dfe9d972 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -250,7 +250,7 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.sisyphus.prompt).toContain("git-master") }) - test("includes custom agents from OpenCode registry in orchestrator prompts", async () => { + test("includes custom agents in orchestrator prompts when provided via config", async () => { // #given const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( new Set([ @@ -263,20 +263,13 @@ describe("createBuiltinAgents with model overrides", () => { ]) ) - const client = { - agent: { - list: async () => ({ - data: [ - { - name: "researcher", - description: "Research agent for deep analysis", - mode: "subagent", - hidden: false, - }, - ], - }), + const customAgentSummaries = [ + { + name: "researcher", + description: "Research agent for deep analysis", + hidden: false, }, - } + ] try { // #when @@ -288,7 +281,7 @@ describe("createBuiltinAgents with model overrides", () => { undefined, undefined, [], - client + customAgentSummaries ) // #then @@ -299,6 +292,179 @@ describe("createBuiltinAgents with model overrides", () => { fetchSpy.mockRestore() } }) + + test("excludes hidden custom agents from orchestrator prompts", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const customAgentSummaries = [ + { + name: "hidden-agent", + description: "Should never show", + hidden: true, + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).not.toContain("hidden-agent") + expect(agents.hephaestus.prompt).not.toContain("hidden-agent") + expect(agents.atlas.prompt).not.toContain("hidden-agent") + } finally { + fetchSpy.mockRestore() + } + }) + + test("excludes disabled custom agents from orchestrator prompts", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const customAgentSummaries = [ + { + name: "disabled-agent", + description: "Should never show", + disabled: true, + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).not.toContain("disabled-agent") + expect(agents.hephaestus.prompt).not.toContain("disabled-agent") + expect(agents.atlas.prompt).not.toContain("disabled-agent") + } finally { + fetchSpy.mockRestore() + } + }) + + test("excludes custom agents when disabledAgents contains their name (case-insensitive)", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const disabledAgents = ["ReSeArChEr"] + const customAgentSummaries = [ + { + name: "researcher", + description: "Should never show", + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + disabledAgents, + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).not.toContain("researcher") + expect(agents.hephaestus.prompt).not.toContain("researcher") + expect(agents.atlas.prompt).not.toContain("researcher") + } finally { + fetchSpy.mockRestore() + } + }) + + test("deduplicates custom agents case-insensitively", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const customAgentSummaries = [ + { name: "Researcher", description: "First" }, + { name: "researcher", description: "Second" }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + const matches = agents.sisyphus.prompt.match(/Custom agent: researcher/gi) ?? [] + expect(matches.length).toBe(1) + } finally { + fetchSpy.mockRestore() + } + }) + + test("sanitizes custom agent strings for markdown tables", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const customAgentSummaries = [ + { + name: "table-agent", + description: "Line1\nAlpha | Beta", + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).toContain("Line1 Alpha \\| Beta") + } finally { + fetchSpy.mockRestore() + } + }) }) describe("createBuiltinAgents without systemDefaultModel", () => { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index bdd95488..55d6187b 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -68,6 +68,14 @@ type RegisteredAgentSummary = { description: string } +function sanitizeMarkdownTableCell(value: string): string { + return value + .replace(/\r?\n/g, " ") + .replace(/\|/g, "\\|") + .replace(/\s+/g, " ") + .trim() +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } @@ -85,37 +93,28 @@ function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[] const hidden = item.hidden if (hidden === true) continue + const disabled = item.disabled + if (disabled === true) continue + + const enabled = item.enabled + if (enabled === false) continue + const description = typeof item.description === "string" ? item.description : "" - result.push({ name, description }) + result.push({ name, description: sanitizeMarkdownTableCell(description) }) } return result } -async function fetchRegisteredAgentsFromClient(client: unknown): Promise { - if (!isRecord(client)) return [] - const agentObj = client.agent - if (!isRecord(agentObj)) return [] - const listFn = agentObj.list - if (typeof listFn !== "function") return [] - - try { - const response = await listFn.call(agentObj) - if (!isRecord(response)) return [] - return parseRegisteredAgentSummaries(response.data) - } catch { - return [] - } -} - function buildCustomAgentMetadata(agentName: string, description: string): AgentPromptMetadata { - const shortDescription = truncateDescription(description).trim() + const shortDescription = sanitizeMarkdownTableCell(truncateDescription(description)) + const safeAgentName = sanitizeMarkdownTableCell(agentName) return { category: "specialist", cost: "CHEAP", triggers: [ { - domain: `Custom agent: ${agentName}`, + domain: `Custom agent: ${safeAgentName}`, trigger: shortDescription || "Use when this agent's description matches the task", }, ], @@ -303,13 +302,13 @@ export async function createBuiltinAgents( categories?: CategoriesConfig, gitMasterConfig?: GitMasterConfig, discoveredSkills: LoadedSkill[] = [], - client?: any, + customAgentSummaries?: unknown, browserProvider?: BrowserAutomationProvider, uiSelectedModel?: string, disabledSkills?: Set ): Promise> { const connectedProviders = readConnectedProvidersCache() - // IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization. + // IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization. // This function is called from config handler, and calling client API causes deadlock. // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 const availableModels = await fetchAvailableModels(undefined, { @@ -349,7 +348,7 @@ export async function createBuiltinAgents( const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable] - const registeredAgents = await fetchRegisteredAgentsFromClient(client) + const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries) const builtinAgentNames = new Set(Object.keys(agentSources).map((n) => n.toLowerCase())) const disabledAgentNames = new Set(disabledAgents.map((n) => n.toLowerCase())) diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 41adbaf2..1c5c7cd3 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -183,19 +183,40 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { // Pass it as uiSelectedModel so it takes highest priority in model resolution const currentModel = config.model as string | undefined; const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); - const builtinAgents = await createBuiltinAgents( - migratedDisabledAgents, - pluginConfig.agents, - ctx.directory, - undefined, // systemDefaultModel - let fallback chain handle this - pluginConfig.categories, - pluginConfig.git_master, - allDiscoveredSkills, - ctx.client, - browserProvider, - currentModel, // uiSelectedModel - takes highest priority - disabledSkills - ); + + type AgentConfig = Record< + string, + Record | undefined + > & { + build?: Record; + plan?: Record; + explore?: { tools?: Record }; + librarian?: { tools?: Record }; + "multimodal-looker"?: { tools?: Record }; + atlas?: { tools?: Record }; + sisyphus?: { tools?: Record }; + }; + const configAgent = config.agent as AgentConfig | undefined; + + function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; + } + + function buildCustomAgentSummaryInput(agents: Record | undefined): unknown[] { + if (!agents) return []; + + const result: unknown[] = []; + for (const [name, value] of Object.entries(agents)) { + if (!isRecord(value)) continue; + + const description = typeof value.description === "string" ? value.description : ""; + const hidden = value.hidden === true; + const disabled = value.disabled === true || value.enabled === false; + result.push({ name, description, hidden, disabled }); + } + + return result; + } // Claude Code agents: Do NOT apply permission migration // Claude Code uses whitelist-based tools format which is semantically different @@ -216,6 +237,27 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { ]) ); + const customAgentSummaries = [ + ...buildCustomAgentSummaryInput(configAgent), + ...buildCustomAgentSummaryInput(userAgents), + ...buildCustomAgentSummaryInput(projectAgents), + ...buildCustomAgentSummaryInput(pluginAgents), + ]; + + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + pluginConfig.agents, + ctx.directory, + undefined, // systemDefaultModel - let fallback chain handle this + pluginConfig.categories, + pluginConfig.git_master, + allDiscoveredSkills, + customAgentSummaries, + browserProvider, + currentModel, // uiSelectedModel - takes highest priority + disabledSkills + ); + const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true; const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; @@ -224,20 +266,6 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true; const shouldDemotePlan = plannerEnabled && replacePlan; - type AgentConfig = Record< - string, - Record | undefined - > & { - build?: Record; - plan?: Record; - explore?: { tools?: Record }; - librarian?: { tools?: Record }; - "multimodal-looker"?: { tools?: Record }; - atlas?: { tools?: Record }; - sisyphus?: { tools?: Record }; - }; - const configAgent = config.agent as AgentConfig | undefined; - if (isSisyphusEnabled && builtinAgents.sisyphus) { (config as { default_agent?: string }).default_agent = "sisyphus";