diff --git a/src/tools/agent-teams/team-task-store.test.ts b/src/tools/agent-teams/team-task-store.test.ts index 9314f3b94..c17b642e3 100644 --- a/src/tools/agent-teams/team-task-store.test.ts +++ b/src/tools/agent-teams/team-task-store.test.ts @@ -1,44 +1,460 @@ /// import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdtempSync, rmSync } from "node:fs" +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { dirname } from "node:path" +import { ensureDir } from "../../features/claude-tasks/storage" import { tmpdir } from "node:os" import { join } from "node:path" -import { createTeamConfig, deleteTeamData } from "./team-config-store" -import { createTeamTask, deleteTeamTaskFile, readTeamTask } from "./team-task-store" +import { + getTeamTaskPath, + readTeamTask, + writeTeamTask, + listTeamTasks, + deleteTeamTask, +} from "./team-task-store" +import type { TeamTask } from "./types" -describe("agent-teams task store", () => { +describe("getTeamTaskPath", () => { + test("returns correct file path for team task", () => { + //#given + const teamName = "my-team" + const taskId = "T-abc123" + + //#when + const result = getTeamTaskPath(teamName, taskId) + + //#then + expect(result).toContain("my-team") + expect(result).toContain("T-abc123.json") + }) +}) + +describe("readTeamTask", () => { let originalCwd: string let tempProjectDir: string beforeEach(() => { originalCwd = process.cwd() - tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-task-store-")) + tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-")) process.chdir(tempProjectDir) - createTeamConfig("core", "Core team", "ses-main", tempProjectDir, "sisyphus") }) afterEach(() => { - deleteTeamData("core") process.chdir(originalCwd) - rmSync(tempProjectDir, { recursive: true, force: true }) + if (existsSync(tempProjectDir)) { + rmSync(tempProjectDir, { recursive: true, force: true }) + } }) - test("creates and reads a task", () => { + test("returns null when task file does not exist", () => { //#given - const created = createTeamTask("core", "Subject", "Description") + const teamName = "nonexistent-team" + const taskId = "T-does-not-exist" //#when - const loaded = readTeamTask("core", created.id) + const result = readTeamTask(teamName, taskId) //#then - expect(loaded?.id).toBe(created.id) - expect(loaded?.subject).toBe("Subject") + expect(result).toBeNull() }) - test("rejects invalid team name and task id", () => { + test("returns task when valid task file exists", () => { + //#given + const task: TeamTask = { + id: "T-existing-task", + subject: "Test task", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_test", + } + writeTeamTask("test-team", "T-existing-task", task) + + //#when + const result = readTeamTask("test-team", "T-existing-task") + //#then - expect(() => readTeamTask("../../etc", "T-1")).toThrow("team_name_invalid") - expect(() => readTeamTask("core", "../../passwd")).toThrow("task_id_invalid") - expect(() => deleteTeamTaskFile("core", "../../passwd")).toThrow("task_id_invalid") + expect(result).not.toBeNull() + expect(result?.id).toBe("T-existing-task") + expect(result?.subject).toBe("Test task") + }) + + test("returns null when task file contains invalid JSON", () => { + //#given + const taskPath = getTeamTaskPath("invalid-team", "T-invalid-json") + const parentDir = dirname(taskPath) + rmSync(parentDir, { recursive: true, force: true }) + ensureDir(parentDir) + writeFileSync(taskPath, "{ invalid json }") + + //#when + const result = readTeamTask("invalid-team", "T-invalid-json") + + //#then + expect(result).toBeNull() + }) + + test("returns null when task file does not match schema", () => { + //#given + const taskPath = getTeamTaskPath("invalid-schema-team", "T-bad-schema") + const parentDir = dirname(taskPath) + rmSync(parentDir, { recursive: true, force: true }) + ensureDir(parentDir) + const invalidData = { id: "T-bad-schema" } + writeFileSync(taskPath, JSON.stringify(invalidData)) + + //#when + const result = readTeamTask("invalid-schema-team", "T-bad-schema") + + //#then + expect(result).toBeNull() + }) +}) + +describe("writeTeamTask", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + if (existsSync(tempProjectDir)) { + rmSync(tempProjectDir, { recursive: true, force: true }) + } + }) + + test("creates task file in team namespace", () => { + //#given + const task: TeamTask = { + id: "T-write-test", + subject: "Write test task", + description: "Test writing task", + status: "in_progress", + blocks: [], + blockedBy: [], + threadID: "ses_write", + } + + //#when + writeTeamTask("write-team", "T-write-test", task) + + //#then + const taskPath = getTeamTaskPath("write-team", "T-write-test") + expect(existsSync(taskPath)).toBe(true) + }) + + test("overwrites existing task file", () => { + //#given + const task: TeamTask = { + id: "T-overwrite-test", + subject: "Original subject", + description: "Original description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_original", + } + writeTeamTask("overwrite-team", "T-overwrite-test", task) + + const updatedTask: TeamTask = { + ...task, + subject: "Updated subject", + status: "completed", + } + + //#when + writeTeamTask("overwrite-team", "T-overwrite-test", updatedTask) + + //#then + const result = readTeamTask("overwrite-team", "T-overwrite-test") + expect(result?.subject).toBe("Updated subject") + expect(result?.status).toBe("completed") + }) + + test("creates team directory if it does not exist", () => { + //#given + const task: TeamTask = { + id: "T-new-dir", + subject: "New directory test", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_newdir", + } + + //#when + writeTeamTask("new-team-directory", "T-new-dir", task) + + //#then + const taskPath = getTeamTaskPath("new-team-directory", "T-new-dir") + expect(existsSync(taskPath)).toBe(true) + }) +}) + +describe("listTeamTasks", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + if (existsSync(tempProjectDir)) { + rmSync(tempProjectDir, { recursive: true, force: true }) + } + }) + + test("returns empty array when team has no tasks", () => { + //#given + // No tasks written + + //#when + const result = listTeamTasks("empty-team") + + //#then + expect(result).toEqual([]) + }) + + test("returns all tasks for a team", () => { + //#given + const task1: TeamTask = { + id: "T-task-1", + subject: "Task 1", + description: "First task", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_1", + } + const task2: TeamTask = { + id: "T-task-2", + subject: "Task 2", + description: "Second task", + status: "in_progress", + blocks: [], + blockedBy: [], + threadID: "ses_2", + } + writeTeamTask("list-test-team", "T-task-1", task1) + writeTeamTask("list-test-team", "T-task-2", task2) + + //#when + const result = listTeamTasks("list-test-team") + + //#then + expect(result).toHaveLength(2) + expect(result.some((t) => t.id === "T-task-1")).toBe(true) + expect(result.some((t) => t.id === "T-task-2")).toBe(true) + }) + + test("includes tasks with all statuses", () => { + //#given + const pendingTask: TeamTask = { + id: "T-pending", + subject: "Pending task", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_pending", + } + const inProgressTask: TeamTask = { + id: "T-in-progress", + subject: "In progress task", + description: "Test", + status: "in_progress", + blocks: [], + blockedBy: [], + threadID: "ses_inprogress", + } + const completedTask: TeamTask = { + id: "T-completed", + subject: "Completed task", + description: "Test", + status: "completed", + blocks: [], + blockedBy: [], + threadID: "ses_completed", + } + const deletedTask: TeamTask = { + id: "T-deleted", + subject: "Deleted task", + description: "Test", + status: "deleted", + blocks: [], + blockedBy: [], + threadID: "ses_deleted", + } + writeTeamTask("status-test-team", "T-pending", pendingTask) + writeTeamTask("status-test-team", "T-in-progress", inProgressTask) + writeTeamTask("status-test-team", "T-completed", completedTask) + writeTeamTask("status-test-team", "T-deleted", deletedTask) + + //#when + const result = listTeamTasks("status-test-team") + + //#then + expect(result).toHaveLength(4) + const statuses = result.map((t) => t.status) + expect(statuses).toContain("pending") + expect(statuses).toContain("in_progress") + expect(statuses).toContain("completed") + expect(statuses).toContain("deleted") + }) + + test("does not include tasks from other teams", () => { + //#given + const taskTeam1: TeamTask = { + id: "T-team1-task", + subject: "Team 1 task", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_team1", + } + const taskTeam2: TeamTask = { + id: "T-team2-task", + subject: "Team 2 task", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_team2", + } + writeTeamTask("team-1", "T-team1-task", taskTeam1) + writeTeamTask("team-2", "T-team2-task", taskTeam2) + + //#when + const result = listTeamTasks("team-1") + + //#then + expect(result).toHaveLength(1) + expect(result[0].id).toBe("T-team1-task") + }) +}) + +describe("deleteTeamTask", () => { + let originalCwd: string + let tempProjectDir: string + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "team-task-store-test-")) + process.chdir(tempProjectDir) + }) + + afterEach(() => { + process.chdir(originalCwd) + if (existsSync(tempProjectDir)) { + rmSync(tempProjectDir, { recursive: true, force: true }) + } + }) + + test("deletes existing task file", () => { + //#given + const task: TeamTask = { + id: "T-delete-me", + subject: "Delete this task", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_delete", + } + writeTeamTask("delete-test-team", "T-delete-me", task) + const taskPath = getTeamTaskPath("delete-test-team", "T-delete-me") + + //#when + deleteTeamTask("delete-test-team", "T-delete-me") + + //#then + expect(existsSync(taskPath)).toBe(false) + }) + + test("does not throw when task does not exist", () => { + //#given + // Task does not exist + + //#when + expect(() => deleteTeamTask("nonexistent-team", "T-does-not-exist")).not.toThrow() + + //#then + // No exception thrown + }) + + test("does not affect other tasks in same team", () => { + //#given + const task1: TeamTask = { + id: "T-keep-me", + subject: "Keep this task", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_keep", + } + const task2: TeamTask = { + id: "T-delete-me", + subject: "Delete this task", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_delete", + } + writeTeamTask("mixed-test-team", "T-keep-me", task1) + writeTeamTask("mixed-test-team", "T-delete-me", task2) + + //#when + deleteTeamTask("mixed-test-team", "T-delete-me") + + //#then + const remaining = listTeamTasks("mixed-test-team") + expect(remaining).toHaveLength(1) + expect(remaining[0].id).toBe("T-keep-me") + }) + + test("does not affect tasks from other teams", () => { + //#given + const task1: TeamTask = { + id: "T-task-1", + subject: "Task 1", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_1", + } + const task2: TeamTask = { + id: "T-task-2", + subject: "Task 2", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "ses_2", + } + writeTeamTask("team-a", "T-task-1", task1) + writeTeamTask("team-b", "T-task-2", task2) + + //#when + deleteTeamTask("team-a", "T-task-1") + + //#then + const remainingInTeamB = listTeamTasks("team-b") + expect(remainingInTeamB).toHaveLength(1) + expect(remainingInTeamB[0].id).toBe("T-task-2") }) }) diff --git a/src/tools/agent-teams/team-task-store.ts b/src/tools/agent-teams/team-task-store.ts index 6fb599b6d..10895eb4f 100644 --- a/src/tools/agent-teams/team-task-store.ts +++ b/src/tools/agent-teams/team-task-store.ts @@ -41,6 +41,8 @@ function withTaskLock(teamName: string, operation: () => T): T { } } +export { getTeamTaskPath } from "./paths" + export function readTeamTask(teamName: string, taskId: string): TeamTask | null { assertValidTeamName(teamName) assertValidTaskId(taskId) @@ -94,32 +96,33 @@ export function createTeamTask( } return withTaskLock(teamName, () => { + const taskId = generateTaskId() const task: TeamTask = { - id: generateTaskId(), + id: taskId, subject, description, activeForm, status: "pending", blocks: [], blockedBy: [], + threadID: `unknown_${taskId}`, ...(metadata ? { metadata } : {}), } const validated = TeamTaskSchema.parse(task) - writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated) + writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated) return validated }) } -export function writeTeamTask(teamName: string, task: TeamTask): TeamTask { +export function writeTeamTask(teamName: string, taskId: string, task: TeamTask): void { assertValidTeamName(teamName) - assertValidTaskId(task.id) + assertValidTaskId(taskId) const validated = TeamTaskSchema.parse(task) - writeJsonAtomic(getTeamTaskPath(teamName, validated.id), validated) - return validated + writeJsonAtomic(getTeamTaskPath(teamName, taskId), validated) } -export function deleteTeamTaskFile(teamName: string, taskId: string): void { +export function deleteTeamTask(teamName: string, taskId: string): void { assertValidTeamName(teamName) assertValidTaskId(taskId) const taskPath = getTeamTaskPath(teamName, taskId) @@ -128,6 +131,11 @@ export function deleteTeamTaskFile(teamName: string, taskId: string): void { } } +// Backward compatibility alias +export function deleteTeamTaskFile(teamName: string, taskId: string): void { + deleteTeamTask(teamName, taskId) +} + export function readTaskFromDirectory(taskDir: string, taskId: string): TeamTask | null { assertValidTaskId(taskId) return readJsonSafe(join(taskDir, `${taskId}.json`), TeamTaskSchema) @@ -146,7 +154,7 @@ export function resetOwnerTasks(teamName: string, ownerName: string): void { owner: undefined, status: task.status === "completed" ? "completed" : "pending", } - writeTeamTask(teamName, next) + writeTeamTask(teamName, next.id, next) } }) }