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
This commit is contained in:
YeonGyu-Kim
2026-02-11 22:34:47 +09:00
parent d67138575c
commit aa83b05f1f
3 changed files with 410 additions and 88 deletions

View File

@@ -3,9 +3,14 @@ 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 { getTeamDir } from "./paths"
import { createAgentTeamsTools } from "./tools"
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
@@ -19,21 +24,20 @@ function createContext(sessionID = "ses-main"): TestToolContext {
sessionID,
messageID: "msg-main",
agent: "sisyphus",
abort: new AbortController().signal,
abort: new AbortController().signal as AbortSignal,
}
}
async function executeJsonTool(
tools: ReturnType<typeof createAgentTeamsTools>,
toolName: keyof ReturnType<typeof createAgentTeamsTools>,
tool: ReturnType<typeof createTeamCreateTool | typeof createTeamDeleteTool>,
args: Record<string, unknown>,
context: TestToolContext,
): Promise<unknown> {
const output = await tools[toolName].execute(args, context)
const output = await tool.execute(args, context)
return JSON.parse(output)
}
describe("agent-teams team lifecycle tools", () => {
describe("team_lifecycle tools", () => {
let originalCwd: string
let tempProjectDir: string
@@ -48,22 +52,246 @@ describe("agent-teams team lifecycle tools", () => {
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("team_delete requires lead session authorization", async () => {
//#given
const tools = createAgentTeamsTools({} as BackgroundManager)
const leadContext = createContext("ses-main")
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
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 unauthorized = await executeJsonTool(
tools,
"team_delete",
{ team_name: "core" },
createContext("ses-intruder"),
) as { error?: string }
//#when
const result = await executeJsonTool(tool, {
team_name: teamName,
description: "My test team",
}, context)
//#then
expect(unauthorized.error).toBe("unauthorized_lead_session")
expect(existsSync(getTeamDir("core"))).toBe(true)
//#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",
})
})
})
})

View File

