diff --git a/src/features/claude-code-agent-loader/index.ts b/src/features/claude-code-agent-loader/index.ts index 644158c42..670e416a4 100644 --- a/src/features/claude-code-agent-loader/index.ts +++ b/src/features/claude-code-agent-loader/index.ts @@ -1,2 +1,5 @@ export * from "./types" export * from "./loader" +export * from "./agent-definitions-loader" +export * from "./opencode-config-agents-reader" +export * from "./json-agent-loader" diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts index c814c8fd9..1ecc77e63 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.test.ts @@ -229,6 +229,34 @@ describe("readOpencodeConfigAgents", () => { fs.rmSync(tempDir, { recursive: true }) }) + it("supports agent key as fallback when agents key is not present", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-")) + const opencodeDir = path.join(tempDir, ".opencode") + fs.mkdirSync(opencodeDir, { recursive: true }) + + const configPath = path.join(opencodeDir, "opencode.json") + fs.writeFileSync( + configPath, + JSON.stringify({ + agent: { + "fallback-agent": { + description: "Using agent key", + mode: "subagent", + prompt: "Fallback prompt", + }, + }, + }) + ) + + const result = readOpencodeConfigAgents(tempDir) + + expect(result).toHaveProperty("fallback-agent") + expect(result["fallback-agent"].description).toBe("(opencode-config) Using agent key") + expect(result["fallback-agent"].prompt).toBe("Fallback prompt") + + fs.rmSync(tempDir, { recursive: true }) + }) + it("prioritizes project-level opencode.json over user-level", () => { const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-project-")) const projectOpencodeDir = path.join(projectDir, ".opencode") diff --git a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts index 354ab9edf..5fcb75cea 100644 --- a/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts +++ b/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts @@ -9,6 +9,7 @@ import type { ClaudeCodeAgentConfig } from "./types" interface OpencodeConfigWithAgents { agents?: Record + agent?: Record agent_definitions?: string | string[] } @@ -103,8 +104,10 @@ export function readOpencodeConfigAgents(directory: string): Record { let discoverGlobalAgentsSkillsSpy: ReturnType let loadUserAgentsSpy: ReturnType let loadProjectAgentsSpy: ReturnType + let loadAgentDefinitionsSpy: ReturnType + let readOpencodeConfigAgentsSpy: ReturnType let migrateAgentConfigSpy: ReturnType let logSpy: ReturnType @@ -140,6 +142,11 @@ describe("applyAgentConfig builtin override protection", () => { loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({}) loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({}) + loadAgentDefinitionsSpy = spyOn(agentLoader, "loadAgentDefinitions").mockReturnValue({}) + readOpencodeConfigAgentsSpy = spyOn( + agentLoader, + "readOpencodeConfigAgents", + ).mockReturnValue({}) migrateAgentConfigSpy = spyOn(shared, "migrateAgentConfig").mockImplementation( (config: Record) => config, @@ -159,6 +166,8 @@ describe("applyAgentConfig builtin override protection", () => { discoverGlobalAgentsSkillsSpy.mockRestore() loadUserAgentsSpy.mockRestore() loadProjectAgentsSpy.mockRestore() + loadAgentDefinitionsSpy.mockRestore() + readOpencodeConfigAgentsSpy.mockRestore() migrateAgentConfigSpy.mockRestore() logSpy.mockRestore() }) @@ -441,4 +450,208 @@ describe("applyAgentConfig builtin override protection", () => { ]), ) }) + + describe("agent_definitions and opencode.json integration", () => { + test("agent_definitions agents appear in output", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + "my-custom-agent": { + name: "my-custom-agent", + prompt: "test custom agent from agent_definitions", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["my-custom-agent"]).toBeDefined() + expect(result["my-custom-agent"]?.prompt).toBe("test custom agent from agent_definitions") + }) + + test("opencode.json agents appear in output", async () => { + // given + readOpencodeConfigAgentsSpy.mockReturnValue({ + "opencode-agent": { + name: "opencode-agent", + prompt: "test opencode config agent", + mode: "subagent", + description: "(opencode-config) OC", + }, + }) + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["opencode-agent"]).toBeDefined() + expect(result["opencode-agent"]?.prompt).toBe("test opencode config agent") + expect(result["opencode-agent"]?.description).toBe("(opencode-config) OC") + }) + + test("agent_definitions agents subject to disabled_agents filtering", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + "disabled-custom-agent": { + name: "disabled-custom-agent", + prompt: "this should be filtered", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + pluginConfig.disabled_agents = ["disabled-custom-agent"] + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["disabled-custom-agent"]).toBeUndefined() + }) + + test("agent_definitions cannot override builtin agents", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + oracle: { + name: "oracle", + prompt: "evil override prompt", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result.oracle).toBeDefined() + expect(result.oracle?.prompt).not.toBe("evil override prompt") + }) + + test("precedence: configAgents override agent_definitions", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + "shared-name": { + name: "shared-name", + prompt: "from-definitions", + mode: "subagent", + }, + }) + const config = createBaseConfig() + ;(config as Record).agent = { + "shared-name": { + name: "shared-name", + prompt: "from-config", + mode: "subagent", + }, + } + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + + // when + const result = await applyAgentConfig({ + config, + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["shared-name"]).toBeDefined() + expect(result["shared-name"]?.prompt).toBe("from-config") + }) + + test("precedence: agent_definitions overrides project agents", async () => { + // given + loadProjectAgentsSpy.mockReturnValue({ + "shared-name": { + name: "shared-name", + prompt: "from-project", + mode: "subagent", + }, + }) + loadAgentDefinitionsSpy.mockReturnValue({ + "shared-name": { + name: "shared-name", + prompt: "from-definitions", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["shared-name"]).toBeDefined() + expect(result["shared-name"]?.prompt).toBe("from-definitions") + }) + + test("both Sisyphus-enabled and disabled paths include new sources", async () => { + // given + loadAgentDefinitionsSpy.mockReturnValue({ + "definitions-agent": { + name: "definitions-agent", + prompt: "from agent_definitions", + mode: "subagent", + }, + }) + readOpencodeConfigAgentsSpy.mockReturnValue({ + "opencode-agent": { + name: "opencode-agent", + prompt: "from opencode.json", + mode: "subagent", + }, + }) + const pluginConfig = createPluginConfig() + pluginConfig.agent_definitions = ["/fake/path/agent.md"] + if (pluginConfig.sisyphus_agent) { + pluginConfig.sisyphus_agent.planner_enabled = false + } + + // when + const result = await applyAgentConfig({ + config: createBaseConfig(), + pluginConfig, + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }) + + // then + expect(result["definitions-agent"]).toBeDefined() + expect(result["definitions-agent"]?.prompt).toBe("from agent_definitions") + expect(result["opencode-agent"]).toBeDefined() + expect(result["opencode-agent"]?.prompt).toBe("from opencode.json") + }) + }) }) diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 91c711b19..384871114 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -14,7 +14,14 @@ import { discoverProjectClaudeSkills, discoverUserClaudeSkills, } from "../features/opencode-skill-loader"; -import { loadProjectAgents, loadUserAgents, loadOpencodeGlobalAgents, loadOpencodeProjectAgents } from "../features/claude-code-agent-loader"; +import { + loadProjectAgents, + loadUserAgents, + loadOpencodeGlobalAgents, + loadOpencodeProjectAgents, + loadAgentDefinitions, + readOpencodeConfigAgents, +} from "../features/claude-code-agent-loader"; import type { PluginComponents } from "./plugin-components-loader"; import { reorderAgentsByPriority } from "./agent-priority-order"; import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"; @@ -33,7 +40,6 @@ type AgentConfigRecord = Record | undefined> & { function getConfiguredDefaultAgent(config: Record): string | undefined { const defaultAgent = config.default_agent; if (typeof defaultAgent !== "string") return undefined; - const trimmedDefaultAgent = defaultAgent.trim(); return trimmedDefaultAgent.length > 0 ? trimmedDefaultAgent : undefined; } @@ -100,6 +106,11 @@ export async function applyAgentConfig(params: { const opencodeProjectAgents = loadOpencodeProjectAgents(params.ctx.directory); const rawPluginAgents = params.pluginComponents.agents; + const agentDefinitionAgents = params.pluginConfig.agent_definitions + ? loadAgentDefinitions(params.pluginConfig.agent_definitions, "definition-file") + : {}; + const opencodeConfigAgents = readOpencodeConfigAgents(params.ctx.directory); + const pluginAgents = Object.fromEntries( Object.entries(rawPluginAgents).map(([key, value]) => { if (!value) return [key, value]; @@ -118,6 +129,8 @@ export async function applyAgentConfig(params: { ...Object.entries(opencodeGlobalAgents), ...Object.entries(opencodeProjectAgents), ...Object.entries(pluginAgents).filter(([, config]) => config !== undefined), + ...Object.entries(agentDefinitionAgents), + ...Object.entries(opencodeConfigAgents), ] .filter(([, config]) => config != null) .map(([name, config]) => ({ @@ -127,6 +140,20 @@ export async function applyAgentConfig(params: { : "", })); + log( + "[agent-config-handler] Agent sources loaded", + { + user: Object.keys(userAgents).length, + project: Object.keys(projectAgents).length, + opencodeGlobal: Object.keys(opencodeGlobalAgents).length, + opencodeProject: Object.keys(opencodeProjectAgents).length, + plugin: Object.keys(pluginAgents).length, + agentDefinitions: Object.keys(agentDefinitionAgents).length, + opencodeConfig: Object.keys(opencodeConfigAgents).length, + config: Object.keys(configAgent ?? {}).length, + } + ); + const builtinAgents = await createBuiltinAgents( migratedDisabledAgents, params.pluginConfig.agents, @@ -269,6 +296,14 @@ export async function applyAgentConfig(params: { opencodeProjectAgents, protectedBuiltinAgentNames, ); + const filteredAgentDefinitionAgents = filterProtectedAgentOverrides( + agentDefinitionAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeConfigAgents = filterProtectedAgentOverrides( + opencodeConfigAgents, + protectedBuiltinAgentNames, + ); params.config.agent = { ...agentConfig, @@ -283,6 +318,8 @@ export async function applyAgentConfig(params: { ...filterDisabledAgents(filteredOpencodeGlobalAgents), ...filterDisabledAgents(filteredProjectAgents), ...filterDisabledAgents(filteredOpencodeProjectAgents), + ...filterDisabledAgents(filteredAgentDefinitionAgents), + ...filterDisabledAgents(filteredOpencodeConfigAgents), ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), @@ -311,6 +348,14 @@ export async function applyAgentConfig(params: { opencodeProjectAgents, protectedBuiltinAgentNames, ); + const filteredAgentDefinitionAgents = filterProtectedAgentOverrides( + agentDefinitionAgents, + protectedBuiltinAgentNames, + ); + const filteredOpencodeConfigAgents = filterProtectedAgentOverrides( + opencodeConfigAgents, + protectedBuiltinAgentNames, + ); const defaultedConfigAgents = configAgent ? Object.fromEntries( @@ -331,6 +376,8 @@ export async function applyAgentConfig(params: { ...filterDisabledAgents(filteredOpencodeGlobalAgents), ...filterDisabledAgents(filteredProjectAgents), ...filterDisabledAgents(filteredOpencodeProjectAgents), + ...filterDisabledAgents(filteredAgentDefinitionAgents), + ...filterDisabledAgents(filteredOpencodeConfigAgents), ...defaultedConfigAgents, }; }