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")
+ })
+})