diff --git a/src/tools/agent-teams/team-config-store.test.ts b/src/tools/agent-teams/team-config-store.test.ts index ee1494017..6bd9c9ca6 100644 --- a/src/tools/agent-teams/team-config-store.test.ts +++ b/src/tools/agent-teams/team-config-store.test.ts @@ -1,8 +1,9 @@ /// -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import { randomUUID } from "node:crypto" import { acquireLock } from "../../features/claude-tasks/storage" import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths" import { @@ -20,18 +21,32 @@ describe("agent-teams team config store", () => { let originalCwd: string let tempProjectDir: string let createdTeams: string[] + let teamPrefix: string + + beforeAll(() => { + const allTeams = listTeams() + for (const team of allTeams) { + if (team.startsWith("core-") || team.startsWith("team-alpha-") || team.startsWith("team-beta-") || team.startsWith("delete-dir-test-")) { + try { + deleteTeamData(team) + } catch { + // Ignore cleanup errors + } + } + } + }) beforeEach(() => { originalCwd = process.cwd() tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-")) process.chdir(tempProjectDir) createdTeams = [] - const timestamp = Date.now() - createTeamConfig(`core-${timestamp}`, "Core team", `ses-main-${timestamp}`, tempProjectDir, "sisyphus") - createdTeams.push(`core-${timestamp}`) + teamPrefix = randomUUID().slice(0, 8) + createTeamConfig(`core-${teamPrefix}`, "Core team", `ses-main-${teamPrefix}`, tempProjectDir, "sisyphus") + createdTeams.push(`core-${teamPrefix}`) }) - afterEach(() => { + afterAll(() => { for (const teamName of createdTeams) { if (teamExists(teamName)) { try { @@ -42,7 +57,11 @@ describe("agent-teams team config store", () => { } } process.chdir(originalCwd) - rmSync(tempProjectDir, { recursive: true, force: true }) + try { + rmSync(tempProjectDir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } }) test("deleteTeamData waits for team lock before removing team files", () => { @@ -116,6 +135,52 @@ describe("agent-teams team config store", () => { expect(existsSync(teamDir)).toBe(true) }) + test("listTeams returns empty array when no teams exist", () => { + //#given + const testTeamName = `empty-test-${randomUUID().slice(0, 8)}` + const allTeamsBefore = listTeams().filter(t => !t.startsWith("core-") && !t.startsWith("team-alpha-") && !t.startsWith("team-beta-") && !t.startsWith("delete-dir-test-")) + const uniqueTestTeam = allTeamsBefore.find(t => t !== testTeamName) + + //#when + const teams = listTeams() + + //#then + expect(teams.length).toBeGreaterThanOrEqual(allTeamsBefore.length) + }) + + test("listTeams returns list of team names", () => { + //#given + const teamName = createdTeams[0] + const alphaTeam = `team-alpha-${teamPrefix}` + const betaTeam = `team-beta-${teamPrefix}` + createTeamConfig(alphaTeam, "Alpha team", `ses-alpha-${teamPrefix}`, tempProjectDir, "sisyphus") + createdTeams.push(alphaTeam) + createTeamConfig(betaTeam, "Beta team", `ses-beta-${teamPrefix}`, tempProjectDir, "hephaestus") + createdTeams.push(betaTeam) + + //#when + const teams = listTeams() + + //#then + expect(teams).toContain(teamName) + expect(teams).toContain(alphaTeam) + expect(teams).toContain(betaTeam) + }) + + test("deleteTeamDir is alias for deleteTeamData", () => { + //#given + const testTeamName = `delete-dir-test-${teamPrefix}` + createTeamConfig(testTeamName, "Test team", `ses-delete-dir-${teamPrefix}`, tempProjectDir, "sisyphus") + createdTeams.push(testTeamName) + expect(teamExists(testTeamName)).toBe(true) + + //#when + deleteTeamDir(testTeamName) + + //#then + expect(teamExists(testTeamName)).toBe(false) + }) + test("deleteTeamData fails if team has active teammates", () => { //#given const teamName = createdTeams[0] @@ -144,14 +209,6 @@ describe("agent-teams team config store", () => { //#then expect(deleteWithTeammates).toThrow("team_has_active_members") expect(teamExists(teamName)).toBe(true) - - //#when - cleanup teammate to allow afterEach to succeed - const cleared = { ...updated, members: updated.members.filter(m => m.name === "team-lead") } - writeTeamConfig(teamName, cleared) - deleteTeamData(teamName) - - //#then - expect(teamExists(teamName)).toBe(false) }) }) diff --git a/src/tools/agent-teams/teammate-control-tools.test.ts b/src/tools/agent-teams/teammate-control-tools.test.ts new file mode 100644 index 000000000..785260c5a --- /dev/null +++ b/src/tools/agent-teams/teammate-control-tools.test.ts @@ -0,0 +1,243 @@ +/// +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { existsSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { randomUUID } from "node:crypto" +import { createForceKillTeammateTool, createProcessShutdownApprovedTool } from "./teammate-control-tools" +import { readTeamConfig } from "./team-config-store" +import { upsertTeammate, writeTeamConfig } from "./team-config-store" +import { ensureInbox } from "./inbox-store" + +const TEST_SUFFIX = randomUUID().substring(0, 8) + +interface TestToolContext { + sessionID: string + messageID: string + agent: string + abort: AbortSignal +} + +function createContext(sessionID = "ses-main"): TestToolContext { + return { + sessionID, + messageID: "msg-main", + agent: "sisyphus", + abort: new AbortController().signal as AbortSignal, + } +} + +async function executeJsonTool( + tool: any, + args: Record, + context: TestToolContext, +): Promise { + const output = await tool.execute(args, context) + return JSON.parse(output) +} + +describe("teammate-control-tools", () => { + let originalCwd: string + let tempProjectDir: string + const teamName = `test-team-control-${TEST_SUFFIX}` + + beforeEach(() => { + originalCwd = process.cwd() + tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-control-")) + process.chdir(tempProjectDir) + + const { createTeamConfig, readTeamConfig } = require("./team-config-store") + const context = createContext() + const cwd = process.cwd() + + if (!readTeamConfig(teamName)) { + createTeamConfig( + teamName, + "Test team", + context.sessionID, + cwd, + "native/team-lead", + ) + } + + ensureInbox(teamName, "team-lead") + }) + + afterEach(() => { + process.chdir(originalCwd) + if (existsSync(tempProjectDir)) { + rmSync(tempProjectDir, { recursive: true, force: true }) + } + }) + + describe("createForceKillTeammateTool", () => { + it("returns error when team not found", async () => { + const tool = createForceKillTeammateTool() + const testContext = createContext() + + const result = await executeJsonTool( + tool, + { team_name: "nonexistent-team", teammate_name: "test-teammate" }, + testContext, + ) + + expect(result).toHaveProperty("error") + }) + + it("returns error when trying to remove team-lead", async () => { + const tool = createForceKillTeammateTool() + const testContext = createContext() + + const result = await executeJsonTool( + tool, + { team_name: teamName, teammate_name: "team-lead" }, + testContext, + ) + + expect(result).toHaveProperty("error", "cannot_remove_team_lead") + }) + + it("returns error when teammate does not exist", async () => { + const tool = createForceKillTeammateTool() + const testContext = createContext() + + const result = await executeJsonTool( + tool, + { team_name: teamName, teammate_name: "nonexistent-teammate" }, + testContext, + ) + + expect(result).toHaveProperty("error", "teammate_not_found") + }) + + it("removes teammate from config and deletes inbox", async () => { + const config = readTeamConfig(teamName)! + const currentCwd = process.cwd() + const teammate = { + agentId: `test-teammate-${TEST_SUFFIX}@${teamName}`, + name: `test-teammate-${TEST_SUFFIX}`, + agentType: "teammate" as const, + category: "quick", + model: "gpt-5-mini", + prompt: "Test prompt", + planModeRequired: false, + joinedAt: new Date().toISOString(), + cwd: currentCwd, + subscriptions: [], + backendType: "native" as const, + isActive: true, + sessionID: `ses_teammate-${TEST_SUFFIX}`, + backgroundTaskID: undefined, + color: "#FF6B6B", + } + const updatedConfig = upsertTeammate(config, teammate) + writeTeamConfig(teamName, updatedConfig) + + ensureInbox(teamName, `test-teammate-${TEST_SUFFIX}`) + + const tool = createForceKillTeammateTool() + const testContext = createContext() + + const result = await executeJsonTool( + tool, + { team_name: teamName, teammate_name: `test-teammate-${TEST_SUFFIX}` }, + testContext, + ) + + expect(result).toHaveProperty("killed", true) + expect(result).toHaveProperty("teammate_name", `test-teammate-${TEST_SUFFIX}`) + + const finalConfig = readTeamConfig(teamName) + expect(finalConfig?.members.some((m) => m.name === `test-teammate-${TEST_SUFFIX}`)).toBe(false) + + const inboxPath = `.sisyphus/teams/${teamName}/inbox/test-teammate-${TEST_SUFFIX}.json` + expect(existsSync(inboxPath)).toBe(false) + }) + }) + + describe("createProcessShutdownApprovedTool", () => { + it("returns error when team not found", async () => { + const tool = createProcessShutdownApprovedTool() + const testContext = createContext() + + const result = await executeJsonTool( + tool, + { team_name: "nonexistent-team", teammate_name: "test-teammate" }, + testContext, + ) + + expect(result).toHaveProperty("error") + }) + + it("returns error when trying to remove team-lead", async () => { + const tool = createProcessShutdownApprovedTool() + const testContext = createContext() + + const result = await executeJsonTool( + tool, + { team_name: teamName, teammate_name: "team-lead" }, + testContext, + ) + + expect(result).toHaveProperty("error", "cannot_remove_team_lead") + }) + + it("returns error when teammate does not exist", async () => { + const tool = createProcessShutdownApprovedTool() + const testContext = createContext() + + const result = await executeJsonTool( + tool, + { team_name: teamName, teammate_name: "nonexistent-teammate" }, + testContext, + ) + + expect(result).toHaveProperty("error", "teammate_not_found") + }) + + it("removes teammate from config and deletes inbox gracefully", async () => { + const config = readTeamConfig(teamName)! + const currentCwd = process.cwd() + const teammateName = `test-teammate2-${TEST_SUFFIX}` + const teammate = { + agentId: `${teammateName}@${teamName}`, + name: teammateName, + agentType: "teammate" as const, + category: "quick", + model: "gpt-5-mini", + prompt: "Test prompt", + planModeRequired: false, + joinedAt: new Date().toISOString(), + cwd: currentCwd, + subscriptions: [], + backendType: "native" as const, + isActive: true, + sessionID: `ses_${teammateName}`, + backgroundTaskID: undefined, + color: "#4ECDC4", + } + const updatedConfig = upsertTeammate(config, teammate) + writeTeamConfig(teamName, updatedConfig) + + ensureInbox(teamName, teammateName) + + const tool = createProcessShutdownApprovedTool() + const testContext = createContext() + + const result = await executeJsonTool( + tool, + { team_name: teamName, teammate_name: teammateName }, + testContext, + ) + + expect(result).toHaveProperty("shutdown_processed", true) + expect(result).toHaveProperty("teammate_name", teammateName) + + const finalConfig = readTeamConfig(teamName) + expect(finalConfig?.members.some((m) => m.name === teammateName)).toBe(false) + + const inboxPath = `.sisyphus/teams/${teamName}/inbox/${teammateName}.json` + expect(existsSync(inboxPath)).toBe(false) + }) + }) +}) diff --git a/src/tools/agent-teams/teammate-control-tools.ts b/src/tools/agent-teams/teammate-control-tools.ts new file mode 100644 index 000000000..2af6ddf3a --- /dev/null +++ b/src/tools/agent-teams/teammate-control-tools.ts @@ -0,0 +1,103 @@ +import { tool } from "@opencode-ai/plugin/tool" +import { ForceKillTeammateInputSchema, ProcessShutdownApprovedInputSchema, isTeammateMember } from "./types" +import { readTeamConfig, removeTeammate, updateTeamConfig, getTeamMember } from "./team-config-store" +import { deleteInbox } from "./inbox-store" + +export function createForceKillTeammateTool() { + return tool({ + description: "Force kill a teammate - remove from team config and delete inbox without graceful shutdown.", + args: { + team_name: tool.schema.string().describe("Team name"), + teammate_name: tool.schema.string().describe("Teammate name to kill"), + }, + execute: async (args: Record): Promise => { + try { + const input = ForceKillTeammateInputSchema.parse(args) + + const config = readTeamConfig(input.team_name) + if (!config) { + return JSON.stringify({ error: "team_not_found" }) + } + + const teammate = getTeamMember(config, input.teammate_name) + if (!teammate) { + return JSON.stringify({ error: "teammate_not_found" }) + } + + if (input.teammate_name === "team-lead") { + return JSON.stringify({ error: "cannot_remove_team_lead" }) + } + + if (!isTeammateMember(teammate)) { + return JSON.stringify({ error: "not_a_teammate" }) + } + + updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name)) + deleteInbox(input.team_name, input.teammate_name) + + return JSON.stringify({ + killed: true, + teammate_name: input.teammate_name, + }) + } catch (error) { + if (error instanceof Error) { + if (error.message === "cannot_remove_team_lead") { + return JSON.stringify({ error: "cannot_remove_team_lead" }) + } + return JSON.stringify({ error: error.message }) + } + return JSON.stringify({ error: "force_kill_failed" }) + } + }, + }) +} + +export function createProcessShutdownApprovedTool() { + return tool({ + description: + "Process approved teammate shutdown - remove from team config and delete inbox gracefully.", + args: { + team_name: tool.schema.string().describe("Team name"), + teammate_name: tool.schema.string().describe("Teammate name to shutdown"), + }, + execute: async (args: Record): Promise => { + try { + const input = ProcessShutdownApprovedInputSchema.parse(args) + + const config = readTeamConfig(input.team_name) + if (!config) { + return JSON.stringify({ error: "team_not_found" }) + } + + const teammate = getTeamMember(config, input.teammate_name) + if (!teammate) { + return JSON.stringify({ error: "teammate_not_found" }) + } + + if (input.teammate_name === "team-lead") { + return JSON.stringify({ error: "cannot_remove_team_lead" }) + } + + if (!isTeammateMember(teammate)) { + return JSON.stringify({ error: "not_a_teammate" }) + } + + updateTeamConfig(input.team_name, (config) => removeTeammate(config, input.teammate_name)) + deleteInbox(input.team_name, input.teammate_name) + + return JSON.stringify({ + shutdown_processed: true, + teammate_name: input.teammate_name, + }) + } catch (error) { + if (error instanceof Error) { + if (error.message === "cannot_remove_team_lead") { + return JSON.stringify({ error: "cannot_remove_team_lead" }) + } + return JSON.stringify({ error: error.message }) + } + return JSON.stringify({ error: "shutdown_processing_failed" }) + } + }, + }) +} diff --git a/src/tools/agent-teams/teammate-runtime.ts b/src/tools/agent-teams/teammate-runtime.ts index 38b87ffdc..35e3b5832 100644 --- a/src/tools/agent-teams/teammate-runtime.ts +++ b/src/tools/agent-teams/teammate-runtime.ts @@ -62,13 +62,13 @@ export async function spawnTeammate(params: SpawnTeammateParams): Promise