fix(agents): use config data instead of client API to avoid init deadlock (#1623)

This commit is contained in:
YeonGyu-Kim 2026-02-08 15:34:47 +09:00
parent f035be842d
commit 321b319b58
3 changed files with 257 additions and 64 deletions

View File

@ -250,7 +250,7 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.sisyphus.prompt).toContain("git-master") 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 // #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set([ new Set([
@ -263,20 +263,13 @@ describe("createBuiltinAgents with model overrides", () => {
]) ])
) )
const client = { const customAgentSummaries = [
agent: { {
list: async () => ({ name: "researcher",
data: [ description: "Research agent for deep analysis",
{ hidden: false,
name: "researcher",
description: "Research agent for deep analysis",
mode: "subagent",
hidden: false,
},
],
}),
}, },
} ]
try { try {
// #when // #when
@ -288,7 +281,7 @@ describe("createBuiltinAgents with model overrides", () => {
undefined, undefined,
undefined, undefined,
[], [],
client customAgentSummaries
) )
// #then // #then
@ -299,6 +292,179 @@ describe("createBuiltinAgents with model overrides", () => {
fetchSpy.mockRestore() 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", () => { describe("createBuiltinAgents without systemDefaultModel", () => {

View File

@ -68,6 +68,14 @@ type RegisteredAgentSummary = {
description: string 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<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null return typeof value === "object" && value !== null
} }
@ -85,37 +93,28 @@ function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[]
const hidden = item.hidden const hidden = item.hidden
if (hidden === true) continue 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 : "" const description = typeof item.description === "string" ? item.description : ""
result.push({ name, description }) result.push({ name, description: sanitizeMarkdownTableCell(description) })
} }
return result return result
} }
async function fetchRegisteredAgentsFromClient(client: unknown): Promise<RegisteredAgentSummary[]> {
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 { function buildCustomAgentMetadata(agentName: string, description: string): AgentPromptMetadata {
const shortDescription = truncateDescription(description).trim() const shortDescription = sanitizeMarkdownTableCell(truncateDescription(description))
const safeAgentName = sanitizeMarkdownTableCell(agentName)
return { return {
category: "specialist", category: "specialist",
cost: "CHEAP", cost: "CHEAP",
triggers: [ triggers: [
{ {
domain: `Custom agent: ${agentName}`, domain: `Custom agent: ${safeAgentName}`,
trigger: shortDescription || "Use when this agent's description matches the task", trigger: shortDescription || "Use when this agent's description matches the task",
}, },
], ],
@ -303,13 +302,13 @@ export async function createBuiltinAgents(
categories?: CategoriesConfig, categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig, gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [], discoveredSkills: LoadedSkill[] = [],
client?: any, customAgentSummaries?: unknown,
browserProvider?: BrowserAutomationProvider, browserProvider?: BrowserAutomationProvider,
uiSelectedModel?: string, uiSelectedModel?: string,
disabledSkills?: Set<string> disabledSkills?: Set<string>
): Promise<Record<string, AgentConfig>> { ): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache() 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. // This function is called from config handler, and calling client API causes deadlock.
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
const availableModels = await fetchAvailableModels(undefined, { const availableModels = await fetchAvailableModels(undefined, {
@ -349,7 +348,7 @@ export async function createBuiltinAgents(
const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable] 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 builtinAgentNames = new Set(Object.keys(agentSources).map((n) => n.toLowerCase()))
const disabledAgentNames = new Set(disabledAgents.map((n) => n.toLowerCase())) const disabledAgentNames = new Set(disabledAgents.map((n) => n.toLowerCase()))

View File

@ -183,19 +183,40 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
// Pass it as uiSelectedModel so it takes highest priority in model resolution // Pass it as uiSelectedModel so it takes highest priority in model resolution
const currentModel = config.model as string | undefined; const currentModel = config.model as string | undefined;
const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []); const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []);
const builtinAgents = await createBuiltinAgents(
migratedDisabledAgents, type AgentConfig = Record<
pluginConfig.agents, string,
ctx.directory, Record<string, unknown> | undefined
undefined, // systemDefaultModel - let fallback chain handle this > & {
pluginConfig.categories, build?: Record<string, unknown>;
pluginConfig.git_master, plan?: Record<string, unknown>;
allDiscoveredSkills, explore?: { tools?: Record<string, unknown> };
ctx.client, librarian?: { tools?: Record<string, unknown> };
browserProvider, "multimodal-looker"?: { tools?: Record<string, unknown> };
currentModel, // uiSelectedModel - takes highest priority atlas?: { tools?: Record<string, unknown> };
disabledSkills sisyphus?: { tools?: Record<string, unknown> };
); };
const configAgent = config.agent as AgentConfig | undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function buildCustomAgentSummaryInput(agents: Record<string, unknown> | 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 agents: Do NOT apply permission migration
// Claude Code uses whitelist-based tools format which is semantically different // 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 isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled = const builderEnabled =
pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; 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 replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
const shouldDemotePlan = plannerEnabled && replacePlan; const shouldDemotePlan = plannerEnabled && replacePlan;
type AgentConfig = Record<
string,
Record<string, unknown> | undefined
> & {
build?: Record<string, unknown>;
plan?: Record<string, unknown>;
explore?: { tools?: Record<string, unknown> };
librarian?: { tools?: Record<string, unknown> };
"multimodal-looker"?: { tools?: Record<string, unknown> };
atlas?: { tools?: Record<string, unknown> };
sisyphus?: { tools?: Record<string, unknown> };
};
const configAgent = config.agent as AgentConfig | undefined;
if (isSisyphusEnabled && builtinAgents.sisyphus) { if (isSisyphusEnabled && builtinAgents.sisyphus) {
(config as { default_agent?: string }).default_agent = "sisyphus"; (config as { default_agent?: string }).default_agent = "sisyphus";