feat(agents): add agent definitions file loader and opencode.json reader
- Add loadAgentDefinitions() for explicit file path loading (.md/.json/.jsonc) - Add readOpencodeConfigAgents() for independent opencode.json(c) reading - Extract parseMarkdownAgentFile() from loader.ts for reuse - Refactor loader.ts to use extracted parser (-50 LOC) - Add comprehensive test coverage (13 tests for definitions loader, 10 tests for opencode reader) - Support inline agents + agent_definitions paths in opencode.json(c) - Inline agents override definition-file agents (correct precedence) Part of agent definitions enhancement (Wave 2/3)
This commit is contained in:
committed by
YeonGyu-Kim
parent
fd28f7e668
commit
5755a90c3b
@@ -0,0 +1,277 @@
|
||||
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("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.")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user