@@ -1,4 +1,5 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { z } from "zod"
import { getTeamConfigPath } from "./paths"
import { validateTeamName } from "./name-validation"
import { ensureInbox } from "./inbox-store"
@@ -23,7 +24,7 @@ function resolveReaderFromContext(config: TeamConfig, context: TeamToolContext):
function toPublicTeamConfig(config: TeamConfig): {
team_name: string
description: string
description: string | undefined
lead_agent_id: string
teammates: Array<{ name: string }>
} {
@@ -36,70 +37,87 @@ function toPublicTeamConfig(config: TeamConfig): {
}
export function createTeamCreateTool(): ToolDefinition {
return tool({
description: "Create a team workspace with config, inboxes, and task storage.",
args: {
team_name: tool.schema.string().describe("Team name (letters, numbers, hyphens, underscores)"),
description: tool.schema.string().optional().describe("Team description"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamCreateInputSchema.parse(args)
const nameError = validateTeamName(input.team_name)
if (nameError) {
return JSON.stringify({ error: nameError })
}
return tool({
description: "Create a team workspace with config, inboxes, and task storage.",
args: {
team_name: tool.schema.string().describe("Team name (letters, numbers, hyphens, underscores)"),
description: tool.schema.string().optional().describe("Team description"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamCreateInputSchema.parse(args)
const config = createTeamConfig(
input.team_name,
input.description ?? "",
context.sessionID,
process.cwd(),
"native/team-lead",
)
ensureInbox(config.name, "team-lead")
const config = createTeamConfig(
input.team_name,
input.description ?? "",
context.sessionID,
process.cwd(),
"native/team-lead",
)
ensureInbox(config.name, "team-lead")
return JSON.stringify({
team_name: config.name,
team_file_path: getTeamConfigPath(config.name),
lead_agent_id: config.leadAgentId,
})
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_create_failed" })
}
},
})
}
return JSON.stringify({
team_name: config.name,
config_path: getTeamConfigPath(config.name) as string,
lead_agent_id: config.leadAgentId,
})
} catch (error) {
if (error instanceof Error && error.message === "team_already_exists") {
return JSON.stringify({ error: error.message })
}
return JSON.stringify({ error: "team_create_failed" })
}
},
})
}
export function createTeamDeleteTool(): ToolDefinition {
return tool({
description: "Delete a team and its stored data. Fails if teammates still exist.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamDeleteInputSchema.parse(args)
const config = readTeamConfigOrThrow(input.team_name)
if (context.sessionID !== config.leadSessionId) {
return JSON.stringify({ error: "unauthorized_lead_session" })
}
const teammates = listTeammates(config)
if (teammates.length > 0) {
return JSON.stringify({
error: "team_has_active_members",
members: teammates.map((member) => member.name),
})
}
return tool({
description: "Delete a team and its stored data. Fails if teammates still exist.",
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>, _context: TeamToolContext): Promise<string> => {
let teamName: string | undefined
deleteTeamData(input.team_name)
return JSON.stringify({ success: true, team_name: input.team_name })
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_delete_failed" })
}
},
})
}
try {
const input = TeamDeleteInputSchema.parse(args)
teamName = input.team_name
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
const teammates = listTeammates(config)
if (teammates.length > 0) {
return JSON.stringify({
error: "team_has_active_members",
members: teammates.map((member) => member.name),
})
}
deleteTeamData(input.team_name)
return JSON.stringify({ deleted: true, team_name: input.team_name })
} catch (error) {
if (error instanceof Error) {
if (error.message === "team_has_active_members") {
const config = readTeamConfig(teamName!)
const activeMembers = config ? listTeammates(config) : []
return JSON.stringify({
error: "team_has_active_members",
members: activeMembers.map((member) => member.name),
})
}
if (error.message === "team_not_found") {
return JSON.stringify({ error: "team_not_found" })
}
return JSON.stringify({ error: error.message })
}
return JSON.stringify({ error: "team_delete_failed" })
}
},
})
}
export function createTeamReadConfigTool(): ToolDefinition {
return tool({

View File

@@ -115,38 +115,49 @@ export const TeamDeleteInputSchema = z.object({
export type TeamDeleteInput = z.infer<typeof TeamDeleteInputSchema>
const teamNameField = z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64)
const senderField = z.string().optional()
export const SendMessageInputSchema = z.discriminatedUnion("type", [
z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
team_name: teamNameField,
type: z.literal("message"),
recipient: z.string().min(1),
content: z.string().optional(),
summary: z.string().optional(),
sender: senderField,
}),
z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
team_name: teamNameField,
type: z.literal("broadcast"),
content: z.string().optional(),
summary: z.string().optional(),
sender: senderField,
}),
z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
team_name: teamNameField,
type: z.literal("shutdown_request"),
recipient: z.string().min(1),
content: z.string().optional(),
summary: z.string().optional(),
sender: senderField,
}),
z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
team_name: teamNameField,
type: z.literal("shutdown_response"),
request_id: z.string().min(1),
approve: z.boolean(),
content: z.string().optional(),
sender: senderField,
}),
z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
team_name: teamNameField,
type: z.literal("plan_approval_response"),
request_id: z.string().min(1),
approve: z.boolean(),
recipient: z.string().optional(),
content: z.string().optional(),
sender: senderField,
}),
])
@@ -183,9 +194,74 @@ export const ForceKillTeammateInputSchema = z.object({
export type ForceKillTeammateInput = z.infer<typeof ForceKillTeammateInputSchema>
export type TeamToolContext = {
sessionID: string
messageID: string
agent: string
abort?: AbortSignal
}
export const TeamSendMessageInputSchema = SendMessageInputSchema
export type TeamSendMessageInput = z.infer<typeof TeamSendMessageInputSchema>
export const TeamReadInboxInputSchema = ReadInboxInputSchema
export type TeamReadInboxInput = z.infer<typeof TeamReadInboxInputSchema>
export const TeamReadConfigInputSchema = ReadConfigInputSchema
export type TeamReadConfigInput = z.infer<typeof TeamReadConfigInputSchema>
export const ProcessShutdownApprovedInputSchema = z.object({
team_name: z.string().regex(/^[A-Za-z0-9_-]+$/, "Team name must contain only letters, numbers, hyphens, and underscores").max(64),
teammate_name: z.string().min(1),
})
export type ProcessShutdownApprovedInput = z.infer<typeof ProcessShutdownApprovedInputSchema>
export const TeamForceKillInputSchema = ForceKillTeammateInputSchema
export type TeamForceKillInput = z.infer<typeof TeamForceKillInputSchema>
export const TeamProcessShutdownInputSchema = ProcessShutdownApprovedInputSchema
export type TeamProcessShutdownInput = z.infer<typeof TeamProcessShutdownInputSchema>
export const TeamTaskCreateInputSchema = z.object({
team_name: teamNameField,
subject: z.string().min(1),
description: z.string(),
active_form: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type TeamTaskCreateInput = z.infer<typeof TeamTaskCreateInputSchema>
export const TeamTaskListInputSchema = z.object({
team_name: teamNameField,
})
export type TeamTaskListInput = z.infer<typeof TeamTaskListInputSchema>
export const TeamTaskGetInputSchema = z.object({
team_name: teamNameField,
task_id: z.string().min(1),
})
export type TeamTaskGetInput = z.infer<typeof TeamTaskGetInputSchema>
export const TeamTaskUpdateInputSchema = z.object({
team_name: teamNameField,
task_id: z.string().min(1),
status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(),
owner: z.string().optional(),
subject: z.string().optional(),
description: z.string().optional(),
active_form: z.string().optional(),
add_blocks: z.array(z.string()).optional(),
add_blocked_by: z.array(z.string()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
export type TeamTaskUpdateInput = z.infer<typeof TeamTaskUpdateInputSchema>