diff --git a/src/tools/agent-teams/name-validation.test.ts b/src/tools/agent-teams/name-validation.test.ts new file mode 100644 index 00000000..9dd0b124 --- /dev/null +++ b/src/tools/agent-teams/name-validation.test.ts @@ -0,0 +1,58 @@ +/// +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") + }) +}) diff --git a/src/tools/agent-teams/paths.test.ts b/src/tools/agent-teams/paths.test.ts new file mode 100644 index 00000000..ab5de9a1 --- /dev/null +++ b/src/tools/agent-teams/paths.test.ts @@ -0,0 +1,80 @@ +/// +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`)) + }) +}) diff --git a/src/tools/agent-teams/team-task-dependency.test.ts b/src/tools/agent-teams/team-task-dependency.test.ts new file mode 100644 index 00000000..0aea66d8 --- /dev/null +++ b/src/tools/agent-teams/team-task-dependency.test.ts @@ -0,0 +1,94 @@ +/// +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([ + ["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([["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([ + ["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([ + ["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", + ) + }) +}) diff --git a/src/tools/agent-teams/tools.functional.test.ts b/src/tools/agent-teams/tools.functional.test.ts new file mode 100644 index 00000000..ab3cbbf9 --- /dev/null +++ b/src/tools/agent-teams/tools.functional.test.ts @@ -0,0 +1,345 @@ +/// +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() + 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, + toolName: keyof ReturnType, + args: Record, + context: TestToolContext, +): Promise { + 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") + }) +})