oh-my-opencode/src/tools/agent-teams/tools.functional.test.ts
Nguyen Khac Trung Kien dc3d81a0b8 fix(agent-teams): tighten reviewer-raised runtime and messaging guards
Validate sender/owner/team flows more strictly, fail fast on invalid model overrides, and cancel failed launches to prevent orphaned background tasks while expanding functional coverage for these paths.
2026-02-14 13:33:29 +09:00

596 lines
16 KiB
TypeScript

/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import { createAgentTeamsTools } from "./tools"
import { getTeamDir, getTeamInboxPath, getTeamTaskDir } from "./paths"
interface LaunchCall {
description: string
prompt: string
agent: string
parentSessionID: string
parentMessageID: string
parentAgent?: string
model?: {
providerID: string
modelID: string
}
}
interface ResumeCall {
sessionId: string
prompt: string
parentSessionID: string
parentMessageID: string
parentAgent?: string
}
interface CancelCall {
taskId: string
options?: unknown
}
interface MockManagerHandles {
manager: BackgroundManager
launchCalls: LaunchCall[]
resumeCalls: ResumeCall[]
cancelCalls: CancelCall[]
}
interface TestToolContext {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
}
function createMockManager(): MockManagerHandles {
const launchCalls: LaunchCall[] = []
const resumeCalls: ResumeCall[] = []
const cancelCalls: CancelCall[] = []
const launchedTasks = new Map<string, { id: string; sessionID: string }>()
let launchCount = 0
const manager = {
launch: async (args: LaunchCall) => {
launchCount += 1
launchCalls.push(args)
const task = { id: `bg-${launchCount}`, sessionID: `ses-worker-${launchCount}` }
launchedTasks.set(task.id, task)
return task
},
getTask: (taskId: string) => launchedTasks.get(taskId),
resume: async (args: ResumeCall) => {
resumeCalls.push(args)
return { id: `resume-${resumeCalls.length}` }
},
cancelTask: async (taskId: string, options?: unknown) => {
cancelCalls.push({ taskId, options })
return true
},
} as unknown as BackgroundManager
return { manager, launchCalls, resumeCalls, cancelCalls }
}
function createFailingLaunchManager(): { manager: BackgroundManager; cancelCalls: CancelCall[] } {
const cancelCalls: CancelCall[] = []
const manager = {
launch: async () => ({ id: "bg-fail" }),
getTask: () => ({
id: "bg-fail",
parentSessionID: "ses-main",
parentMessageID: "msg-main",
description: "failed launch",
prompt: "prompt",
agent: "sisyphus-junior",
status: "error",
error: "launch failed",
}),
resume: async () => ({ id: "resume-unused" }),
cancelTask: async (taskId: string, options?: unknown) => {
cancelCalls.push({ taskId, options })
return true
},
} as unknown as BackgroundManager
return { manager, cancelCalls }
}
function createContext(): TestToolContext {
return {
sessionID: "ses-main",
messageID: "msg-main",
agent: "sisyphus",
abort: new AbortController().signal,
}
}
async function executeJsonTool(
tools: ReturnType<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tools[toolName].execute(args, context)
return JSON.parse(output)
}
describe("agent-teams tools functional", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-tools-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("team_create/read_config/delete work with project-local storage", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
//#when
const created = await executeJsonTool(
tools,
"team_create",
{ team_name: "core", description: "Core team" },
context,
) as { team_name: string; team_file_path: string; lead_agent_id: string }
//#then
expect(created.team_name).toBe("core")
expect(created.lead_agent_id).toBe("team-lead@core")
expect(created.team_file_path).toBe(join(tempProjectDir, ".sisyphus", "agent-teams", "teams", "core", "config.json"))
expect(existsSync(created.team_file_path)).toBe(true)
expect(existsSync(getTeamInboxPath("core", "team-lead"))).toBe(true)
//#when
const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as {
name: string
members: Array<{ name: string }>
}
//#then
expect(config.name).toBe("core")
expect(config.members.map((member) => member.name)).toEqual(["team-lead"])
//#when
const deleted = await executeJsonTool(tools, "team_delete", { team_name: "core" }, context) as {
success: boolean
}
//#then
expect(deleted.success).toBe(true)
expect(existsSync(getTeamDir("core"))).toBe(false)
expect(existsSync(getTeamTaskDir("core"))).toBe(false)
})
test("task tools create/update/get/list and emit assignment inbox message", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, context)
await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
},
context,
)
//#when
const createdTask = await executeJsonTool(
tools,
"team_task_create",
{
team_name: "core",
subject: "Draft release notes",
description: "Prepare release notes for next publish.",
},
context,
) as { id: string; status: string }
//#then
expect(createdTask.id).toMatch(/^T-[a-f0-9-]+$/)
expect(createdTask.status).toBe("pending")
//#when
const updatedTask = await executeJsonTool(
tools,
"team_task_update",
{
team_name: "core",
task_id: createdTask.id,
owner: "worker_1",
status: "in_progress",
},
context,
) as { owner?: string; status: string }
//#then
expect(updatedTask.owner).toBe("worker_1")
expect(updatedTask.status).toBe("in_progress")
//#when
const fetchedTask = await executeJsonTool(
tools,
"team_task_get",
{ team_name: "core", task_id: createdTask.id },
context,
) as { id: string; owner?: string }
const listedTasks = await executeJsonTool(tools, "team_task_list", { team_name: "core" }, context) as Array<{ id: string }>
const inbox = await executeJsonTool(
tools,
"read_inbox",
{
team_name: "core",
agent_name: "worker_1",
unread_only: true,
mark_as_read: false,
},
context,
) as Array<{ summary?: string; text: string }>
//#then
expect(fetchedTask.id).toBe(createdTask.id)
expect(fetchedTask.owner).toBe("worker_1")
expect(listedTasks.some((task) => task.id === createdTask.id)).toBe(true)
expect(inbox.some((message) => message.summary === "task_assignment")).toBe(true)
const assignment = inbox.find((message) => message.summary === "task_assignment")
expect(assignment).toBeDefined()
const payload = JSON.parse(assignment!.text) as { type: string; taskId: string }
expect(payload.type).toBe("task_assignment")
expect(payload.taskId).toBe(createdTask.id)
//#when
const clearedOwnerTask = await executeJsonTool(
tools,
"team_task_update",
{
team_name: "core",
task_id: createdTask.id,
owner: "",
},
context,
) as { owner?: string }
//#then
expect(clearedOwnerTask.owner).toBeUndefined()
})
test("rejects invalid task id input for task_get", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, context)
//#when
const result = await executeJsonTool(
tools,
"team_task_get",
{ team_name: "core", task_id: "../../etc/passwd" },
context,
) as { error?: string }
//#then
expect(result.error).toBe("task_id_invalid")
})
test("requires owner to be a team member when setting task owner", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, context)
const createdTask = await executeJsonTool(
tools,
"team_task_create",
{
team_name: "core",
subject: "Investigate bug",
description: "Investigate and report root cause",
},
context,
) as { id: string }
//#when
const result = await executeJsonTool(
tools,
"team_task_update",
{
team_name: "core",
task_id: createdTask.id,
owner: "ghost_user",
},
context,
) as { error?: string }
//#then
expect(result.error).toBe("owner_not_in_team")
})
test("allows assigning team-lead as task owner", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, context)
const createdTask = await executeJsonTool(
tools,
"team_task_create",
{
team_name: "core",
subject: "Prepare checklist",
description: "Prepare release checklist",
},
context,
) as { id: string }
//#when
const updated = await executeJsonTool(
tools,
"team_task_update",
{
team_name: "core",
task_id: createdTask.id,
owner: "team-lead",
},
context,
) as { owner?: string }
//#then
expect(updated.owner).toBe("team-lead")
})
test("spawn_teammate + send_message + force_kill_teammate execute end-to-end", async () => {
//#given
const { manager, launchCalls, resumeCalls, cancelCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, context)
//#when
const spawned = await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
},
context,
) as { name: string; session_id: string; task_id: string }
//#then
expect(spawned.name).toBe("worker_1")
expect(spawned.session_id).toBe("ses-worker-1")
expect(spawned.task_id).toBe("bg-1")
expect(launchCalls).toHaveLength(1)
expect(launchCalls[0]).toMatchObject({
description: "[team:core] worker_1",
agent: "sisyphus-junior",
parentSessionID: "ses-main",
parentMessageID: "msg-main",
parentAgent: "sisyphus",
})
//#when
const sent = await executeJsonTool(
tools,
"send_message",
{
team_name: "core",
type: "message",
recipient: "worker_1",
summary: "sync",
content: "Please update status.",
},
context,
) as { success: boolean }
//#then
expect(sent.success).toBe(true)
expect(resumeCalls).toHaveLength(1)
expect(resumeCalls[0].sessionId).toBe("ses-worker-1")
//#when
const invalidSender = await executeJsonTool(
tools,
"send_message",
{
team_name: "core",
type: "message",
sender: "ghost_user",
recipient: "worker_1",
summary: "sync",
content: "Please update status.",
},
context,
) as { error?: string }
//#then
expect(invalidSender.error).toBe("invalid_sender")
//#given
const createdTask = await executeJsonTool(
tools,
"team_task_create",
{
team_name: "core",
subject: "Follow-up",
description: "Collect teammate update",
},
context,
) as { id: string }
await executeJsonTool(
tools,
"team_task_update",
{
team_name: "core",
task_id: createdTask.id,
owner: "worker_1",
status: "in_progress",
},
context,
)
//#when
const killed = await executeJsonTool(
tools,
"force_kill_teammate",
{
team_name: "core",
agent_name: "worker_1",
},
context,
) as { success: boolean }
//#then
expect(killed.success).toBe(true)
expect(cancelCalls).toHaveLength(1)
expect(cancelCalls[0].taskId).toBe("bg-1")
expect(cancelCalls[0].options).toEqual(
expect.objectContaining({
source: "team_force_kill",
abortSession: true,
skipNotification: true,
}),
)
//#when
const configAfterKill = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as {
members: Array<{ name: string }>
}
const taskAfterKill = await executeJsonTool(
tools,
"team_task_get",
{
team_name: "core",
task_id: createdTask.id,
},
context,
) as { owner?: string; status: string }
//#then
expect(configAfterKill.members.some((member) => member.name === "worker_1")).toBe(false)
expect(taskAfterKill.owner).toBeUndefined()
expect(taskAfterKill.status).toBe("pending")
})
test("rolls back teammate and cancels background task when launch fails", async () => {
//#given
const { manager, cancelCalls } = createFailingLaunchManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, context)
//#when
const spawnResult = await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
},
context,
) as { error?: string }
//#then
expect(spawnResult.error).toBe("teammate_launch_failed:launch failed")
//#when
const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as {
members: Array<{ name: string }>
}
//#then
expect(config.members.map((member) => member.name)).toEqual(["team-lead"])
expect(cancelCalls).toHaveLength(1)
expect(cancelCalls[0].taskId).toBe("bg-fail")
expect(cancelCalls[0].options).toEqual(
expect.objectContaining({
source: "team_launch_failed",
abortSession: true,
skipNotification: true,
}),
)
})
test("returns explicit error on invalid model override format", async () => {
//#given
const { manager, launchCalls } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, context)
//#when
const spawnResult = await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
model: "invalid-format",
},
context,
) as { error?: string }
//#then
expect(spawnResult.error).toBe("invalid_model_override_format")
expect(launchCalls).toHaveLength(0)
//#when
const config = await executeJsonTool(tools, "read_config", { team_name: "core" }, context) as {
members: Array<{ name: string }>
}
//#then
expect(config.members.map((member) => member.name)).toEqual(["team-lead"])
})
test("read_inbox returns team_not_found for unknown team", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const context = createContext()
//#when
const result = await executeJsonTool(
tools,
"read_inbox",
{
team_name: "missing_team",
agent_name: "team-lead",
},
context,
) as { error?: string }
//#then
expect(result.error).toBe("team_not_found")
})
})