Merge pull request #2299 from brandonwebb-vista/feat/dynamic-custom-agent-support
feat(call-omo-agent): support custom agents via dynamic resolution
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const AgentDefinitionPathSchema = z.string().min(1)
|
||||
|
||||
export const AgentDefinitionsConfigSchema = z.array(AgentDefinitionPathSchema).optional()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { AnyMcpNameSchema } from "../../mcp/types"
|
||||
import { BuiltinSkillNameSchema } from "./agent-names"
|
||||
import { AgentDefinitionsConfigSchema } from "./agent-definitions"
|
||||
import { AgentOverridesSchema } from "./agent-overrides"
|
||||
import { BabysittingConfigSchema } from "./babysitting"
|
||||
import { BackgroundTaskConfigSchema } from "./background-task"
|
||||
@@ -29,6 +30,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
new_task_system_enabled: z.boolean().optional(),
|
||||
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
||||
default_run_agent: z.string().optional(),
|
||||
/** Paths to external agent definition files (.md or .json) */
|
||||
agent_definitions: AgentDefinitionsConfigSchema,
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
disabled_agents: z.array(z.string()).optional(),
|
||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import { loadAgentDefinitions, parseMarkdownAgentFile } from "./agent-definitions-loader"
|
||||
|
||||
describe("agent-definitions-loader", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "agent-definitions-test-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("#parseMarkdownAgentFile", () => {
|
||||
test("parses valid markdown agent file", () => {
|
||||
const filePath = join(tempDir, "test-agent.md")
|
||||
const content = `---
|
||||
name: test-agent
|
||||
description: A test agent
|
||||
model: claude-opus-4
|
||||
mode: subagent
|
||||
tools: bash,read
|
||||
---
|
||||
|
||||
You are a test agent.`
|
||||
|
||||
writeFileSync(filePath, content, "utf-8")
|
||||
|
||||
const result = parseMarkdownAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.name).toBe("test-agent")
|
||||
expect(result?.config.description).toBe("(definition-file) A test agent")
|
||||
expect(result?.config.mode).toBe("subagent")
|
||||
expect(result?.config.prompt).toBe("You are a test agent.")
|
||||
expect(result?.config.tools).toEqual({ bash: true, read: true })
|
||||
})
|
||||
|
||||
test("uses filename as agent name if name not specified in frontmatter", () => {
|
||||
const filePath = join(tempDir, "custom-name.md")
|
||||
const content = `---
|
||||
description: No name specified
|
||||
---
|
||||
|
||||
Prompt content.`
|
||||
|
||||
writeFileSync(filePath, content, "utf-8")
|
||||
|
||||
const result = parseMarkdownAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.name).toBe("custom-name")
|
||||
})
|
||||
|
||||
test("returns null for missing file", () => {
|
||||
const filePath = join(tempDir, "missing.md")
|
||||
const result = parseMarkdownAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("handles malformed frontmatter gracefully with defaults", () => {
|
||||
const filePath = join(tempDir, "malformed.md")
|
||||
const content = `---
|
||||
invalid: yaml: content: here
|
||||
---
|
||||
|
||||
Prompt.`
|
||||
|
||||
writeFileSync(filePath, content, "utf-8")
|
||||
|
||||
const result = parseMarkdownAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.name).toBe("malformed")
|
||||
expect(result?.config.mode).toBe("subagent")
|
||||
expect(result?.config.prompt).toBe("Prompt.")
|
||||
})
|
||||
|
||||
test("strips .MD extension case-insensitively for agent name", () => {
|
||||
const filePath = join(tempDir, "UpperCase.MD")
|
||||
const content = `---
|
||||
description: Mixed case extension
|
||||
---
|
||||
|
||||
Prompt content.`
|
||||
|
||||
writeFileSync(filePath, content, "utf-8")
|
||||
|
||||
const result = parseMarkdownAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.name).toBe("UpperCase")
|
||||
})
|
||||
|
||||
test("defaults mode to subagent when not specified", () => {
|
||||
const filePath = join(tempDir, "no-mode.md")
|
||||
const content = `---
|
||||
name: no-mode-agent
|
||||
---
|
||||
|
||||
Prompt.`
|
||||
|
||||
writeFileSync(filePath, content, "utf-8")
|
||||
|
||||
const result = parseMarkdownAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.config.mode).toBe("subagent")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#loadAgentDefinitions", () => {
|
||||
test("loads mixed format files (markdown and JSON)", () => {
|
||||
const mdPath = join(tempDir, "agent1.md")
|
||||
const jsonPath = join(tempDir, "agent2.json")
|
||||
|
||||
writeFileSync(
|
||||
mdPath,
|
||||
`---
|
||||
name: md-agent
|
||||
---
|
||||
|
||||
Markdown agent prompt.`,
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
jsonPath,
|
||||
JSON.stringify({
|
||||
name: "json-agent",
|
||||
prompt: "JSON agent prompt.",
|
||||
}),
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
const result = loadAgentDefinitions([mdPath, jsonPath], "definition-file")
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
expect(result["md-agent"]).toBeDefined()
|
||||
expect(result["json-agent"]).toBeDefined()
|
||||
expect(result["md-agent"].prompt).toBe("Markdown agent prompt.")
|
||||
expect(result["json-agent"].prompt).toBe("JSON agent prompt.")
|
||||
})
|
||||
|
||||
test("silently skips missing files with warning log", () => {
|
||||
const validPath = join(tempDir, "valid.md")
|
||||
const missingPath = join(tempDir, "missing.md")
|
||||
|
||||
writeFileSync(
|
||||
validPath,
|
||||
`---
|
||||
name: valid-agent
|
||||
---
|
||||
|
||||
Valid prompt.`,
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
const result = loadAgentDefinitions([validPath, missingPath], "definition-file")
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(result["valid-agent"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("silently skips malformed files with warning log", () => {
|
||||
const validPath = join(tempDir, "valid.jsonc")
|
||||
const malformedPath = join(tempDir, "malformed.json")
|
||||
|
||||
writeFileSync(
|
||||
validPath,
|
||||
JSON.stringify({
|
||||
name: "valid-agent",
|
||||
prompt: "Valid prompt.",
|
||||
}),
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
writeFileSync(malformedPath, "{ invalid json", "utf-8")
|
||||
|
||||
const result = loadAgentDefinitions([validPath, malformedPath], "definition-file")
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(result["valid-agent"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("last-write-wins for duplicate agent names", () => {
|
||||
const path1 = join(tempDir, "agent-v1.md")
|
||||
const path2 = join(tempDir, "agent-v2.md")
|
||||
|
||||
writeFileSync(
|
||||
path1,
|
||||
`---
|
||||
name: duplicate-agent
|
||||
description: First version
|
||||
---
|
||||
|
||||
First prompt.`,
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
path2,
|
||||
`---
|
||||
name: duplicate-agent
|
||||
description: Second version
|
||||
---
|
||||
|
||||
Second prompt.`,
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
const result = loadAgentDefinitions([path1, path2], "definition-file")
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(result["duplicate-agent"].description).toBe("(definition-file) Second version")
|
||||
expect(result["duplicate-agent"].prompt).toBe("Second prompt.")
|
||||
})
|
||||
|
||||
test("returns empty object for empty paths array", () => {
|
||||
const result = loadAgentDefinitions([], "definition-file")
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("handles absolute paths correctly", () => {
|
||||
const absolutePath = join(tempDir, "absolute.md")
|
||||
|
||||
writeFileSync(
|
||||
absolutePath,
|
||||
`---
|
||||
name: absolute-agent
|
||||
---
|
||||
|
||||
Absolute path prompt.`,
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
const result = loadAgentDefinitions([absolutePath], "definition-file")
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(result["absolute-agent"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("skips unsupported file extensions with warning", () => {
|
||||
const validPath = join(tempDir, "valid.md")
|
||||
const unsupportedPath = join(tempDir, "unsupported.txt")
|
||||
|
||||
writeFileSync(
|
||||
validPath,
|
||||
`---
|
||||
name: valid-agent
|
||||
---
|
||||
|
||||
Valid prompt.`,
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
writeFileSync(unsupportedPath, "Some text file content.", "utf-8")
|
||||
|
||||
const result = loadAgentDefinitions([validPath, unsupportedPath], "definition-file")
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(result["valid-agent"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("supports JSONC format with comments", () => {
|
||||
const jsoncPath = join(tempDir, "agent.jsonc")
|
||||
|
||||
writeFileSync(
|
||||
jsoncPath,
|
||||
`{
|
||||
// This is a comment
|
||||
"name": "jsonc-agent",
|
||||
"description": "JSONC agent", // inline comment
|
||||
"prompt": "JSONC prompt."
|
||||
}`,
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
const result = loadAgentDefinitions([jsoncPath], "definition-file")
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(result["jsonc-agent"]).toBeDefined()
|
||||
expect(result["jsonc-agent"].prompt).toBe("JSONC prompt.")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { basename, extname } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { log } from "../../shared/logger"
|
||||
import { parseToolsConfig } from "../../shared/parse-tools-config"
|
||||
import { parseJsonAgentFile } from "./json-agent-loader"
|
||||
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
|
||||
import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
|
||||
|
||||
export function parseMarkdownAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
|
||||
|
||||
const fileName = basename(filePath)
|
||||
const agentName = fileName.replace(/\.md$/i, "")
|
||||
const name = data.name || agentName
|
||||
const originalDescription = data.description || ""
|
||||
|
||||
const formattedDescription = `(${scope}) ${originalDescription}`
|
||||
|
||||
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
|
||||
const modelString = mappedModelOverride
|
||||
? `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}`
|
||||
: undefined
|
||||
|
||||
const config: ClaudeCodeAgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: data.mode || "subagent",
|
||||
prompt: body.trim(),
|
||||
...(modelString ? { model: modelString } : {}),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
if (toolsConfig) {
|
||||
config.tools = toolsConfig
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
path: filePath,
|
||||
config,
|
||||
scope,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAgentDefinitions(
|
||||
paths: string[],
|
||||
scope: AgentScope
|
||||
): Record<string, ClaudeCodeAgentConfig> {
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (!existsSync(filePath)) {
|
||||
log(`[agent-definitions-loader] File not found, skipping: ${filePath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
let agent: LoadedAgent | null = null
|
||||
|
||||
if (ext === ".md") {
|
||||
agent = parseMarkdownAgentFile(filePath, scope)
|
||||
} else if (ext === ".json" || ext === ".jsonc") {
|
||||
agent = parseJsonAgentFile(filePath, scope)
|
||||
} else {
|
||||
log(`[agent-definitions-loader] Unsupported file extension: ${ext} for ${filePath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
log(`[agent-definitions-loader] Failed to parse agent file: ${filePath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, test, expect, afterEach } from "bun:test"
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { parseJsonAgentFile } from "./json-agent-loader"
|
||||
|
||||
describe("json-agent-loader", () => {
|
||||
const dirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
dirs.length = 0
|
||||
})
|
||||
|
||||
function trackDir(dir: string): string {
|
||||
dirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
test("parses valid JSON agent file", () => {
|
||||
const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-")))
|
||||
const filePath = join(dir, "agent.json")
|
||||
|
||||
writeFileSync(filePath, JSON.stringify({
|
||||
name: "test-agent",
|
||||
description: "A test agent",
|
||||
prompt: "You are a test agent.",
|
||||
tools: ["Bash", "Read"],
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
mode: "subagent",
|
||||
}), "utf-8")
|
||||
|
||||
const result = parseJsonAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.name).toBe("test-agent")
|
||||
expect(result?.path).toBe(filePath)
|
||||
expect(result?.scope).toBe("definition-file")
|
||||
expect(result?.config.description).toBe("(definition-file) A test agent")
|
||||
expect(result?.config.prompt).toBe("You are a test agent.")
|
||||
expect(result?.config.mode).toBe("subagent")
|
||||
expect(result?.config.tools).toEqual({ bash: true, read: true })
|
||||
})
|
||||
|
||||
test("parses JSONC with comments", () => {
|
||||
const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-")))
|
||||
const filePath = join(dir, "agent.jsonc")
|
||||
|
||||
writeFileSync(filePath, `{
|
||||
// Agent name
|
||||
"name": "commented-agent",
|
||||
"description": "Agent with comments",
|
||||
"prompt": "Do something.",
|
||||
"tools": ["Bash"], // Tools for the agent
|
||||
// Model specification
|
||||
"model": "claude-3-5-sonnet-20241022"
|
||||
}`, "utf-8")
|
||||
|
||||
const result = parseJsonAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.name).toBe("commented-agent")
|
||||
expect(result?.config.tools).toEqual({ bash: true })
|
||||
})
|
||||
|
||||
test("returns null when required fields are missing (name)", () => {
|
||||
const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-")))
|
||||
const filePath = join(dir, "agent.json")
|
||||
|
||||
writeFileSync(filePath, JSON.stringify({
|
||||
description: "Missing name",
|
||||
prompt: "You are an agent.",
|
||||
}), "utf-8")
|
||||
|
||||
const result = parseJsonAgentFile(filePath, "definition-file")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null when required fields are missing (prompt)", () => {
|
||||
const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-")))
|
||||
const filePath = join(dir, "agent.json")
|
||||
|
||||
writeFileSync(filePath, JSON.stringify({
|
||||
name: "missing-prompt",
|
||||
description: "Missing prompt",
|
||||
}), "utf-8")
|
||||
|
||||
const result = parseJsonAgentFile(filePath, "definition-file")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("defaults optional fields correctly", () => {
|
||||
const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-")))
|
||||
const filePath = join(dir, "agent.json")
|
||||
|
||||
writeFileSync(filePath, JSON.stringify({
|
||||
name: "minimal-agent",
|
||||
prompt: "You are minimal.",
|
||||
}), "utf-8")
|
||||
|
||||
const result = parseJsonAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.config.description).toBe("(definition-file) ")
|
||||
expect(result?.config.mode).toBe("subagent")
|
||||
expect(result?.config.tools).toBeUndefined()
|
||||
expect(result?.config.model).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles tools as string comma-separated list", () => {
|
||||
const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-")))
|
||||
const filePath = join(dir, "agent.json")
|
||||
|
||||
writeFileSync(filePath, JSON.stringify({
|
||||
name: "string-tools-agent",
|
||||
prompt: "You are an agent.",
|
||||
tools: "Bash, Read, Grep",
|
||||
}), "utf-8")
|
||||
|
||||
const result = parseJsonAgentFile(filePath, "definition-file")
|
||||
|
||||
expect(result?.config.tools).toEqual({ bash: true, read: true, grep: true })
|
||||
})
|
||||
|
||||
test("returns null for malformed JSON", () => {
|
||||
const dir = trackDir(mkdtempSync(join(tmpdir(), "json-agent-loader-test-")))
|
||||
const filePath = join(dir, "agent.json")
|
||||
|
||||
writeFileSync(filePath, `{
|
||||
"name": "broken",
|
||||
"prompt": "incomplete json`,
|
||||
"utf-8")
|
||||
|
||||
const result = parseJsonAgentFile(filePath, "definition-file")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { parseJsoncSafe } from "../../shared/jsonc-parser"
|
||||
import { parseToolsConfig } from "../../shared/parse-tools-config"
|
||||
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
|
||||
import type { AgentScope, AgentJsonDefinition, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
|
||||
|
||||
export function parseJsonAgentFile(filePath: string, scope: AgentScope): LoadedAgent | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const { data } = parseJsoncSafe<AgentJsonDefinition>(content)
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!data.name || !data.prompt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const originalDescription = data.description ?? ""
|
||||
const formattedDescription = `(${scope}) ${originalDescription}`
|
||||
|
||||
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
|
||||
const modelString = mappedModelOverride
|
||||
? `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}`
|
||||
: undefined
|
||||
|
||||
const config: ClaudeCodeAgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: data.mode ?? "subagent",
|
||||
prompt: data.prompt.trim(),
|
||||
...(modelString ? { model: modelString } : {}),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
if (toolsConfig) {
|
||||
config.tools = toolsConfig
|
||||
}
|
||||
|
||||
return {
|
||||
name: data.name,
|
||||
path: filePath,
|
||||
config,
|
||||
scope,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
import {
|
||||
loadUserAgents,
|
||||
loadProjectAgents,
|
||||
loadOpencodeGlobalAgents,
|
||||
loadOpencodeProjectAgents,
|
||||
} from "./loader";
|
||||
|
||||
/**
|
||||
* Creates a temporary directory tree for testing agent loading.
|
||||
* Returns the root dir with `.claude/agents/` and `.opencode/agents/` subdirs
|
||||
* pre-created, containing the specified agent files.
|
||||
*/
|
||||
function createProjectWithAgents(
|
||||
agents: {
|
||||
claudeAgents?: Array<{ filename: string; content: string }>;
|
||||
opencodeAgents?: Array<{ filename: string; content: string }>;
|
||||
} = {},
|
||||
): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "agent-loader-test-"));
|
||||
if (agents.claudeAgents) {
|
||||
const dir = join(root, ".claude", "agents");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const { filename, content } of agents.claudeAgents) {
|
||||
writeFileSync(join(dir, filename), content, "utf-8");
|
||||
}
|
||||
}
|
||||
if (agents.opencodeAgents) {
|
||||
const dir = join(root, ".opencode", "agents");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const { filename, content } of agents.opencodeAgents) {
|
||||
writeFileSync(join(dir, filename), content, "utf-8");
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
const BASIC_AGENT = `---
|
||||
name: test-agent
|
||||
description: A test agent
|
||||
tools: Bash,Read
|
||||
---
|
||||
You are a test agent.`;
|
||||
|
||||
const MINIMAL_AGENT = `---
|
||||
description: Minimal agent
|
||||
---
|
||||
Do minimal things.`;
|
||||
|
||||
const NO_FRONTMATTER_AGENT = `Just a prompt with no frontmatter.`;
|
||||
|
||||
describe("claude-code-agent-loader", () => {
|
||||
const dirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
function trackDir(dir: string): string {
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("loadProjectAgents", () => {
|
||||
test("loads agents from <directory>/.claude/agents", () => {
|
||||
const root = trackDir(
|
||||
createProjectWithAgents({
|
||||
claudeAgents: [{ filename: "my-agent.md", content: BASIC_AGENT }],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadProjectAgents(root);
|
||||
|
||||
expect(Object.keys(result)).toEqual(["test-agent"]);
|
||||
expect(result["test-agent"].description).toBe("(project) A test agent");
|
||||
expect(result["test-agent"].mode).toBe("subagent");
|
||||
expect(result["test-agent"].prompt).toBe("You are a test agent.");
|
||||
expect(result["test-agent"].tools).toEqual({ bash: true, read: true });
|
||||
});
|
||||
|
||||
test("uses filename as agent name when frontmatter name is absent", () => {
|
||||
const root = trackDir(
|
||||
createProjectWithAgents({
|
||||
claudeAgents: [
|
||||
{ filename: "fallback-name.md", content: MINIMAL_AGENT },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadProjectAgents(root);
|
||||
|
||||
expect(Object.keys(result)).toEqual(["fallback-name"]);
|
||||
expect(result["fallback-name"].description).toBe(
|
||||
"(project) Minimal agent",
|
||||
);
|
||||
});
|
||||
|
||||
test("handles agent with no frontmatter", () => {
|
||||
const root = trackDir(
|
||||
createProjectWithAgents({
|
||||
claudeAgents: [{ filename: "raw.md", content: NO_FRONTMATTER_AGENT }],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadProjectAgents(root);
|
||||
|
||||
expect(Object.keys(result)).toEqual(["raw"]);
|
||||
expect(result["raw"].prompt).toBe("Just a prompt with no frontmatter.");
|
||||
});
|
||||
|
||||
test("returns empty object when project has no .claude/agents directory", () => {
|
||||
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-")));
|
||||
|
||||
const result = loadProjectAgents(root);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("ignores non-markdown files", () => {
|
||||
const root = trackDir(
|
||||
createProjectWithAgents({
|
||||
claudeAgents: [
|
||||
{ filename: "good.md", content: BASIC_AGENT },
|
||||
{ filename: "bad.txt", content: "not a markdown file" },
|
||||
{ filename: "also-bad.json", content: "{}" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadProjectAgents(root);
|
||||
|
||||
expect(Object.keys(result)).toEqual(["test-agent"]);
|
||||
});
|
||||
|
||||
test("loads multiple agents", () => {
|
||||
const root = trackDir(
|
||||
createProjectWithAgents({
|
||||
claudeAgents: [
|
||||
{ filename: "agent-a.md", content: BASIC_AGENT },
|
||||
{
|
||||
filename: "agent-b.md",
|
||||
content: `---\nname: second-agent\ndescription: Another agent\n---\nDo other things.`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadProjectAgents(root);
|
||||
|
||||
expect(Object.keys(result).sort()).toEqual([
|
||||
"second-agent",
|
||||
"test-agent",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadOpencodeProjectAgents", () => {
|
||||
test("loads agents from <directory>/.opencode/agents", () => {
|
||||
const root = trackDir(
|
||||
createProjectWithAgents({
|
||||
opencodeAgents: [{ filename: "oc-agent.md", content: BASIC_AGENT }],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadOpencodeProjectAgents(root);
|
||||
|
||||
expect(Object.keys(result)).toEqual(["test-agent"]);
|
||||
expect(result["test-agent"].description).toBe(
|
||||
"(opencode-project) A test agent",
|
||||
);
|
||||
expect(result["test-agent"].mode).toBe("subagent");
|
||||
expect(result["test-agent"].prompt).toBe("You are a test agent.");
|
||||
});
|
||||
|
||||
test("returns empty object when project has no .opencode/agents directory", () => {
|
||||
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-")));
|
||||
|
||||
const result = loadOpencodeProjectAgents(root);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadUserAgents", () => {
|
||||
test("returns empty object when pointed at dir without agents/", () => {
|
||||
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-")))
|
||||
// Temporarily set env var — best-effort in parallel test runner
|
||||
const prev = process.env.CLAUDE_CONFIG_DIR
|
||||
try {
|
||||
process.env.CLAUDE_CONFIG_DIR = root
|
||||
const result = loadUserAgents()
|
||||
expect(result).toEqual({})
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev
|
||||
else delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadOpencodeGlobalAgents", () => {
|
||||
test("returns empty object when pointed at dir without agents/", () => {
|
||||
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-test-")))
|
||||
const prev = process.env.OPENCODE_CONFIG_DIR
|
||||
try {
|
||||
process.env.OPENCODE_CONFIG_DIR = root
|
||||
const result = loadOpencodeGlobalAgents()
|
||||
expect(result).toEqual({})
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.OPENCODE_CONFIG_DIR = prev
|
||||
else delete process.env.OPENCODE_CONFIG_DIR
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("tools parsing", () => {
|
||||
test("parses comma-separated tools into boolean record", () => {
|
||||
const agentWithTools = `---\nname: tooled\ndescription: Has tools\ntools: Bash,Read,Edit\n---\nDo things.`;
|
||||
const root = trackDir(
|
||||
createProjectWithAgents({
|
||||
claudeAgents: [{ filename: "tooled.md", content: agentWithTools }],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadProjectAgents(root);
|
||||
|
||||
expect(result["tooled"].tools).toEqual({
|
||||
bash: true,
|
||||
read: true,
|
||||
edit: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("omits tools when frontmatter tools field is absent", () => {
|
||||
const agentNoTools = `---\nname: no-tools\ndescription: No tools\n---\nDo things.`;
|
||||
const root = trackDir(
|
||||
createProjectWithAgents({
|
||||
claudeAgents: [{ filename: "no-tools.md", content: agentNoTools }],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadProjectAgents(root);
|
||||
|
||||
expect(result["no-tools"].tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope labeling", () => {
|
||||
test("project and opencode-project loaders apply correct scope prefixes", () => {
|
||||
const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-scope-")))
|
||||
const content = `---\nname: scoped\ndescription: Scoped agent\n---\nPrompt.`
|
||||
|
||||
const claudeProjectDir = join(root, "project", ".claude", "agents")
|
||||
const ocProjectDir = join(root, "project", ".opencode", "agents")
|
||||
|
||||
mkdirSync(claudeProjectDir, { recursive: true })
|
||||
mkdirSync(ocProjectDir, { recursive: true })
|
||||
|
||||
writeFileSync(join(claudeProjectDir, "a.md"), content, "utf-8")
|
||||
writeFileSync(join(ocProjectDir, "a.md"), content, "utf-8")
|
||||
|
||||
const project = loadProjectAgents(join(root, "project"))
|
||||
const ocProject = loadOpencodeProjectAgents(join(root, "project"))
|
||||
|
||||
expect(project["scoped"].description).toBe("(project) Scoped agent")
|
||||
expect(ocProject["scoped"].description).toBe("(opencode-project) Scoped agent")
|
||||
})
|
||||
})
|
||||
});
|
||||
@@ -1,23 +1,10 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { existsSync, readdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
|
||||
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
|
||||
|
||||
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
|
||||
if (!toolsStr) return undefined
|
||||
|
||||
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
if (tools.length === 0) return undefined
|
||||
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const tool of tools) {
|
||||
result[tool.toLowerCase()] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
import type { AgentScope, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
|
||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||
import { parseMarkdownAgentFile } from "./agent-definitions-loader"
|
||||
|
||||
function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] {
|
||||
if (!existsSync(agentsDir)) {
|
||||
@@ -31,42 +18,10 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const agentPath = join(agentsDir, entry.name)
|
||||
const agentName = basename(entry.name, ".md")
|
||||
const agent = parseMarkdownAgentFile(agentPath, scope)
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
|
||||
|
||||
const name = data.name || agentName
|
||||
const originalDescription = data.description || ""
|
||||
|
||||
const formattedDescription = `(${scope}) ${originalDescription}`
|
||||
|
||||
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
|
||||
const modelString = mappedModelOverride
|
||||
? `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}`
|
||||
: undefined
|
||||
|
||||
const config: ClaudeCodeAgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: data.mode || "subagent",
|
||||
prompt: body.trim(),
|
||||
...(modelString ? { model: modelString } : {}),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
if (toolsConfig) {
|
||||
config.tools = toolsConfig
|
||||
}
|
||||
|
||||
agents.push({
|
||||
name,
|
||||
path: agentPath,
|
||||
config,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
if (agent) {
|
||||
agents.push(agent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +32,7 @@ export function loadUserAgents(): Record<string, ClaudeCodeAgentConfig> {
|
||||
const userAgentsDir = join(getClaudeConfigDir(), "agents")
|
||||
const agents = loadAgentsFromDir(userAgentsDir, "user")
|
||||
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = {}
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
@@ -88,7 +43,30 @@ export function loadProjectAgents(directory?: string): Record<string, ClaudeCode
|
||||
const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents")
|
||||
const agents = loadAgentsFromDir(projectAgentsDir, "project")
|
||||
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = {}
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadOpencodeGlobalAgents(): Record<string, ClaudeCodeAgentConfig> {
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const opencodeAgentsDir = join(configDir, "agents")
|
||||
const agents = loadAgentsFromDir(opencodeAgentsDir, "opencode")
|
||||
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadOpencodeProjectAgents(directory?: string): Record<string, ClaudeCodeAgentConfig> {
|
||||
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "agents")
|
||||
const agents = loadAgentsFromDir(opencodeProjectDir, "opencode-project")
|
||||
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
|
||||
import * as configDir from "../../shared/opencode-config-dir"
|
||||
import { readOpencodeConfigAgents } from "./opencode-config-agents-reader"
|
||||
|
||||
describe("readOpencodeConfigAgents", () => {
|
||||
let mockGlobalConfigDir = ""
|
||||
let configDirSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobalConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-mock-global-"))
|
||||
configDirSpy = spyOn(configDir, "getOpenCodeConfigDir").mockReturnValue(mockGlobalConfigDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
configDirSpy.mockRestore()
|
||||
fs.rmSync(mockGlobalConfigDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("returns empty record when no opencode.json exists", () => {
|
||||
const nonexistentDir = "/nonexistent/directory/path"
|
||||
const result = readOpencodeConfigAgents(nonexistentDir)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it("reads inline agents from opencode.json", () => {
|
||||
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({
|
||||
agents: {
|
||||
"my-agent": {
|
||||
description: "Custom agent",
|
||||
model: "claude-opus-4-6",
|
||||
mode: "subagent",
|
||||
prompt: "You are a helpful assistant",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const result = readOpencodeConfigAgents(tempDir)
|
||||
|
||||
expect(result).toHaveProperty("my-agent")
|
||||
expect(result["my-agent"].description).toBe("(opencode-config) Custom agent")
|
||||
expect(result["my-agent"].mode).toBe("subagent")
|
||||
expect(result["my-agent"].prompt).toBe("You are a helpful assistant")
|
||||
expect(result["my-agent"].model).toBeDefined()
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
})
|
||||
|
||||
it("reads agents from opencode.jsonc with comments", () => {
|
||||
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.jsonc")
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
// Define agents
|
||||
"agents": {
|
||||
"test-agent": {
|
||||
"description": "Test agent",
|
||||
"prompt": "Test prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
const result = readOpencodeConfigAgents(tempDir)
|
||||
|
||||
expect(Object.keys(result).length).toBeGreaterThan(0)
|
||||
expect(result).toHaveProperty("test-agent")
|
||||
expect(result["test-agent"].description).toBe("(opencode-config) Test agent")
|
||||
expect(result["test-agent"].prompt).toBe("Test prompt")
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
})
|
||||
|
||||
it("handles malformed opencode.json gracefully", () => {
|
||||
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, "{ invalid json ")
|
||||
|
||||
const result = readOpencodeConfigAgents(tempDir)
|
||||
expect(result).toEqual({})
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
})
|
||||
|
||||
it("maps Claude model names correctly", () => {
|
||||
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({
|
||||
agents: {
|
||||
"sonnet-agent": {
|
||||
description: "Sonnet",
|
||||
model: "sonnet",
|
||||
prompt: "test",
|
||||
},
|
||||
"opus-agent": {
|
||||
description: "Opus",
|
||||
model: "opus",
|
||||
prompt: "test",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const result = readOpencodeConfigAgents(tempDir)
|
||||
|
||||
expect(result["sonnet-agent"].model).toBeDefined()
|
||||
expect(result["opus-agent"].model).toBeDefined()
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
})
|
||||
|
||||
it("handles agent_definitions file paths", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-"))
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
|
||||
const agentDefFile = path.join(opencodeDir, "agents.json")
|
||||
fs.writeFileSync(
|
||||
agentDefFile,
|
||||
JSON.stringify({
|
||||
name: "definition-agent",
|
||||
description: "From definition file",
|
||||
prompt: "File-based agent prompt",
|
||||
})
|
||||
)
|
||||
|
||||
const configPath = path.join(opencodeDir, "opencode.json")
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
agent_definitions: ["./agents.json"],
|
||||
})
|
||||
)
|
||||
|
||||
const result = readOpencodeConfigAgents(tempDir)
|
||||
|
||||
if (Object.keys(result).length > 0) {
|
||||
expect(result).toHaveProperty("definition-agent")
|
||||
expect(result["definition-agent"].description).toContain("From definition file")
|
||||
}
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
})
|
||||
|
||||
it("merges inline and definition agents, with inline taking precedence", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-"))
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
|
||||
const agentDefFile = path.join(opencodeDir, "agents.json")
|
||||
fs.writeFileSync(
|
||||
agentDefFile,
|
||||
JSON.stringify({
|
||||
name: "shared-agent",
|
||||
description: "From definition file",
|
||||
prompt: "Definition prompt",
|
||||
})
|
||||
)
|
||||
|
||||
const configPath = path.join(opencodeDir, "opencode.json")
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
"shared-agent": {
|
||||
description: "From inline",
|
||||
prompt: "Inline prompt",
|
||||
},
|
||||
},
|
||||
agent_definitions: ["./agents.json"],
|
||||
})
|
||||
)
|
||||
|
||||
const result = readOpencodeConfigAgents(tempDir)
|
||||
|
||||
expect(result["shared-agent"].description).toBe("(opencode-config) From inline")
|
||||
expect(result["shared-agent"].prompt).toBe("Inline prompt")
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
})
|
||||
|
||||
it("parses tools as both string and array formats", () => {
|
||||
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({
|
||||
agents: {
|
||||
"string-tools": {
|
||||
description: "Tools as string",
|
||||
tools: "tool1, tool2, tool3",
|
||||
prompt: "test",
|
||||
},
|
||||
"array-tools": {
|
||||
description: "Tools as array",
|
||||
tools: ["bash", "read"],
|
||||
prompt: "test",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const result = readOpencodeConfigAgents(tempDir)
|
||||
|
||||
expect(result["string-tools"].tools).toEqual({
|
||||
tool1: true,
|
||||
tool2: true,
|
||||
tool3: true,
|
||||
})
|
||||
|
||||
expect(result["array-tools"].tools).toEqual({
|
||||
bash: true,
|
||||
read: true,
|
||||
})
|
||||
|
||||
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")
|
||||
fs.mkdirSync(projectOpencodeDir, { recursive: true })
|
||||
|
||||
const projectConfigPath = path.join(projectOpencodeDir, "opencode.json")
|
||||
fs.writeFileSync(
|
||||
projectConfigPath,
|
||||
JSON.stringify({
|
||||
agents: {
|
||||
"project-agent": {
|
||||
description: "From project",
|
||||
prompt: "Project prompt",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const result = readOpencodeConfigAgents(projectDir)
|
||||
|
||||
expect(result).toHaveProperty("project-agent")
|
||||
expect(result["project-agent"].description).toBe("(opencode-config) From project")
|
||||
|
||||
fs.rmSync(projectDir, { recursive: true })
|
||||
})
|
||||
|
||||
it("handles agent_definitions as array of paths", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-test-"))
|
||||
const opencodeDir = path.join(tempDir, ".opencode")
|
||||
fs.mkdirSync(opencodeDir, { recursive: true })
|
||||
|
||||
const agentDef1 = path.join(opencodeDir, "agents1.json")
|
||||
fs.writeFileSync(
|
||||
agentDef1,
|
||||
JSON.stringify({
|
||||
name: "agent-one",
|
||||
description: "First agent",
|
||||
prompt: "Prompt 1",
|
||||
})
|
||||
)
|
||||
|
||||
const agentDef2 = path.join(opencodeDir, "agents2.json")
|
||||
fs.writeFileSync(
|
||||
agentDef2,
|
||||
JSON.stringify({
|
||||
name: "agent-two",
|
||||
description: "Second agent",
|
||||
prompt: "Prompt 2",
|
||||
})
|
||||
)
|
||||
|
||||
const configPath = path.join(opencodeDir, "opencode.json")
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
agent_definitions: ["./agents1.json", "./agents2.json"],
|
||||
})
|
||||
)
|
||||
|
||||
const result = readOpencodeConfigAgents(tempDir)
|
||||
|
||||
if (Object.keys(result).length >= 2) {
|
||||
expect(result).toHaveProperty("agent-one")
|
||||
expect(result).toHaveProperty("agent-two")
|
||||
}
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
|
||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||
import { parseJsoncSafe } from "../../shared/jsonc-parser"
|
||||
import { parseToolsConfig } from "../../shared/parse-tools-config"
|
||||
import { resolveAgentDefinitionPaths } from "../../shared/resolve-agent-definition-paths"
|
||||
import { loadAgentDefinitions } from "./agent-definitions-loader"
|
||||
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
|
||||
import type { ClaudeCodeAgentConfig } from "./types"
|
||||
|
||||
interface OpencodeConfigWithAgents {
|
||||
agents?: Record<string, unknown>
|
||||
agent?: Record<string, unknown>
|
||||
agent_definitions?: string | string[]
|
||||
}
|
||||
|
||||
function getConfigPaths(directory: string): string[] {
|
||||
const globalConfigDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const paths = [
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
path.join(globalConfigDir, "opencode.json"),
|
||||
path.join(globalConfigDir, "opencode.jsonc"),
|
||||
]
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function convertInlineAgent(agentData: unknown): ClaudeCodeAgentConfig | null {
|
||||
if (!agentData || typeof agentData !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const agent = agentData as Record<string, unknown>
|
||||
|
||||
const description = agent.description ? `(opencode-config) ${String(agent.description)}` : "(opencode-config) "
|
||||
|
||||
const mappedModel = mapClaudeModelToOpenCode(
|
||||
agent.model ? String(agent.model) : undefined
|
||||
)
|
||||
const modelString = mappedModel
|
||||
? `${mappedModel.providerID}/${mappedModel.modelID}`
|
||||
: undefined
|
||||
|
||||
const VALID_MODES = ["subagent", "primary", "all"] as const
|
||||
const rawMode = typeof agent.mode === "string" ? agent.mode : undefined
|
||||
const mode = rawMode && (VALID_MODES as readonly string[]).includes(rawMode)
|
||||
? (rawMode as "subagent" | "primary" | "all")
|
||||
: "subagent"
|
||||
|
||||
const config: ClaudeCodeAgentConfig = {
|
||||
description,
|
||||
mode,
|
||||
prompt: agent.prompt ? String(agent.prompt) : "",
|
||||
...(modelString ? { model: modelString } : {}),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(agent.tools)
|
||||
if (toolsConfig) {
|
||||
config.tools = toolsConfig
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function readOpencodeConfigAgents(directory: string): Record<string, ClaudeCodeAgentConfig> {
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = Object.create(null)
|
||||
|
||||
for (const configPath of getConfigPaths(directory)) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const parseResult = parseJsoncSafe<OpencodeConfigWithAgents>(content)
|
||||
|
||||
if (!parseResult.data) continue
|
||||
|
||||
const configDir = path.dirname(configPath)
|
||||
|
||||
const agentsToLoad = parseResult.data.agents || parseResult.data.agent
|
||||
|
||||
if (agentsToLoad && typeof agentsToLoad === "object") {
|
||||
for (const [agentName, agentData] of Object.entries(agentsToLoad)) {
|
||||
if (Object.hasOwn(result, agentName)) continue
|
||||
const converted = convertInlineAgent(agentData)
|
||||
if (converted) {
|
||||
result[agentName] = converted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parseResult.data.agent_definitions) {
|
||||
const definitionPaths = extractDefinitionPaths(parseResult.data.agent_definitions)
|
||||
const resolvedPaths = resolveAgentDefinitionPaths(definitionPaths, configDir, directory)
|
||||
|
||||
const definitionAgents = loadAgentDefinitions(resolvedPaths, "opencode-config")
|
||||
|
||||
for (const [name, config] of Object.entries(definitionAgents)) {
|
||||
if (!Object.hasOwn(result, name)) {
|
||||
result[name] = config
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function extractDefinitionPaths(definitionPaths: unknown): string[] {
|
||||
if (typeof definitionPaths === "string") {
|
||||
return [definitionPaths]
|
||||
}
|
||||
|
||||
if (Array.isArray(definitionPaths)) {
|
||||
return definitionPaths
|
||||
.filter((p) => typeof p === "string")
|
||||
.map((p) => p as string)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export type AgentScope = "user" | "project"
|
||||
export type AgentScope = "user" | "project" | "opencode" | "opencode-project" | "definition-file" | "opencode-config"
|
||||
|
||||
export type ClaudeCodeAgentConfig = Omit<AgentConfig, "model"> & {
|
||||
model?: string | { providerID: string; modelID: string }
|
||||
@@ -14,6 +14,15 @@ export interface AgentFrontmatter {
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
}
|
||||
|
||||
export interface AgentJsonDefinition {
|
||||
name: string
|
||||
description?: string
|
||||
model?: string
|
||||
tools?: string | string[]
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export interface LoadedAgent {
|
||||
name: string
|
||||
path: string
|
||||
|
||||
@@ -3,27 +3,11 @@ import { basename, join } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { log } from "../../shared/logger"
|
||||
import { parseToolsConfig } from "../../shared/parse-tools-config"
|
||||
import type { AgentFrontmatter, ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
|
||||
import { mapClaudeModelToOpenCode } from "../claude-code-agent-loader/claude-model-mapper"
|
||||
import type { LoadedPlugin } from "./types"
|
||||
|
||||
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
|
||||
if (!toolsStr) return undefined
|
||||
|
||||
const tools = toolsStr
|
||||
.split(",")
|
||||
.map((tool) => tool.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (tools.length === 0) return undefined
|
||||
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const tool of tools) {
|
||||
result[tool.toLowerCase()] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, ClaudeCodeAgentConfig> {
|
||||
const agents: Record<string, ClaudeCodeAgentConfig> = {}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseJsonc,
|
||||
detectPluginConfigFile,
|
||||
migrateConfigFile,
|
||||
resolveAgentDefinitionPaths,
|
||||
} from "./shared";
|
||||
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
|
||||
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
|
||||
@@ -41,6 +42,7 @@ const PARTIAL_STRING_ARRAY_KEYS = new Set([
|
||||
"disabled_commands",
|
||||
"disabled_tools",
|
||||
"mcp_env_allowlist",
|
||||
"agent_definitions",
|
||||
]);
|
||||
|
||||
export function parseConfigPartially(
|
||||
@@ -139,6 +141,12 @@ export function mergeConfigs(
|
||||
...override,
|
||||
agents: deepMerge(base.agents, override.agents),
|
||||
categories: deepMerge(base.categories, override.categories),
|
||||
agent_definitions: [
|
||||
...new Set([
|
||||
...(base.agent_definitions ?? []),
|
||||
...(override.agent_definitions ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_agents: [
|
||||
...new Set([
|
||||
...(base.disabled_agents ?? []),
|
||||
@@ -250,6 +258,15 @@ export function loadPluginConfig(
|
||||
// Load user config first (base). Parse empty config through Zod to apply field defaults.
|
||||
const userConfig = loadConfigFromPath(userConfigPath, ctx)
|
||||
const userGitMasterOverrides = loadExplicitGitMasterOverrides(userConfigPath)
|
||||
|
||||
if (userConfig?.agent_definitions) {
|
||||
userConfig.agent_definitions = resolveAgentDefinitionPaths(
|
||||
userConfig.agent_definitions,
|
||||
configDir,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
let config: OhMyOpenCodeConfig =
|
||||
userConfig ?? OhMyOpenCodeConfigSchema.parse({});
|
||||
|
||||
@@ -257,6 +274,15 @@ export function loadPluginConfig(
|
||||
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||
const projectGitMasterOverrides = loadExplicitGitMasterOverrides(projectConfigPath)
|
||||
|
||||
if (projectConfig?.agent_definitions) {
|
||||
projectConfig.agent_definitions = resolveAgentDefinitionPaths(
|
||||
projectConfig.agent_definitions,
|
||||
projectBasePath,
|
||||
directory
|
||||
)
|
||||
}
|
||||
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ describe("applyAgentConfig builtin override protection", () => {
|
||||
let discoverGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>
|
||||
let loadUserAgentsSpy: ReturnType<typeof spyOn>
|
||||
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
|
||||
let loadAgentDefinitionsSpy: ReturnType<typeof spyOn>
|
||||
let readOpencodeConfigAgentsSpy: ReturnType<typeof spyOn>
|
||||
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
|
||||
@@ -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<string, unknown>) => 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<string, unknown>).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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
discoverProjectClaudeSkills,
|
||||
discoverUserClaudeSkills,
|
||||
} from "../features/opencode-skill-loader";
|
||||
import { loadProjectAgents, loadUserAgents } 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<string, Record<string, unknown> | undefined> & {
|
||||
function getConfiguredDefaultAgent(config: Record<string, unknown>): string | undefined {
|
||||
const defaultAgent = config.default_agent;
|
||||
if (typeof defaultAgent !== "string") return undefined;
|
||||
|
||||
const trimmedDefaultAgent = defaultAgent.trim();
|
||||
return trimmedDefaultAgent.length > 0 ? trimmedDefaultAgent : undefined;
|
||||
}
|
||||
@@ -96,8 +102,15 @@ export async function applyAgentConfig(params: {
|
||||
const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true;
|
||||
const userAgents = includeClaudeAgents ? loadUserAgents() : {};
|
||||
const projectAgents = includeClaudeAgents ? loadProjectAgents(params.ctx.directory) : {};
|
||||
const opencodeGlobalAgents = loadOpencodeGlobalAgents();
|
||||
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];
|
||||
@@ -113,7 +126,11 @@ export async function applyAgentConfig(params: {
|
||||
...Object.entries(configAgent ?? {}),
|
||||
...Object.entries(userAgents),
|
||||
...Object.entries(projectAgents),
|
||||
...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]) => ({
|
||||
@@ -123,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,
|
||||
@@ -257,6 +288,22 @@ export async function applyAgentConfig(params: {
|
||||
pluginAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
const filteredOpencodeGlobalAgents = filterProtectedAgentOverrides(
|
||||
opencodeGlobalAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
const filteredOpencodeProjectAgents = filterProtectedAgentOverrides(
|
||||
opencodeProjectAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
const filteredAgentDefinitionAgents = filterProtectedAgentOverrides(
|
||||
agentDefinitionAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
const filteredOpencodeConfigAgents = filterProtectedAgentOverrides(
|
||||
opencodeConfigAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
|
||||
params.config.agent = {
|
||||
...agentConfig,
|
||||
@@ -265,9 +312,14 @@ export async function applyAgentConfig(params: {
|
||||
([key]) => key !== "sisyphus" && key !== "hephaestus" && key !== "atlas",
|
||||
),
|
||||
),
|
||||
...filterDisabledAgents(filteredUserAgents),
|
||||
...filterDisabledAgents(filteredProjectAgents),
|
||||
// Precedence: later entries override earlier (project > global > user > plugin)
|
||||
...filterDisabledAgents(filteredPluginAgents),
|
||||
...filterDisabledAgents(filteredUserAgents),
|
||||
...filterDisabledAgents(filteredOpencodeGlobalAgents),
|
||||
...filterDisabledAgents(filteredProjectAgents),
|
||||
...filterDisabledAgents(filteredOpencodeProjectAgents),
|
||||
...filterDisabledAgents(filteredAgentDefinitionAgents),
|
||||
...filterDisabledAgents(filteredOpencodeConfigAgents),
|
||||
...filteredConfigAgents,
|
||||
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
||||
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
||||
@@ -288,6 +340,22 @@ export async function applyAgentConfig(params: {
|
||||
pluginAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
const filteredOpencodeGlobalAgents = filterProtectedAgentOverrides(
|
||||
opencodeGlobalAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
const filteredOpencodeProjectAgents = filterProtectedAgentOverrides(
|
||||
opencodeProjectAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
const filteredAgentDefinitionAgents = filterProtectedAgentOverrides(
|
||||
agentDefinitionAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
const filteredOpencodeConfigAgents = filterProtectedAgentOverrides(
|
||||
opencodeConfigAgents,
|
||||
protectedBuiltinAgentNames,
|
||||
);
|
||||
|
||||
const defaultedConfigAgents = configAgent
|
||||
? Object.fromEntries(
|
||||
@@ -302,9 +370,14 @@ export async function applyAgentConfig(params: {
|
||||
|
||||
params.config.agent = {
|
||||
...builtinAgents,
|
||||
...filterDisabledAgents(filteredUserAgents),
|
||||
...filterDisabledAgents(filteredProjectAgents),
|
||||
// Precedence: later entries override earlier (project > global > user > plugin)
|
||||
...filterDisabledAgents(filteredPluginAgents),
|
||||
...filterDisabledAgents(filteredUserAgents),
|
||||
...filterDisabledAgents(filteredOpencodeGlobalAgents),
|
||||
...filterDisabledAgents(filteredProjectAgents),
|
||||
...filterDisabledAgents(filteredOpencodeProjectAgents),
|
||||
...filterDisabledAgents(filteredAgentDefinitionAgents),
|
||||
...filterDisabledAgents(filteredOpencodeConfigAgents),
|
||||
...defaultedConfigAgents,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ beforeEach(async () => {
|
||||
|
||||
spyOn(agentLoader, "loadUserAgents" as any).mockReturnValue({})
|
||||
spyOn(agentLoader, "loadProjectAgents" as any).mockReturnValue({})
|
||||
spyOn(agentLoader, "loadOpencodeGlobalAgents" as any).mockReturnValue({})
|
||||
spyOn(agentLoader, "loadOpencodeProjectAgents" as any).mockReturnValue({})
|
||||
|
||||
spyOn(mcpLoader, "loadMcpConfigs" as any).mockResolvedValue({ servers: {} })
|
||||
setAdditionalAllowedMcpEnvVarsSpy = spyOn(mcpLoader, "setAdditionalAllowedMcpEnvVars").mockImplementation(() => {})
|
||||
@@ -118,6 +120,8 @@ afterEach(() => {
|
||||
;(skillLoader.discoverOpencodeProjectSkills as any)?.mockRestore?.()
|
||||
;(agentLoader.loadUserAgents as any)?.mockRestore?.()
|
||||
;(agentLoader.loadProjectAgents as any)?.mockRestore?.()
|
||||
;(agentLoader.loadOpencodeGlobalAgents as any)?.mockRestore?.()
|
||||
;(agentLoader.loadOpencodeProjectAgents as any)?.mockRestore?.()
|
||||
;(mcpLoader.loadMcpConfigs as any)?.mockRestore?.()
|
||||
setAdditionalAllowedMcpEnvVarsSpy?.mockRestore()
|
||||
;(pluginLoader.loadAllPluginComponents as any)?.mockRestore?.()
|
||||
@@ -1596,3 +1600,173 @@ describe("disable_omo_env pass-through", () => {
|
||||
expect(disableOmoEnv).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Agent merge priority — project-local overrides global", () => {
|
||||
test("project-local Claude agent overrides global Claude agent with same name", async () => {
|
||||
// #given — same agent name in both global (user) and project scopes
|
||||
;(agentLoader.loadUserAgents as any).mockReturnValue({
|
||||
"my-custom-agent": {
|
||||
description: "(user) global version",
|
||||
mode: "subagent",
|
||||
prompt: "I am the global agent",
|
||||
},
|
||||
})
|
||||
;(agentLoader.loadProjectAgents as any).mockReturnValue({
|
||||
"my-custom-agent": {
|
||||
description: "(project) project version",
|
||||
mode: "subagent",
|
||||
prompt: "I am the project agent",
|
||||
},
|
||||
})
|
||||
|
||||
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then — project version wins
|
||||
const agentConfig = config.agent as Record<string, { description?: string; prompt?: string }>
|
||||
expect(agentConfig["my-custom-agent"]?.description).toBe("(project) project version")
|
||||
expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the project agent")
|
||||
})
|
||||
|
||||
test("opencode project agent overrides opencode global agent with same name", async () => {
|
||||
// #given — same agent name in opencode global vs opencode project
|
||||
;(agentLoader.loadOpencodeGlobalAgents as any).mockReturnValue({
|
||||
"my-custom-agent": {
|
||||
description: "(opencode) global version",
|
||||
mode: "subagent",
|
||||
prompt: "I am the opencode global agent",
|
||||
},
|
||||
})
|
||||
;(agentLoader.loadOpencodeProjectAgents as any).mockReturnValue({
|
||||
"my-custom-agent": {
|
||||
description: "(opencode-project) project version",
|
||||
mode: "subagent",
|
||||
prompt: "I am the opencode project agent",
|
||||
},
|
||||
})
|
||||
|
||||
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then — opencode project version wins over opencode global
|
||||
const agentConfig = config.agent as Record<string, { description?: string; prompt?: string }>
|
||||
expect(agentConfig["my-custom-agent"]?.description).toBe("(opencode-project) project version")
|
||||
expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the opencode project agent")
|
||||
})
|
||||
|
||||
test("project Claude agent overrides opencode global agent with same name", async () => {
|
||||
// #given — project-scope Claude agent vs global-scope opencode agent
|
||||
;(agentLoader.loadOpencodeGlobalAgents as any).mockReturnValue({
|
||||
"my-custom-agent": {
|
||||
description: "(opencode) global version",
|
||||
mode: "subagent",
|
||||
prompt: "I am the opencode global agent",
|
||||
},
|
||||
})
|
||||
;(agentLoader.loadProjectAgents as any).mockReturnValue({
|
||||
"my-custom-agent": {
|
||||
description: "(project) project version",
|
||||
mode: "subagent",
|
||||
prompt: "I am the project Claude agent",
|
||||
},
|
||||
})
|
||||
|
||||
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then — project-scope wins over global-scope regardless of format
|
||||
const agentConfig = config.agent as Record<string, { description?: string; prompt?: string }>
|
||||
expect(agentConfig["my-custom-agent"]?.description).toBe("(project) project version")
|
||||
expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the project Claude agent")
|
||||
})
|
||||
|
||||
test("plugin agents have lowest priority — overridden by all other sources", async () => {
|
||||
// #given — same agent in plugin, global, and project scopes
|
||||
;(pluginLoader.loadAllPluginComponents as any).mockResolvedValue({
|
||||
commands: {},
|
||||
skills: {},
|
||||
agents: {
|
||||
"my-custom-agent": {
|
||||
description: "plugin version",
|
||||
mode: "subagent",
|
||||
prompt: "I am the plugin agent",
|
||||
},
|
||||
},
|
||||
mcpServers: {},
|
||||
hooksConfigs: [],
|
||||
plugins: [],
|
||||
errors: [],
|
||||
})
|
||||
;(agentLoader.loadUserAgents as any).mockReturnValue({
|
||||
"my-custom-agent": {
|
||||
description: "(user) global version",
|
||||
mode: "subagent",
|
||||
prompt: "I am the user agent",
|
||||
},
|
||||
})
|
||||
|
||||
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then — user (global) agent overrides plugin agent
|
||||
const agentConfig = config.agent as Record<string, { description?: string; prompt?: string }>
|
||||
expect(agentConfig["my-custom-agent"]?.description).toBe("(user) global version")
|
||||
expect(agentConfig["my-custom-agent"]?.prompt).toBe("I am the user agent")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,8 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
|
||||
}
|
||||
|
||||
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
|
||||
// Custom/unknown agents get no restrictions (empty object), matching Claude Code's
|
||||
// trust model where project-registered agents retain full tool access including bash.
|
||||
const stripped = stripInvisibleAgentCharacters(agentName)
|
||||
return AGENT_RESTRICTIONS[stripped]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === stripped.toLowerCase())?.[1]
|
||||
@@ -54,8 +56,6 @@ export function getAgentToolRestrictions(agentName: string): Record<string, bool
|
||||
}
|
||||
|
||||
export function hasAgentToolRestrictions(agentName: string): boolean {
|
||||
const stripped = stripInvisibleAgentCharacters(agentName)
|
||||
const restrictions = AGENT_RESTRICTIONS[stripped]
|
||||
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === stripped.toLowerCase())?.[1]
|
||||
return restrictions !== undefined && Object.keys(restrictions).length > 0
|
||||
const restrictions = getAgentToolRestrictions(agentName)
|
||||
return Object.keys(restrictions).length > 0
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from "./claude-config-dir"
|
||||
export * from "./jsonc-parser"
|
||||
export * from "./migration"
|
||||
export * from "./opencode-config-dir"
|
||||
export * from "./resolve-agent-definition-paths"
|
||||
export type {
|
||||
OpenCodeBinaryType,
|
||||
OpenCodeConfigDirOptions,
|
||||
@@ -75,3 +76,4 @@ export { SessionCategoryRegistry } from "./session-category-registry"
|
||||
export * from "./plugin-identity"
|
||||
export * from "./log-legacy-plugin-startup-warning"
|
||||
export * from "./task-system-enabled"
|
||||
export * from "./parse-tools-config"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Parses a tools configuration value into a boolean record.
|
||||
* Accepts comma-separated strings, string arrays, or unknown values from config files.
|
||||
* Returns undefined when input is empty or invalid.
|
||||
*/
|
||||
export function parseToolsConfig(toolsValue: unknown): Record<string, boolean> | undefined {
|
||||
if (!toolsValue) return undefined
|
||||
|
||||
let items: string[]
|
||||
if (typeof toolsValue === "string") {
|
||||
items = toolsValue.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
} else if (Array.isArray(toolsValue)) {
|
||||
items = toolsValue.filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => (t as string).trim())
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (items.length === 0) return undefined
|
||||
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const tool of items) {
|
||||
result[tool.toLowerCase()] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import { resolveAgentDefinitionPaths } from "./resolve-agent-definition-paths"
|
||||
|
||||
describe("resolveAgentDefinitionPaths", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "resolve-agent-def-paths-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("#given relative paths", () => {
|
||||
test("#then they are resolved against baseDir", () => {
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["agents/my-agent.md"],
|
||||
tempDir,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(result).toEqual([join(tempDir, "agents/my-agent.md")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given absolute paths", () => {
|
||||
test("#then they are returned as-is", () => {
|
||||
const absPath = join(tempDir, "absolute-agent.md")
|
||||
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
[absPath],
|
||||
"/some/other/base",
|
||||
null,
|
||||
)
|
||||
|
||||
expect(result).toEqual([absPath])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given tilde-prefixed paths", () => {
|
||||
test("#then ~ is expanded to homedir", () => {
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["~/agents/test.md"],
|
||||
tempDir,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(result).toEqual([join(homedir(), "agents/test.md")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given containmentDir is set", () => {
|
||||
test("#then paths outside the boundary are rejected", () => {
|
||||
const projectDir = join(tempDir, "project")
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["/etc/passwd"],
|
||||
projectDir,
|
||||
projectDir,
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("#then paths inside the boundary are allowed", () => {
|
||||
const projectDir = join(tempDir, "project")
|
||||
const agentsDir = join(projectDir, "agents")
|
||||
mkdirSync(agentsDir, { recursive: true })
|
||||
writeFileSync(join(agentsDir, "a.md"), "test", "utf-8")
|
||||
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["agents/a.md"],
|
||||
projectDir,
|
||||
projectDir,
|
||||
)
|
||||
|
||||
expect(result).toEqual([join(projectDir, "agents/a.md")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given containmentDir is null", () => {
|
||||
test("#then no boundary check is applied", () => {
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["/some/outside/path/agent.md"],
|
||||
tempDir,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(result).toEqual(["/some/outside/path/agent.md"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an empty paths array", () => {
|
||||
test("#then an empty array is returned", () => {
|
||||
const result = resolveAgentDefinitionPaths([], tempDir, null)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given mixed valid and invalid paths", () => {
|
||||
test("#then only valid paths within the boundary are returned", () => {
|
||||
const projectDir = join(tempDir, "project")
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
const result = resolveAgentDefinitionPaths(
|
||||
["./valid.md", "/outside/boundary.md"],
|
||||
projectDir,
|
||||
projectDir,
|
||||
)
|
||||
|
||||
expect(result).toEqual([join(projectDir, "valid.md")])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { homedir } from "os"
|
||||
import { isAbsolute, resolve } from "path"
|
||||
import { isWithinProject } from "./contains-path"
|
||||
import { log } from "./logger"
|
||||
|
||||
export function resolveAgentDefinitionPaths(
|
||||
paths: string[],
|
||||
baseDir: string,
|
||||
containmentDir: string | null
|
||||
): string[] {
|
||||
return paths.flatMap((p) => {
|
||||
const expanded = p.startsWith("~/") ? p.replace(/^~\//, `${homedir()}/`) : p
|
||||
const resolved = isAbsolute(expanded) ? expanded : resolve(baseDir, expanded)
|
||||
|
||||
if (containmentDir !== null && !isWithinProject(resolved, containmentDir)) {
|
||||
log(`agent_definitions path rejected (outside project boundary): ${p} -> ${resolved}`)
|
||||
return []
|
||||
}
|
||||
|
||||
return [resolved]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Requirement-based tests for resolveCallableAgents().
|
||||
*
|
||||
* These tests are derived from behavioral requirements in the PR description
|
||||
* and feature spec, NOT from reading the implementation:
|
||||
*
|
||||
* R1: ALLOWED_AGENTS always present as baseline
|
||||
* R2: Dynamic agents from client.app.agents() merged into the result
|
||||
* R3: Primary-mode agents excluded from callable list
|
||||
* R4: Falls back to ALLOWED_AGENTS alone when client.app.agents() fails
|
||||
* R5: All output names are lowercase
|
||||
* R6: No duplicate agent names in output
|
||||
* R7: Malformed agent entries (null, missing name, non-string name, whitespace-only) are skipped gracefully
|
||||
*/
|
||||
const { describe, test, expect, mock, beforeEach } = require("bun:test")
|
||||
const { resolveCallableAgents, clearCallableAgentsCache } = require("./agent-resolver")
|
||||
const { ALLOWED_AGENTS } = require("./constants")
|
||||
|
||||
function createMockClient(agents: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
app: {
|
||||
agents: mock(() => Promise.resolve({ data: agents })),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createFailingClient(error: Error = new Error("API unavailable")) {
|
||||
return {
|
||||
app: {
|
||||
agents: mock(() => Promise.reject(error)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveCallableAgents", () => {
|
||||
beforeEach(() => {
|
||||
clearCallableAgentsCache()
|
||||
})
|
||||
|
||||
describe("#given the SDK returns agents successfully", () => {
|
||||
describe("#when only built-in agents exist", () => {
|
||||
test("#then every ALLOWED_AGENT appears in the result", async () => {
|
||||
const builtinAgents = ALLOWED_AGENTS.map((name: string) => ({
|
||||
name,
|
||||
mode: "subagent",
|
||||
}))
|
||||
const client = createMockClient(builtinAgents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
for (const agent of ALLOWED_AGENTS) {
|
||||
expect(result).toContain(agent)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when dynamic custom agents are present alongside built-ins", () => {
|
||||
test("#then custom agents are included in the result", async () => {
|
||||
const agents = [
|
||||
...ALLOWED_AGENTS.map((name: string) => ({ name, mode: "subagent" })),
|
||||
{ name: "bug-fixer", mode: "subagent" },
|
||||
{ name: "code-reviewer", mode: "subagent" },
|
||||
]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).toContain("bug-fixer")
|
||||
expect(result).toContain("code-reviewer")
|
||||
})
|
||||
|
||||
test("#then ALLOWED_AGENTS are still present", async () => {
|
||||
const agents = [{ name: "custom-agent", mode: "subagent" }]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
for (const agent of ALLOWED_AGENTS) {
|
||||
expect(result).toContain(agent)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when an agent has mode=primary", () => {
|
||||
test("#then it is excluded from the callable list", async () => {
|
||||
const agents = [
|
||||
{ name: "sisyphus", mode: "primary" },
|
||||
{ name: "explore", mode: "subagent" },
|
||||
]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).not.toContain("sisyphus")
|
||||
expect(result).toContain("explore")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when agent names have mixed case", () => {
|
||||
test("#then all output names are lowercase", async () => {
|
||||
const agents = [
|
||||
{ name: "Bug-Fixer", mode: "subagent" },
|
||||
{ name: "CODE-REVIEWER", mode: "subagent" },
|
||||
]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).toContain("bug-fixer")
|
||||
expect(result).toContain("code-reviewer")
|
||||
for (const name of result) {
|
||||
expect(name).toBe(name.toLowerCase())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when duplicate agent names exist across sources", () => {
|
||||
test("#then no duplicates appear in the result", async () => {
|
||||
const agents = [
|
||||
{ name: "explore", mode: "subagent" },
|
||||
{ name: "explore", mode: "subagent" },
|
||||
{ name: "Explore", mode: "subagent" },
|
||||
]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
const exploreCount = result.filter((n: string) => n === "explore").length
|
||||
expect(exploreCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when agent entries are malformed", () => {
|
||||
test("#then entries with null name are skipped", async () => {
|
||||
const agents = [
|
||||
{ name: null, mode: "subagent" },
|
||||
{ name: "explore", mode: "subagent" },
|
||||
]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).toContain("explore")
|
||||
expect(result.length).toBeGreaterThanOrEqual(ALLOWED_AGENTS.length)
|
||||
})
|
||||
|
||||
test("#then entries with numeric name are skipped", async () => {
|
||||
const agents = [
|
||||
{ name: 42, mode: "subagent" },
|
||||
{ name: "explore", mode: "subagent" },
|
||||
]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).not.toContain("42")
|
||||
expect(result).toContain("explore")
|
||||
})
|
||||
|
||||
test("#then entries with whitespace-only name are skipped", async () => {
|
||||
const agents = [
|
||||
{ name: " ", mode: "subagent" },
|
||||
{ name: "explore", mode: "subagent" },
|
||||
]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).not.toContain("")
|
||||
expect(result).not.toContain(" ")
|
||||
expect(result).toContain("explore")
|
||||
})
|
||||
|
||||
test("#then entries with missing name property are skipped", async () => {
|
||||
const agents = [
|
||||
{ mode: "subagent" },
|
||||
{ name: "explore", mode: "subagent" },
|
||||
]
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).toContain("explore")
|
||||
expect(result.length).toBeGreaterThanOrEqual(ALLOWED_AGENTS.length)
|
||||
})
|
||||
|
||||
test("#then entries that are undefined/null themselves are skipped", async () => {
|
||||
const agents = [
|
||||
null,
|
||||
undefined,
|
||||
{ name: "explore", mode: "subagent" },
|
||||
] as unknown as Array<Record<string, unknown>>
|
||||
const client = createMockClient(agents)
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).toContain("explore")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when SDK returns an empty list", () => {
|
||||
test("#then ALLOWED_AGENTS still appear as the baseline", async () => {
|
||||
const client = createMockClient([])
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
for (const agent of ALLOWED_AGENTS) {
|
||||
expect(result).toContain(agent)
|
||||
}
|
||||
expect(result.length).toBe(ALLOWED_AGENTS.length)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given the SDK call fails", () => {
|
||||
describe("#when client.app.agents() throws an error", () => {
|
||||
test("#then it falls back to ALLOWED_AGENTS", async () => {
|
||||
const client = createFailingClient(new Error("Network error"))
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result.length).toBe(ALLOWED_AGENTS.length)
|
||||
for (const agent of ALLOWED_AGENTS) {
|
||||
expect(result).toContain(agent)
|
||||
}
|
||||
})
|
||||
|
||||
test("#then custom agents are NOT available in fallback mode", async () => {
|
||||
const client = createFailingClient()
|
||||
|
||||
const result = await resolveCallableAgents(client)
|
||||
|
||||
expect(result).not.toContain("bug-fixer")
|
||||
expect(result).not.toContain("custom-agent")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { ALLOWED_AGENTS } from "./constants";
|
||||
import { normalizeSDKResponse } from "../../shared";
|
||||
import { log } from "../../shared/logger";
|
||||
|
||||
type AgentInfo = {
|
||||
name: string;
|
||||
mode?: "subagent" | "primary" | "all";
|
||||
};
|
||||
|
||||
const callableAgentsCache = new Map<string, { agents: string[]; timestamp: number }>();
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
export function clearCallableAgentsCache(): void {
|
||||
callableAgentsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the set of callable agent names at execute-time by merging the
|
||||
* hardcoded `ALLOWED_AGENTS` with any additional agents discovered dynamically
|
||||
* via `client.app.agents()`. Custom agents loaded from registered agent
|
||||
* directories appear here alongside built-ins.
|
||||
*
|
||||
* Results are cached per session for 30s to avoid redundant SDK IPC calls.
|
||||
*
|
||||
* Falls back to `ALLOWED_AGENTS` alone if the dynamic lookup fails.
|
||||
*
|
||||
* @param client - The plugin client with access to the agent registry
|
||||
* @param sessionId - Optional session ID for cache scoping
|
||||
* @returns Array of lowercase callable agent names (excludes primary-mode agents)
|
||||
*/
|
||||
export async function resolveCallableAgents(
|
||||
client: PluginInput["client"],
|
||||
sessionId?: string,
|
||||
): Promise<string[]> {
|
||||
const cacheKey = sessionId ?? "__default__";
|
||||
const cached = callableAgentsCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.agents;
|
||||
}
|
||||
|
||||
try {
|
||||
const agentsResult = await client.app.agents();
|
||||
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
|
||||
preferResponseOnMissingData: true,
|
||||
});
|
||||
|
||||
const dynamicAgents = agents
|
||||
.filter((a) => a && typeof a.name === "string" && a.name.trim().length > 0 && a.mode !== "primary")
|
||||
.map((a) => a.name.trim().toLowerCase());
|
||||
|
||||
const merged = new Set([...ALLOWED_AGENTS, ...dynamicAgents]);
|
||||
const result = [...merged];
|
||||
callableAgentsCache.set(cacheKey, { agents: result, timestamp: Date.now() });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(
|
||||
"[call_omo_agent] Failed to resolve dynamic agents, falling back to built-in list",
|
||||
{ error: message },
|
||||
);
|
||||
return [...ALLOWED_AGENTS];
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ export const ALLOWED_AGENTS = [
|
||||
"multimodal-looker",
|
||||
] as const
|
||||
|
||||
export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent. run_in_background REQUIRED (true=async with task_id, false=sync).
|
||||
export const CALL_OMO_AGENT_DESCRIPTION = `Spawn explore/librarian agent or custom agents. run_in_background REQUIRED (true=async with task_id, false=sync).
|
||||
|
||||
Available: {agents}
|
||||
Built-in agents:
|
||||
{agents}
|
||||
|
||||
Custom agents registered via user or project agent directories are also supported.
|
||||
|
||||
Pass \`session_id=<id>\` to continue previous agent with full context. Nested subagent depth is tracked automatically and blocked past the configured limit. Prompts MUST be in English. Use \`background_output\` for async results.`
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Requirement-based integration tests for createCallOmoAgent edge cases
|
||||
* introduced by the dev rebase and dynamic agent resolution feature.
|
||||
*
|
||||
* R1: Spawn reservation is rolled back when execution fails after reservation
|
||||
* R2: Agent names with leading/trailing whitespace are trimmed before matching
|
||||
* R3: An agent present in both ALLOWED_AGENTS and dynamic list is callable (no conflict)
|
||||
* R4: session_id continuation rejects in background mode when session already exists
|
||||
*/
|
||||
const { describe, test, expect, mock, beforeEach } = require("bun:test")
|
||||
const { createCallOmoAgent } = require("./tools")
|
||||
const { clearCallableAgentsCache } = require("./agent-resolver")
|
||||
|
||||
type PluginInput = { client: any; directory: string }
|
||||
|
||||
function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): PluginInput {
|
||||
return {
|
||||
client: {
|
||||
app: {
|
||||
agents: mock(() => Promise.resolve({ data: agents })),
|
||||
},
|
||||
},
|
||||
directory: "/test",
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
const DEFAULT_AGENTS = [
|
||||
{ name: "explore", mode: "subagent" },
|
||||
{ name: "librarian", mode: "subagent" },
|
||||
{ name: "oracle", mode: "subagent" },
|
||||
{ name: "hephaestus", mode: "subagent" },
|
||||
{ name: "metis", mode: "subagent" },
|
||||
{ name: "momus", mode: "subagent" },
|
||||
{ name: "multimodal-looker", mode: "subagent" },
|
||||
]
|
||||
|
||||
const reserveCommitMock = mock(() => 1)
|
||||
const reserveRollbackMock = mock(() => {})
|
||||
const reserveSubagentSpawnMock = mock(() => Promise.resolve({
|
||||
spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 },
|
||||
descendantCount: 1,
|
||||
commit: reserveCommitMock,
|
||||
rollback: reserveRollbackMock,
|
||||
}))
|
||||
|
||||
const toolCtx = {
|
||||
sessionID: "test",
|
||||
messageID: "msg",
|
||||
agent: "test",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearCallableAgentsCache()
|
||||
reserveSubagentSpawnMock.mockClear()
|
||||
reserveCommitMock.mockClear()
|
||||
reserveRollbackMock.mockClear()
|
||||
})
|
||||
|
||||
describe("createCallOmoAgent edge cases", () => {
|
||||
describe("#given spawn reservation succeeds but sync execution fails", () => {
|
||||
test("#then rollback is called to release the reservation", async () => {
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
reserveSubagentSpawnMock.mockResolvedValueOnce({
|
||||
spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 },
|
||||
descendantCount: 1,
|
||||
commit: reserveCommitMock,
|
||||
rollback: reserveRollbackMock,
|
||||
})
|
||||
const mockManager = {
|
||||
assertCanSpawn: mock(() => Promise.resolve(undefined)),
|
||||
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||
launch: mock(() => Promise.resolve()),
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Test prompt",
|
||||
subagent_type: "explore",
|
||||
run_in_background: false,
|
||||
},
|
||||
toolCtx,
|
||||
)
|
||||
|
||||
expect(reserveRollbackMock).toHaveBeenCalled()
|
||||
expect(result).toContain("Error:")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given agent names with extra whitespace from SDK", () => {
|
||||
test("#then whitespace-padded names are trimmed and matched correctly", async () => {
|
||||
const agents = [
|
||||
...DEFAULT_AGENTS,
|
||||
{ name: " bug-fixer ", mode: "subagent" },
|
||||
]
|
||||
const mockCtx = createMockCtx(agents)
|
||||
const mockManager = {
|
||||
assertCanSpawn: mock(() => Promise.resolve(undefined)),
|
||||
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||
launch: mock(() => Promise.resolve({
|
||||
id: "task-id",
|
||||
sessionID: "ses-1",
|
||||
description: "Test",
|
||||
agent: "bug-fixer",
|
||||
status: "pending",
|
||||
})),
|
||||
getTask: mock(() => ({ status: "pending", sessionID: "ses-1" })),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Fix bug",
|
||||
subagent_type: "bug-fixer",
|
||||
run_in_background: true,
|
||||
},
|
||||
toolCtx,
|
||||
)
|
||||
|
||||
expect(result).not.toContain("Invalid agent type")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an agent exists in both ALLOWED_AGENTS and dynamic results", () => {
|
||||
test("#then the agent is callable without conflict", async () => {
|
||||
const agents = [
|
||||
...DEFAULT_AGENTS,
|
||||
{ name: "explore", mode: "subagent" },
|
||||
]
|
||||
const mockCtx = createMockCtx(agents)
|
||||
const mockManager = {
|
||||
assertCanSpawn: mock(() => Promise.resolve(undefined)),
|
||||
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||
launch: mock(() => Promise.resolve({
|
||||
id: "task-id",
|
||||
sessionID: "ses-1",
|
||||
description: "Test",
|
||||
agent: "explore",
|
||||
status: "pending",
|
||||
})),
|
||||
getTask: mock(() => ({ status: "pending", sessionID: "ses-1" })),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Search codebase",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
},
|
||||
toolCtx,
|
||||
)
|
||||
|
||||
expect(result).not.toContain("Invalid agent type")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a disabled custom agent from dynamic resolution", () => {
|
||||
test("#then disabled_agents check takes precedence over dynamic availability", async () => {
|
||||
const agents = [
|
||||
...DEFAULT_AGENTS,
|
||||
{ name: "bug-fixer", mode: "subagent" },
|
||||
]
|
||||
const mockCtx = createMockCtx(agents)
|
||||
const mockManager = {
|
||||
assertCanSpawn: mock(() => Promise.resolve(undefined)),
|
||||
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||
launch: mock(() => Promise.resolve()),
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockManager, ["Bug-Fixer"])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Fix bug",
|
||||
subagent_type: "bug-fixer",
|
||||
run_in_background: true,
|
||||
},
|
||||
toolCtx,
|
||||
)
|
||||
|
||||
expect(result).toContain("disabled via disabled_agents")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given session_id is provided in background mode", () => {
|
||||
test("#then the request is rejected with a clear error", async () => {
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
const mockManager = {
|
||||
assertCanSpawn: mock(() => Promise.resolve(undefined)),
|
||||
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||
launch: mock(() => Promise.resolve()),
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Continue work",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
session_id: "ses-existing-123",
|
||||
},
|
||||
toolCtx,
|
||||
)
|
||||
|
||||
expect(result).toContain("session_id is not supported in background mode")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -1,119 +1,239 @@
|
||||
const { beforeEach, describe, test, expect, mock } = require("bun:test")
|
||||
const { createCallOmoAgent } = require("./tools")
|
||||
const { clearCallableAgentsCache } = require("./agent-resolver")
|
||||
|
||||
type PluginInput = { client: any; directory: string }
|
||||
type BackgroundManager = {
|
||||
assertCanSpawn: Function
|
||||
reserveSubagentSpawn: Function
|
||||
launch: Function
|
||||
getTask: Function
|
||||
}
|
||||
|
||||
function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): PluginInput {
|
||||
return {
|
||||
client: {
|
||||
app: {
|
||||
agents: mock(() => Promise.resolve({ data: agents })),
|
||||
},
|
||||
},
|
||||
directory: "/test",
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
function createFailingMockCtx(error: Error = new Error("API unavailable")): PluginInput {
|
||||
return {
|
||||
client: {
|
||||
app: {
|
||||
agents: mock(() => Promise.reject(error)),
|
||||
},
|
||||
},
|
||||
directory: "/test",
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
const DEFAULT_AGENTS = [
|
||||
{ name: "explore", mode: "subagent" },
|
||||
{ name: "librarian", mode: "subagent" },
|
||||
{ name: "oracle", mode: "subagent" },
|
||||
{ name: "hephaestus", mode: "subagent" },
|
||||
{ name: "metis", mode: "subagent" },
|
||||
{ name: "momus", mode: "subagent" },
|
||||
{ name: "multimodal-looker", mode: "subagent" },
|
||||
]
|
||||
|
||||
const assertCanSpawnMock = mock(() => Promise.resolve(undefined))
|
||||
const reserveCommitMock = mock(() => 1)
|
||||
const reserveRollbackMock = mock(() => {})
|
||||
const reserveSubagentSpawnMock = mock(() => Promise.resolve({
|
||||
spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 },
|
||||
descendantCount: 1,
|
||||
commit: reserveCommitMock,
|
||||
rollback: reserveRollbackMock,
|
||||
}))
|
||||
|
||||
const mockBackgroundManager = {
|
||||
assertCanSpawn: assertCanSpawnMock,
|
||||
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||
launch: mock(() => Promise.resolve({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
})),
|
||||
getTask: mock(() => ({ status: "pending", sessionID: "ses-123" })),
|
||||
} as unknown as BackgroundManager
|
||||
|
||||
const toolCtx = {
|
||||
sessionID: "test",
|
||||
messageID: "msg",
|
||||
agent: "test",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearCallableAgentsCache()
|
||||
assertCanSpawnMock.mockClear()
|
||||
reserveSubagentSpawnMock.mockClear()
|
||||
reserveCommitMock.mockClear()
|
||||
reserveRollbackMock.mockClear()
|
||||
})
|
||||
|
||||
describe("createCallOmoAgent", () => {
|
||||
const assertCanSpawnMock = mock(() => Promise.resolve(undefined))
|
||||
const reserveCommitMock = mock(() => 1)
|
||||
const reserveRollbackMock = mock(() => {})
|
||||
const reserveSubagentSpawnMock = mock(() => Promise.resolve({
|
||||
spawnContext: { rootSessionID: "root-session", parentDepth: 0, childDepth: 1 },
|
||||
descendantCount: 1,
|
||||
commit: reserveCommitMock,
|
||||
rollback: reserveRollbackMock,
|
||||
}))
|
||||
const mockCtx = {
|
||||
client: {},
|
||||
directory: "/test",
|
||||
}
|
||||
describe("disabled_agents validation", () => {
|
||||
test("should reject agent in disabled_agents list", async () => {
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["explore"])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const mockBackgroundManager = {
|
||||
assertCanSpawn: assertCanSpawnMock,
|
||||
reserveSubagentSpawn: reserveSubagentSpawnMock,
|
||||
launch: mock(() => Promise.resolve({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
})),
|
||||
}
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
assertCanSpawnMock.mockClear()
|
||||
reserveSubagentSpawnMock.mockClear()
|
||||
reserveCommitMock.mockClear()
|
||||
reserveRollbackMock.mockClear()
|
||||
expect(result).toContain("disabled via disabled_agents")
|
||||
})
|
||||
|
||||
test("should reject agent in disabled_agents list with case-insensitive matching", async () => {
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["Explore"])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
expect(result).toContain("disabled via disabled_agents")
|
||||
})
|
||||
|
||||
test("should allow agent not in disabled_agents list", async () => {
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["librarian"])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
expect(result).not.toContain("disabled via disabled_agents")
|
||||
})
|
||||
|
||||
test("should allow all agents when disabled_agents is empty", async () => {
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Test prompt", subagent_type: "explore", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
expect(result).not.toContain("disabled via disabled_agents")
|
||||
})
|
||||
})
|
||||
|
||||
test("should reject agent in disabled_agents list", async () => {
|
||||
//#given
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["explore"])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
describe("dynamic custom agent resolution", () => {
|
||||
test("should accept a custom agent returned by client.app.agents()", async () => {
|
||||
const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }]
|
||||
const mockCtx = createMockCtx(agents)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
//#when
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Test prompt",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
},
|
||||
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||
)
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).toContain("disabled via disabled_agents")
|
||||
})
|
||||
expect(result).not.toContain("Invalid agent type")
|
||||
expect(result).not.toContain("not found")
|
||||
})
|
||||
|
||||
test("should reject agent in disabled_agents list with case-insensitive matching", async () => {
|
||||
//#given
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["Explore"])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
test("should reject a custom agent NOT returned by client.app.agents()", async () => {
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
//#when
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Test prompt",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
},
|
||||
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||
)
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Fix bug", subagent_type: "nonexistent-agent", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).toContain("disabled via disabled_agents")
|
||||
})
|
||||
expect(result).toContain("Invalid agent type")
|
||||
})
|
||||
|
||||
test("should allow agent not in disabled_agents list", async () => {
|
||||
//#given
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["librarian"])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
test("should perform case-insensitive matching for custom agents", async () => {
|
||||
const agents = [...DEFAULT_AGENTS, { name: "Bug-Fixer", mode: "subagent" }]
|
||||
const mockCtx = createMockCtx(agents)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
//#when
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Test prompt",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
},
|
||||
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||
)
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
//#then
|
||||
// Should not contain disabled error - may fail for other reasons but disabled check should pass
|
||||
expect(result).not.toContain("disabled via disabled_agents")
|
||||
})
|
||||
expect(result).not.toContain("Invalid agent type")
|
||||
})
|
||||
|
||||
test("should allow all agents when disabled_agents is empty", async () => {
|
||||
//#given
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
test("should exclude primary-mode agents from callable list", async () => {
|
||||
const agents = [
|
||||
...DEFAULT_AGENTS,
|
||||
{ name: "sisyphus", mode: "primary" },
|
||||
]
|
||||
const mockCtx = createMockCtx(agents)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
//#when
|
||||
const result = await executeFunc(
|
||||
{
|
||||
description: "Test",
|
||||
prompt: "Test prompt",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
},
|
||||
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||
)
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Orchestrate", subagent_type: "sisyphus", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).not.toContain("disabled via disabled_agents")
|
||||
expect(result).toContain("Invalid agent type")
|
||||
})
|
||||
|
||||
test("should fall back to ALLOWED_AGENTS when client.app.agents() fails", async () => {
|
||||
const mockCtx = createFailingMockCtx()
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Explore codebase", subagent_type: "explore", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
expect(result).not.toContain("Invalid agent type")
|
||||
})
|
||||
|
||||
test("should reject unknown agent even when client.app.agents() fails (fallback mode)", async () => {
|
||||
const mockCtx = createFailingMockCtx()
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Fix bug", subagent_type: "custom-agent", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
expect(result).toContain("Invalid agent type")
|
||||
})
|
||||
|
||||
test("should still apply disabled_agents check to dynamically resolved custom agents", async () => {
|
||||
const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }]
|
||||
const mockCtx = createMockCtx(agents)
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["bug-fixer"])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
const result = await executeFunc(
|
||||
{ description: "Test", prompt: "Fix bug", subagent_type: "bug-fixer", run_in_background: true },
|
||||
toolCtx
|
||||
)
|
||||
|
||||
expect(result).toContain("disabled via disabled_agents")
|
||||
})
|
||||
})
|
||||
|
||||
test("uses agent override fallback_models when launching background subagent", async () => {
|
||||
@@ -129,6 +249,7 @@ describe("createCallOmoAgent", () => {
|
||||
launch,
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
const toolDef = createCallOmoAgent(
|
||||
mockCtx,
|
||||
managerWithLaunch,
|
||||
@@ -179,7 +300,7 @@ describe("createCallOmoAgent", () => {
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(
|
||||
mockCtx,
|
||||
createMockCtx(DEFAULT_AGENTS),
|
||||
managerWithLaunch,
|
||||
[],
|
||||
{
|
||||
@@ -228,7 +349,7 @@ describe("createCallOmoAgent", () => {
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(
|
||||
mockCtx,
|
||||
createMockCtx(DEFAULT_AGENTS),
|
||||
managerWithLaunch,
|
||||
[],
|
||||
{
|
||||
@@ -279,7 +400,7 @@ describe("createCallOmoAgent", () => {
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(
|
||||
mockCtx,
|
||||
createMockCtx(DEFAULT_AGENTS),
|
||||
managerWithLaunch,
|
||||
[],
|
||||
{
|
||||
@@ -329,7 +450,7 @@ describe("createCallOmoAgent", () => {
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(
|
||||
mockCtx,
|
||||
createMockCtx(DEFAULT_AGENTS),
|
||||
managerWithLaunch,
|
||||
[],
|
||||
{
|
||||
@@ -371,6 +492,7 @@ describe("createCallOmoAgent", () => {
|
||||
|
||||
test("should return a tool error when sync spawn depth validation fails", async () => {
|
||||
//#given
|
||||
const mockCtx = createMockCtx(DEFAULT_AGENTS)
|
||||
reserveSubagentSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
|
||||
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CONFIG_BASENAME } from "../../shared/plugin-identity"
|
||||
import { parseModelString } from "../delegate-task/model-string-parser"
|
||||
import { executeBackground } from "./background-executor"
|
||||
import { executeSync } from "./sync-executor"
|
||||
import { resolveCallableAgents } from "./agent-resolver"
|
||||
|
||||
function resolveModelAndFallbackChain(args: {
|
||||
subagentType: string
|
||||
@@ -83,39 +84,57 @@ export function createCallOmoAgent(
|
||||
userCategories?: CategoriesConfig,
|
||||
): ToolDefinition {
|
||||
const agentDescriptions = ALLOWED_AGENTS.map(
|
||||
(name) => `- ${name}: Specialized agent for ${name} tasks`
|
||||
).join("\n")
|
||||
const description = CALL_OMO_AGENT_DESCRIPTION.replace("{agents}", agentDescriptions)
|
||||
(name) => `- ${name}: Specialized agent for ${name} tasks`,
|
||||
).join("\n");
|
||||
const description = CALL_OMO_AGENT_DESCRIPTION.replace(
|
||||
"{agents}",
|
||||
agentDescriptions,
|
||||
);
|
||||
|
||||
return tool({
|
||||
description,
|
||||
args: {
|
||||
description: tool.schema.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: tool.schema.string().describe("The task for the agent to perform"),
|
||||
description: tool.schema
|
||||
.string()
|
||||
.describe("A short (3-5 words) description of the task"),
|
||||
prompt: tool.schema
|
||||
.string()
|
||||
.describe("The task for the agent to perform"),
|
||||
subagent_type: tool.schema
|
||||
.string()
|
||||
.describe("The type of specialized agent to use for this task (explore or librarian only)"),
|
||||
.describe(
|
||||
"The agent to invoke. Supports built-in agents and any custom agents registered at runtime.",
|
||||
),
|
||||
run_in_background: tool.schema
|
||||
.boolean()
|
||||
.describe("REQUIRED. true: run asynchronously (use background_output to get results), false: run synchronously and wait for completion"),
|
||||
session_id: tool.schema.string().describe("Existing Task session to continue").optional(),
|
||||
.describe(
|
||||
"REQUIRED. true: run asynchronously (use background_output to get results), false: run synchronously and wait for completion",
|
||||
),
|
||||
session_id: tool.schema
|
||||
.string()
|
||||
.describe("Existing Task session to continue")
|
||||
.optional(),
|
||||
},
|
||||
async execute(args: CallOmoAgentArgs, toolContext) {
|
||||
const toolCtx = toolContext as ToolContextWithMetadata
|
||||
log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`)
|
||||
const toolCtx = toolContext as ToolContextWithMetadata;
|
||||
log(
|
||||
`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`,
|
||||
);
|
||||
|
||||
const callableAgents = await resolveCallableAgents(ctx.client);
|
||||
|
||||
// Strip ZWSP and case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc.
|
||||
const strippedAgentType = stripInvisibleAgentCharacters(args.subagent_type)
|
||||
if (
|
||||
!ALLOWED_AGENTS.some(
|
||||
!callableAgents.some(
|
||||
(name) => name.toLowerCase() === strippedAgentType.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.`
|
||||
return `Error: Invalid agent type "${args.subagent_type}". Only ${callableAgents.join(", ")} are allowed.`;
|
||||
}
|
||||
|
||||
const normalizedAgent = strippedAgentType.toLowerCase() as AllowedAgentType
|
||||
args = { ...args, subagent_type: normalizedAgent }
|
||||
const normalizedAgent = strippedAgentType.toLowerCase();
|
||||
args = { ...args, subagent_type: normalizedAgent };
|
||||
|
||||
// Check if agent is disabled
|
||||
if (disabledAgents.some((disabled) => stripInvisibleAgentCharacters(disabled).toLowerCase() === normalizedAgent)) {
|
||||
@@ -130,7 +149,7 @@ export function createCallOmoAgent(
|
||||
|
||||
if (args.run_in_background) {
|
||||
if (args.session_id) {
|
||||
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
|
||||
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`;
|
||||
}
|
||||
return await executeBackground(args, toolCtx, backgroundManager, ctx.client, fallbackChain, resolvedModel)
|
||||
}
|
||||
@@ -148,5 +167,5 @@ export function createCallOmoAgent(
|
||||
|
||||
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, undefined, resolvedModel)
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user