oh-my-opencode/src/tools/agent-teams/team-lifecycle-tools.test.ts
YeonGyu-Kim aa83b05f1f feat(agent-teams): add team_create and team_delete tools
- Implement tool factories for team lifecycle management
- team_create: Creates team with initial config, returns team info
- team_delete: Deletes team if no active teammates
- Name validation: ^[A-Za-z0-9_-]+$, max 64 chars
- 9 comprehensive tests with unique team names per test

Task 7/25 complete
2026-02-14 13:33:30 +09:00

298 lines
8.6 KiB
TypeScript

/// <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 { randomUUID } from "node:crypto"
import { createTeamCreateTool, createTeamDeleteTool } from "./team-lifecycle-tools"
import { getTeamConfigPath, getTeamDir, getTeamTaskDir } from "./paths"
import { readTeamConfig, listTeammates } from "./team-config-store"
import { getTeamsRootDir, getTeamTasksRootDir } from "./paths"
import { deleteTeamData } from "./team-config-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: ReturnType<typeof createTeamCreateTool | typeof createTeamDeleteTool>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tool.execute(args, context)
return JSON.parse(output)
}
describe("team_lifecycle tools", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-lifecycle-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
describe("team_create", () => {
test("creates team with valid name and description", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const teamName = `test-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(tool, {
team_name: teamName,
description: "My test team",
}, context)
//#then
expect(result).toEqual({
team_name: teamName,
config_path: getTeamConfigPath(teamName),
lead_agent_id: `team-lead@${teamName}`,
})
// Verify team was actually created
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
expect(teamConfig?.name).toBe(teamName)
expect(teamConfig?.description).toBe("My test team")
expect(teamConfig?.leadAgentId).toBe(`team-lead@${teamName}`)
expect(teamConfig?.leadSessionId).toBe("ses-main")
expect(teamConfig?.members).toHaveLength(1)
expect(teamConfig?.members[0].agentType).toBe("team-lead")
})
test("creates team with only name (description optional)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const teamName = `minimal-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(tool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
team_name: teamName,
config_path: getTeamConfigPath(teamName),
lead_agent_id: `team-lead@${teamName}`,
})
const teamConfig = readTeamConfig(teamName)
expect(teamConfig?.description).toBe("")
})
test("validates team name format (alphanumeric, hyphens, underscores only)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
//#when
const result = await executeJsonTool(tool, {
team_name: "invalid@name",
}, context)
//#then
expect(result).toEqual({
error: "team_create_failed",
})
})
test("validates team name max length (64 chars)", async () => {
//#given
const tool = createTeamCreateTool()
const context = createContext()
const longName = "a".repeat(65)
//#when
const result = await executeJsonTool(tool, {
team_name: longName,
}, context)
//#then
expect(result).toEqual({
error: "team_create_failed",
})
})
test("rejects duplicate team names", async () => {
//#given
const tool = createTeamCreateTool()
const context1 = createContext("ses-1")
const context2 = createContext("ses-2")
const teamName = `duplicate-team-${TEST_SUFFIX}`
// Create team first
await executeJsonTool(tool, {
team_name: teamName,
}, context1)
//#when - try to create same team again
const result = await executeJsonTool(tool, {
team_name: teamName,
}, context2)
//#then
expect(result).toEqual({
error: "team_already_exists",
})
// Verify first team still exists
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
})
})
describe("team_delete", () => {
test("deletes team when no active teammates", async () => {
//#given
const createTool = createTeamCreateTool()
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `test-delete-team-${TEST_SUFFIX}`
// Create team first
await executeJsonTool(createTool, {
team_name: teamName,
}, context)
//#when
const result = await executeJsonTool(deleteTool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
deleted: true,
team_name: teamName,
})
// Verify team dir is deleted
expect(existsSync(getTeamDir(teamName))).toBe(false)
expect(existsSync(getTeamTaskDir(teamName))).toBe(false)
expect(existsSync(getTeamConfigPath(teamName))).toBe(false)
})
test("blocks deletion when team has active teammates", async () => {
//#given
const createTool = createTeamCreateTool()
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `team-with-members-${TEST_SUFFIX}`
// Create team
await executeJsonTool(createTool, {
team_name: teamName,
}, context)
// Add a teammate by modifying config directly for test
const teamConfig = readTeamConfig(teamName)
expect(teamConfig).not.toBeNull()
// Manually add a teammate to simulate active member
const { writeTeamConfig } = await import("./team-config-store")
if (teamConfig) {
writeTeamConfig(teamName, {
...teamConfig,
members: [
...teamConfig.members,
{
agentId: "teammate-1",
name: "test-teammate",
agentType: "teammate",
color: "#FF6B6B",
category: "test",
model: "test-model",
prompt: "Test prompt",
planModeRequired: false,
joinedAt: new Date().toISOString(),
cwd: "/tmp",
subscriptions: [],
backendType: "native",
isActive: true,
sessionID: "test-session",
},
],
})
}
//#when
const result = await executeJsonTool(deleteTool, {
team_name: teamName,
}, context)
//#then
expect(result).toEqual({
error: "team_has_active_members",
members: ["test-teammate"],
})
// Cleanup - manually remove teammates first, then delete
const configApi = await import("./team-config-store")
const cleanupConfig = readTeamConfig(teamName)
if (cleanupConfig) {
configApi.writeTeamConfig(teamName, {
...cleanupConfig,
members: cleanupConfig.members.filter((m) => m.agentType === "team-lead"),
})
configApi.deleteTeamData(teamName)
}
})
test("validates team name format on deletion", async () => {
//#given
const deleteTool = createTeamDeleteTool()
const context = createContext()
const teamName = `invalid-team-${TEST_SUFFIX}`
//#when
const result = await executeJsonTool(deleteTool, {
team_name: "invalid@name",
}, context)
//#then - Zod returns detailed validation error array
const parsedResult = result as { error: string }
expect(parsedResult.error).toContain("Team name must contain only letters")
})
test("returns error for non-existent team", async () => {
//#given
const deleteTool = createTeamDeleteTool()
const context = createContext()
//#when
const result = await executeJsonTool(deleteTool, {
team_name: "non-existent-team",
}, context)
//#then
expect(result).toEqual({
error: "team_not_found",
})
})
})
})