Files
oh-my-opencode/src/tools/call-omo-agent/agent-resolver.test.ts
T
YeonGyu-Kim e5d3fe96c4 fix(agents): address all PR #2299 code review findings
Blocking fixes:
- B1: Return empty restrictions for unknown/custom agents instead of
  EXPLORATION_AGENT_DENYLIST, allowing custom agents full tool access
- B2: Use Object.create(null) consistently across all 5 agent-loading
  result objects to prevent prototype pollution
- B3: Add code comment documenting custom agent bash access trust model
- B4: Mock getOpenCodeConfigDir in opencode-config-agents-reader tests
  to prevent global config dir leakage

Non-blocking fixes:
- N1: Use resolveAgentDefinitionPaths with project boundary enforcement
  in opencode-config-agents-reader for path containment
- N2: Add session-scoped 30s TTL cache to resolveCallableAgents to
  avoid redundant SDK IPC calls per tool invocation
- N3: Extract shared parseToolsConfig into src/shared/parse-tools-config.ts
  replacing 4 duplicated local implementations
- N4: Add .min(1) to AgentDefinitionPathSchema rejecting empty paths
- N5: Add resolve-agent-definition-paths.test.ts covering tilde expansion,
  relative paths, boundary enforcement, and null containmentDir
- N6: Validate agent mode against allowed values instead of bare type
  assertion in opencode-config-agents-reader
2026-04-15 10:58:16 +09:00

241 lines
7.7 KiB
TypeScript

/**
* 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 {}