test(agent-teams): add functional and utility coverage
This commit is contained in:
committed by
YeonGyu-Kim
parent
766794e0f5
commit
db08cc22cc
58
src/tools/agent-teams/name-validation.test.ts
Normal file
58
src/tools/agent-teams/name-validation.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { validateAgentName, validateTeamName } from "./name-validation"
|
||||
|
||||
describe("agent-teams name validation", () => {
|
||||
test("accepts valid team names", () => {
|
||||
//#given
|
||||
const validNames = ["team_1", "alpha-team", "A1"]
|
||||
|
||||
//#when
|
||||
const result = validNames.map(validateTeamName)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual([null, null, null])
|
||||
})
|
||||
|
||||
test("rejects invalid and empty team names", () => {
|
||||
//#given
|
||||
const blank = ""
|
||||
const invalid = "team space"
|
||||
const tooLong = "a".repeat(65)
|
||||
|
||||
//#when
|
||||
const blankResult = validateTeamName(blank)
|
||||
const invalidResult = validateTeamName(invalid)
|
||||
const tooLongResult = validateTeamName(tooLong)
|
||||
|
||||
//#then
|
||||
expect(blankResult).toBe("team_name_required")
|
||||
expect(invalidResult).toBe("team_name_invalid")
|
||||
expect(tooLongResult).toBe("team_name_too_long")
|
||||
})
|
||||
|
||||
test("rejects reserved teammate name", () => {
|
||||
//#given
|
||||
const reservedName = "team-lead"
|
||||
|
||||
//#when
|
||||
const result = validateAgentName(reservedName)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("agent_name_reserved")
|
||||
})
|
||||
|
||||
test("validates regular agent names", () => {
|
||||
//#given
|
||||
const valid = "worker_1"
|
||||
const invalid = "worker one"
|
||||
|
||||
//#when
|
||||
const validResult = validateAgentName(valid)
|
||||
const invalidResult = validateAgentName(invalid)
|
||||
|
||||
//#then
|
||||
expect(validResult).toBeNull()
|
||||
expect(invalidResult).toBe("agent_name_invalid")
|
||||
})
|
||||
})
|
||||
80
src/tools/agent-teams/paths.test.ts
Normal file
80
src/tools/agent-teams/paths.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
getAgentTeamsRootDir,
|
||||
getTeamConfigPath,
|
||||
getTeamDir,
|
||||
getTeamInboxDir,
|
||||
getTeamInboxPath,
|
||||
getTeamTaskDir,
|
||||
getTeamTaskPath,
|
||||
getTeamsRootDir,
|
||||
getTeamTasksRootDir,
|
||||
} from "./paths"
|
||||
|
||||
describe("agent-teams paths", () => {
|
||||
let originalCwd: string
|
||||
let tempProjectDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd()
|
||||
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-paths-"))
|
||||
process.chdir(tempProjectDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd)
|
||||
rmSync(tempProjectDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("uses project-local .sisyphus directory as storage root", () => {
|
||||
//#given
|
||||
const expectedRoot = join(tempProjectDir, ".sisyphus", "agent-teams")
|
||||
|
||||
//#when
|
||||
const root = getAgentTeamsRootDir()
|
||||
|
||||
//#then
|
||||
expect(root).toBe(expectedRoot)
|
||||
})
|
||||
|
||||
test("builds expected teams and tasks root directories", () => {
|
||||
//#given
|
||||
const expectedRoot = join(tempProjectDir, ".sisyphus", "agent-teams")
|
||||
|
||||
//#when
|
||||
const teamsRoot = getTeamsRootDir()
|
||||
const tasksRoot = getTeamTasksRootDir()
|
||||
|
||||
//#then
|
||||
expect(teamsRoot).toBe(join(expectedRoot, "teams"))
|
||||
expect(tasksRoot).toBe(join(expectedRoot, "tasks"))
|
||||
})
|
||||
|
||||
test("builds team-scoped config, inbox, and task file paths", () => {
|
||||
//#given
|
||||
const teamName = "alpha_team"
|
||||
const agentName = "worker_1"
|
||||
const taskId = "T-123"
|
||||
const expectedTeamDir = join(getTeamsRootDir(), teamName)
|
||||
|
||||
//#when
|
||||
const teamDir = getTeamDir(teamName)
|
||||
const configPath = getTeamConfigPath(teamName)
|
||||
const inboxDir = getTeamInboxDir(teamName)
|
||||
const inboxPath = getTeamInboxPath(teamName, agentName)
|
||||
const taskDir = getTeamTaskDir(teamName)
|
||||
const taskPath = getTeamTaskPath(teamName, taskId)
|
||||
|
||||
//#then
|
||||
expect(teamDir).toBe(expectedTeamDir)
|
||||
expect(configPath).toBe(join(expectedTeamDir, "config.json"))
|
||||
expect(inboxDir).toBe(join(expectedTeamDir, "inboxes"))
|
||||
expect(inboxPath).toBe(join(expectedTeamDir, "inboxes", `${agentName}.json`))
|
||||
expect(taskDir).toBe(join(getTeamTasksRootDir(), teamName))
|
||||
expect(taskPath).toBe(join(getTeamTasksRootDir(), teamName, `${taskId}.json`))
|
||||
})
|
||||
})
|
||||
94
src/tools/agent-teams/team-task-dependency.test.ts
Normal file
94
src/tools/agent-teams/team-task-dependency.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
addPendingEdge,
|
||||
createPendingEdgeMap,
|
||||
ensureDependenciesCompleted,
|
||||
ensureForwardStatusTransition,
|
||||
wouldCreateCycle,
|
||||
} from "./team-task-dependency"
|
||||
import type { TeamTask, TeamTaskStatus } from "./types"
|
||||
|
||||
function createTask(id: string, status: TeamTaskStatus, blockedBy: string[] = []): TeamTask {
|
||||
return {
|
||||
id,
|
||||
subject: `Task ${id}`,
|
||||
description: `Description ${id}`,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
}
|
||||
}
|
||||
|
||||
describe("agent-teams task dependency utilities", () => {
|
||||
test("detects cycle from existing blockedBy chain", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["A", createTask("A", "pending", ["B"])],
|
||||
["B", createTask("B", "pending")],
|
||||
])
|
||||
const pending = createPendingEdgeMap()
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(true)
|
||||
})
|
||||
|
||||
test("detects cycle from pending edge map", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([["A", createTask("A", "pending")]])
|
||||
const pending = createPendingEdgeMap()
|
||||
addPendingEdge(pending, "A", "B")
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("B", "A", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when dependency graph has no cycle", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["A", createTask("A", "pending")],
|
||||
["B", createTask("B", "pending", ["A"])],
|
||||
])
|
||||
const pending = createPendingEdgeMap()
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#when
|
||||
const hasCycle = wouldCreateCycle("C", "B", pending, readTask)
|
||||
|
||||
//#then
|
||||
expect(hasCycle).toBe(false)
|
||||
})
|
||||
|
||||
test("allows forward status transitions and blocks backward transitions", () => {
|
||||
//#then
|
||||
expect(() => ensureForwardStatusTransition("pending", "in_progress")).not.toThrow()
|
||||
expect(() => ensureForwardStatusTransition("in_progress", "completed")).not.toThrow()
|
||||
expect(() => ensureForwardStatusTransition("in_progress", "pending")).toThrow(
|
||||
"invalid_status_transition:in_progress->pending",
|
||||
)
|
||||
})
|
||||
|
||||
test("requires blockers to be completed for in_progress/completed", () => {
|
||||
//#given
|
||||
const tasks = new Map<string, TeamTask>([
|
||||
["done", createTask("done", "completed")],
|
||||
["wait", createTask("wait", "pending")],
|
||||
])
|
||||
const readTask = (id: string) => tasks.get(id) ?? null
|
||||
|
||||
//#then
|
||||
expect(() => ensureDependenciesCompleted("pending", ["wait"], readTask)).not.toThrow()
|
||||
expect(() => ensureDependenciesCompleted("in_progress", ["done"], readTask)).not.toThrow()
|
||||
expect(() => ensureDependenciesCompleted("completed", ["wait"], readTask)).toThrow(
|
||||
"blocked_by_incomplete:wait:pending",
|
||||
)
|
||||
})
|
||||
})
|
||||
345
src/tools/agent-teams/tools.functional.test.ts
Normal file
345
src/tools/agent-teams/tools.functional.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/// <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 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)
|
||||
|
||||
//#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)
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
//#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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user