Files
oh-my-opencode/src/tools/call-omo-agent/tools-edge-cases.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

225 lines
7.2 KiB
TypeScript

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