diff --git a/src/features/team-mode/tools/lifecycle-inline-spec.test.ts b/src/features/team-mode/tools/lifecycle-inline-spec.test.ts new file mode 100644 index 000000000..0d707922c --- /dev/null +++ b/src/features/team-mode/tools/lifecycle-inline-spec.test.ts @@ -0,0 +1,302 @@ +/// + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { randomUUID } from "node:crypto" +import { tmpdir } from "node:os" +import path from "node:path" + +import type { ToolContext } from "@opencode-ai/plugin/tool" + +import { TeamModeConfigSchema } from "../../../config/schema/team-mode" +import type { RuntimeState, TeamSpec } from "../types" + +const runtimes = new Map() +let nextTeamRunNumber = 1 + +const lifecycleSpecifier = import.meta.resolve("./lifecycle") +const teamRuntimeCreateSpecifier = import.meta.resolve("../team-runtime/create") + +function clone(value: TValue): TValue { + return structuredClone(value) +} + +function createToolContext(sessionID: string, agent = "test-agent"): ToolContext { + return { + sessionID, + messageID: randomUUID(), + agent, + directory: "/project", + worktree: "/project", + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => undefined, + } +} + +function createRuntimeState(spec: TeamSpec, leadSessionId: string, teamRunId: string): RuntimeState { + return { + version: 1, + teamRunId, + teamName: spec.name, + specSource: "project", + createdAt: 1, + status: "active", + leadSessionId, + shutdownRequests: [], + bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10000, maxWallClockMinutes: 120, maxMemberTurns: 500 }, + members: spec.members.map((member) => ({ + name: member.name, + sessionId: member.name === spec.leadAgentId ? undefined : `${member.name}-session`, + tmuxPaneId: undefined, + agentType: member.name === spec.leadAgentId ? "leader" : "general-purpose", + status: "running", + color: member.color, + worktreePath: member.worktreePath, + lastInjectedTurnMarker: `turn:${member.name}`, + pendingInjectedMessageIds: [`msg:${member.name}`], + })), + } +} + +const createTeamRunMock = mock(async (spec: TeamSpec, leadSessionId: string) => { + const teamRunId = `team-run-${nextTeamRunNumber++}` + const runtimeState = createRuntimeState(spec, leadSessionId, teamRunId) + runtimes.set(teamRunId, runtimeState) + return clone(runtimeState) +}) + +function registerModuleMocks(): void { + mock.module(teamRuntimeCreateSpecifier, () => ({ createTeamRun: createTeamRunMock })) +} + +async function loadCreateTeamCreateTool(): Promise { + const module = await import(`${lifecycleSpecifier}?test=${randomUUID()}`) + return module.createTeamCreateTool +} + +function createConfig() { + return TeamModeConfigSchema.parse({ + enabled: true, + base_dir: path.join(tmpdir(), `team-mode-inline-spec-${randomUUID()}`), + }) +} + +describe("createTeamCreateTool inline_spec normalization", () => { + afterEach(() => { + mock.restore() + }) + + beforeEach(() => { + mock.restore() + registerModuleMocks() + runtimes.clear() + nextTeamRunNumber = 1 + createTeamRunMock.mockClear() + }) + + test("accepts inline_spec objects and auto-assigns missing member names", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateTool(config, {} as never) + const inlineSpec = { + name: "alpha-team", + lead: { kind: "subagent_type", subagent_type: "sisyphus" }, + members: [ + { kind: "category", category: "quick", prompt: "Quick scout the workspace for entrypoints." }, + { kind: "subagent_type", subagent_type: "atlas" }, + ], + } + + // when + const result = JSON.parse(await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session"))) + const firstCall = createTeamRunMock.mock.calls[0] + + // then + expect(firstCall?.[0]).toMatchObject({ + leadAgentId: "lead", + members: [ + { name: "lead", kind: "subagent_type", subagent_type: "sisyphus" }, + { name: "quick-1", kind: "category", category: "quick" }, + { name: "atlas-1", kind: "subagent_type", subagent_type: "atlas" }, + ], + }) + expect(firstCall?.[1]).toBe("lead-session") + expect(result.runtimeState.members.map((member: { name: string }) => member.name)).toEqual(["lead", "quick-1", "atlas-1"]) + }) + + test("accepts stringified inline_spec values from tool calling", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateTool(config, {} as never) + const inlineSpec = JSON.stringify({ + name: "ccapi-explorers-v2", + lead: { kind: "subagent_type", subagent_type: "sisyphus" }, + members: [ + { kind: "category", category: "quick", prompt: "Quick scout: survey ccapi workspace structure." }, + { kind: "category", category: "deep", prompt: "Deep dive ccapi-cf." }, + { kind: "category", category: "deep", prompt: "Deep dive ccapi-cf-proxy." }, + ], + }) + + // when + const result = JSON.parse(await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session"))) + + // then + expect(result.runtimeState.members.map((member: { name: string }) => member.name)).toEqual(["lead", "quick-1", "deep-1", "deep-2"]) + expect(result.runtimeState.teamName).toBe("ccapi-explorers-v2") + }) + + test("accepts category members written with natural inline prompt fields", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateTool(config, {} as never) + const inlineSpec = { + name: "project-analysis-team", + description: "Analyze the codebase from structure, core logic, and quality angles.", + members: [ + { + name: "structure-analyst", + category: "quick", + loadSkills: [], + systemPrompt: "Focus on directory layouts, module boundaries, and architectural organization.", + }, + { + name: "core-logic-analyst", + category: "quick", + loadSkills: [], + systemPrompt: "Focus on initialization flows, plugin architecture, hooks, tools, and MCP integration.", + }, + { + name: "quality-analyst", + category: "quick", + loadSkills: [], + systemPrompt: "Focus on tests, CI/CD, build scripts, conventions, and anti-pattern enforcement.", + }, + ], + } + + // when + await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus")) + const firstCall = createTeamRunMock.mock.calls[0] + + // then + expect(firstCall?.[0]).toMatchObject({ + leadAgentId: "lead", + members: [ + { name: "lead", kind: "subagent_type" }, + { name: "structure-analyst", kind: "category", category: "quick", prompt: "Focus on directory layouts, module boundaries, and architectural organization." }, + { name: "core-logic-analyst", kind: "category", category: "quick", prompt: "Focus on initialization flows, plugin architecture, hooks, tools, and MCP integration." }, + { name: "quality-analyst", kind: "category", category: "quick", prompt: "Focus on tests, CI/CD, build scripts, conventions, and anti-pattern enforcement." }, + ], + }) + }) + + test("explains how to call team_create when arguments are empty", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateTool(config, {} as never) + + // when + const result = teamCreateTool.execute({}, createToolContext("lead-session", "Sisyphus")) + + // then + await expect(result).rejects.toThrow("team_create requires exactly one of teamName or inline_spec") + await expect(result).rejects.toThrow("team_create({ inline_spec: { name:") + }) + + test("explains how to shape inline_spec when members are missing", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateTool(config, {} as never) + + // when + const result = teamCreateTool.execute({ inline_spec: { name: "project-analysis-team" } }, createToolContext("lead-session", "Sisyphus")) + + // then + await expect(result).rejects.toThrow("Invalid inline_spec for team_create") + await expect(result).rejects.toThrow("members array") + }) + + test("accepts natural team and member names in inline_spec", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateTool(config, {} as never) + const inlineSpec = { + name: "Project Analysis Team", + members: [ + { name: "Agent 1: Structure Analyst", category: "quick", prompt: "Analyze project structure and report concrete files." }, + { name: "Agent 2: Core Logic Analyst", category: "quick", prompt: "Analyze initialization flow and report concrete functions." }, + { name: "Agent 3: Quality/Process Analyst", category: "quick", prompt: "Analyze tests, builds, CI, and conventions." }, + ], + } + + // when + await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus")) + const firstCall = createTeamRunMock.mock.calls[0] + + // then + expect(firstCall?.[0]).toMatchObject({ + name: "project-analysis-team", + members: [ + { name: "lead", kind: "subagent_type" }, + { name: "agent-1-structure-analyst", kind: "category", category: "quick" }, + { name: "agent-2-core-logic-analyst", kind: "category", category: "quick" }, + { name: "agent-3-quality-process-analyst", kind: "category", category: "quick" }, + ], + }) + }) + + test("accepts role and capabilities style members with the configured fallback category", async () => { + // given + const createTeamCreateTool = await loadCreateTeamCreateTool() + const config = createConfig() + const teamCreateTool = createTeamCreateTool(config, {} as never, undefined as never, undefined, { + userCategories: { + analysis: {}, + }, + }) + const inlineSpec = { + name: "Project Analysis Team", + members: [ + { + name: "Agent 1: Structure Analyst", + kind: "agent", + role: "Structure Analyst", + capabilities: ["directory layouts", "module boundaries"], + }, + { + name: "Agent 2: Core Logic Analyst", + kind: "quick", + role: "Core Logic Analyst", + description: "Analyze initialization flow and plugin architecture.", + }, + { + name: "Agent 3: Quality/Process Analyst", + role: "Quality/Process Analyst", + responsibilities: ["tests", "builds", "CI/CD"], + }, + ], + } + + // when + await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session", "Sisyphus")) + const firstCall = createTeamRunMock.mock.calls[0] + + // then + expect(firstCall?.[0]).toMatchObject({ + name: "project-analysis-team", + members: [ + { name: "lead", kind: "subagent_type" }, + { name: "agent-1-structure-analyst", kind: "category", category: "analysis", prompt: "Role: Structure Analyst\ndirectory layouts, module boundaries" }, + { name: "agent-2-core-logic-analyst", kind: "category", category: "quick", prompt: "Role: Core Logic Analyst\nAnalyze initialization flow and plugin architecture." }, + { name: "agent-3-quality-process-analyst", kind: "category", category: "analysis", prompt: "Role: Quality/Process Analyst\ntests, builds, CI/CD" }, + ], + }) + }) +}) diff --git a/src/features/team-mode/tools/lifecycle-test-fixture.ts b/src/features/team-mode/tools/lifecycle-test-fixture.ts new file mode 100644 index 000000000..5f608df58 --- /dev/null +++ b/src/features/team-mode/tools/lifecycle-test-fixture.ts @@ -0,0 +1,170 @@ +/// + +import { mock } from "bun:test" +import { randomUUID } from "node:crypto" + +import type { ToolContext } from "@opencode-ai/plugin/tool" + +import { TeamModeConfigSchema } from "../../../config/schema/team-mode" +import type { OpencodeClient } from "../../../tools/delegate-task/types" +import type { BackgroundManager } from "../../background-agent/manager" +import type { RuntimeState, TeamSpec } from "../types" + +const runtimes = new Map() +const teamRuns = new Map() +let nextTeamRunNumber = 1 + +function clone(value: TValue): TValue { + return structuredClone(value) +} + +export function parseToolResult(value: string): TValue { + return JSON.parse(value) as TValue +} + +export function createToolContext(sessionID: string): ToolContext { + return { + sessionID, + messageID: randomUUID(), + agent: "test-agent", + directory: "/project", + worktree: "/project", + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => undefined, + } +} + +export function getLatestShutdownRequest( + runtimeState: RuntimeState, + memberName: string, +): RuntimeState["shutdownRequests"][number] | undefined { + for (let index = runtimeState.shutdownRequests.length - 1; index >= 0; index -= 1) { + const shutdownRequest = runtimeState.shutdownRequests[index] + if (shutdownRequest?.memberId === memberName) { + return shutdownRequest + } + } +} + +export function createSpec(): TeamSpec { + return { + version: 1, + name: "alpha-team", + createdAt: 1, + leadAgentId: "lead", + members: [ + { kind: "category", name: "lead", category: "deep", prompt: "Lead the assigned work", backendType: "in-process", isActive: true }, + { kind: "category", name: "member-a", category: "quick", prompt: "Do the assigned work", backendType: "in-process", isActive: true }, + ], + } +} + +function createRuntimeState(spec: TeamSpec, leadSessionId: string, teamRunId: string): RuntimeState { + return { + version: 1, + teamRunId, + teamName: spec.name, + specSource: "project", + createdAt: 1, + status: "active", + leadSessionId, + shutdownRequests: [], + bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10000, maxWallClockMinutes: 120, maxMemberTurns: 500 }, + members: spec.members.map((member) => ({ + name: member.name, + sessionId: member.name === spec.leadAgentId ? undefined : `${member.name}-session`, + tmuxPaneId: undefined, + agentType: member.name === spec.leadAgentId ? "leader" : "general-purpose", + status: "running", + color: member.color, + worktreePath: member.worktreePath, + lastInjectedTurnMarker: `turn:${member.name}`, + pendingInjectedMessageIds: [`msg:${member.name}`], + })), + } +} + +export function requireRuntime(teamRunId: string): RuntimeState { + const runtimeState = runtimes.get(teamRunId) + if (!runtimeState) throw new Error(`missing runtime ${teamRunId}`) + return runtimeState +} + +export const createTeamRunMock = mock(async (spec: TeamSpec, leadSessionId: string) => { + const key = `${spec.name}:${leadSessionId}` + const existingTeamRunId = teamRuns.get(key) + if (existingTeamRunId) return clone(requireRuntime(existingTeamRunId)) + const teamRunId = `team-run-${nextTeamRunNumber++}` + teamRuns.set(key, teamRunId) + const runtimeState = createRuntimeState(spec, leadSessionId, teamRunId) + runtimes.set(teamRunId, runtimeState) + return clone(runtimeState) +}) +export const deleteTeamMock = mock(async ( + teamRunId: string, + _config?: unknown, + _tmuxMgr?: unknown, + _bgMgr?: unknown, + options?: { force?: boolean }, +) => { + const runtimeState = requireRuntime(teamRunId) + const deletableStatuses = options?.force + ? new Set(["active", "shutdown_requested", "deleting", "deleted", "creating", "orphaned"]) + : new Set(["active", "shutdown_requested", "deleting", "deleted"]) + if (!deletableStatuses.has(runtimeState.status)) { + throw new Error(`team cannot be deleted from '${runtimeState.status}'`) + } + if (!options?.force && runtimeState.members.some((member) => member.agentType !== "leader" && member.status !== "shutdown_approved" && member.status !== "completed" && member.status !== "errored")) { + throw new Error("members still active") + } + runtimes.delete(teamRunId) + return { removedWorktrees: [], removedLayout: false } +}) +export const requestShutdownOfMemberMock = mock(async (teamRunId: string, targetMemberName: string, requesterName: string) => { + requireRuntime(teamRunId).shutdownRequests.push({ memberId: targetMemberName, requesterName, requestedAt: Date.now() }) +}) +export const approveShutdownMock = mock(async (teamRunId: string, memberName: string) => { + const runtimeState = requireRuntime(teamRunId) + const request = getLatestShutdownRequest(runtimeState, memberName) + if (request) request.approvedAt = Date.now() + const member = runtimeState.members.find((candidate) => candidate.name === memberName) + if (member) member.status = "shutdown_approved" +}) +export const rejectShutdownMock = mock(async (teamRunId: string, memberName: string, reason: string) => { + const request = getLatestShutdownRequest(requireRuntime(teamRunId), memberName) + if (request) { + request.rejectedAt = Date.now() + request.rejectedReason = reason + } +}) +export const loadTeamSpecMock = mock(async () => createSpec()) +export const listActiveTeamsMock = mock(async () => Array.from(runtimes.values()).map((runtimeState) => ({ teamRunId: runtimeState.teamRunId, teamName: runtimeState.teamName, status: runtimeState.status }))) +export const loadRuntimeStateMock = mock(async (teamRunId: string) => clone(requireRuntime(teamRunId))) + +export const config = TeamModeConfigSchema.parse({ enabled: true }) +export const mockClient = {} as OpencodeClient +export const backgroundManager = {} as BackgroundManager + +export function resetLifecycleTestState(): void { + runtimes.clear() + teamRuns.clear() + nextTeamRunNumber = 1 + + for (const mockedFunction of [ + createTeamRunMock, + deleteTeamMock, + requestShutdownOfMemberMock, + approveShutdownMock, + rejectShutdownMock, + loadTeamSpecMock, + listActiveTeamsMock, + loadRuntimeStateMock, + ]) { + mockedFunction.mockClear() + } +} + +export function hasRuntime(teamRunId: string): boolean { + return runtimes.has(teamRunId) +} diff --git a/src/features/team-mode/tools/lifecycle.test.ts b/src/features/team-mode/tools/lifecycle.test.ts new file mode 100644 index 000000000..c709619ee --- /dev/null +++ b/src/features/team-mode/tools/lifecycle.test.ts @@ -0,0 +1,289 @@ +/// + +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" + +import { normalizeTeamSpecInput } from "../team-registry/team-spec-input-normalizer" +import type { RuntimeState } from "../types" +import { + approveShutdownMock, + backgroundManager, + config, + createSpec, + createTeamRunMock, + createToolContext, + deleteTeamMock, + getLatestShutdownRequest, + hasRuntime, + listActiveTeamsMock, + loadRuntimeStateMock, + loadTeamSpecMock, + mockClient, + parseToolResult, + rejectShutdownMock, + requestShutdownOfMemberMock, + requireRuntime, + resetLifecycleTestState, +} from "./lifecycle-test-fixture" + +mock.module("../team-runtime/create", () => ({ createTeamRun: createTeamRunMock })) +mock.module("../team-runtime/shutdown", () => ({ approveShutdown: approveShutdownMock, deleteTeam: deleteTeamMock, rejectShutdown: rejectShutdownMock, requestShutdownOfMember: requestShutdownOfMemberMock })) +mock.module("../team-registry/loader", () => ({ loadTeamSpec: loadTeamSpecMock, normalizeTeamSpecInput })) +mock.module("../team-state-store/store", () => ({ listActiveTeams: listActiveTeamsMock, loadRuntimeState: loadRuntimeStateMock })) + +const { + createTeamApproveShutdownTool, + createTeamCreateTool, + createTeamDeleteTool, + createTeamRejectShutdownTool, + createTeamShutdownRequestTool, +} = await import("./lifecycle") + +describe("team lifecycle tools", () => { + afterAll(() => { + mock.restore() + }) + + beforeEach(() => { + resetLifecycleTestState() + }) + + test("team_create works without toolContext.client field", async () => { + // given + const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager) + + // when + const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + + // then + expect(result.teamRunId).toBe("team-run-1") + expect(createTeamRunMock).toHaveBeenCalledWith( + expect.anything(), + "lead-session", + expect.objectContaining({ client: mockClient }), + config, + backgroundManager, + undefined, + { callerAgentTypeId: undefined, parentMessageID: expect.any(String) }, + ) + }) + + test("team_create resolves a visible sort-prefixed sisyphus caller into callerAgentTypeId", async () => { + // given + const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager) + const toolContext = { + ...createToolContext("lead-session"), + agent: "00|Sisyphus", + } + + // when + await teamCreateTool.execute({ inline_spec: createSpec() }, toolContext) + + // then + expect(createTeamRunMock).toHaveBeenCalledWith( + expect.anything(), + "lead-session", + expect.objectContaining({ client: mockClient }), + config, + backgroundManager, + undefined, + { callerAgentTypeId: "sisyphus", parentMessageID: expect.any(String) }, + ) + }) + + test("team_create returns teamRunId and sanitized runtimeState for inline specs", async () => { + // given + const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager) + + // when + const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + + // then + expect(result.teamRunId).toBe("team-run-1") + expect(result.runtimeState.status).toBe("active") + expect(result.runtimeState.members).toHaveLength(2) + expect(result.runtimeState.members[0]).not.toHaveProperty("lastInjectedTurnMarker") + expect(result.runtimeState.members[0]).not.toHaveProperty("pendingInjectedMessageIds") + }) + + test("team_create normalizes inline lead shorthand before creating the runtime", async () => { + // given + const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager) + const inlineSpec = { + name: "alpha-team", + lead: { kind: "subagent_type", subagent_type: "sisyphus" }, + members: [{ kind: "category", name: "member-a", category: "quick", prompt: "Do the assigned work" }], + } + + // when + const result = parseToolResult<{ runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session"))) + + // then + expect(createTeamRunMock).toHaveBeenCalledWith( + expect.objectContaining({ leadAgentId: "lead" }), + "lead-session", + expect.anything(), + config, + expect.anything(), + undefined, + { callerAgentTypeId: undefined, parentMessageID: expect.any(String) }, + ) + expect(result.runtimeState.members).toHaveLength(2) + expect(result.runtimeState.members[0]).toMatchObject({ name: "lead", agentType: "leader" }) + }) + + test("team_create rejects an empty leadSessionId override", async () => { + // given + const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager) + + // when + const result = teamCreateTool.execute({ inline_spec: createSpec(), leadSessionId: "" }, createToolContext("lead-session")) + + // then + await expect(result).rejects.toThrow("leadSessionId") + }) + + test("team_delete propagates active-member errors", async () => { + // given + const createTool = createTeamCreateTool(config, mockClient, backgroundManager) + const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager) + const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + + // when + const result = deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext("lead-session")) + + // then + expect(result).rejects.toThrow("members still active") + }) + + test("team_delete force=true succeeds even with active members", async () => { + // given + const createTool = createTeamCreateTool(config, mockClient, backgroundManager) + const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager) + const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + + // when + const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext("lead-session"))) + + // then + expect(result.deleted).toBe(true) + expect(hasRuntime(created.teamRunId)).toBe(false) + }) + + test("team_delete force=true allows non-lead caller on orphaned team", async () => { + // given + const createTool = createTeamCreateTool(config, mockClient, backgroundManager) + const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager) + const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + const runtimeState = requireRuntime(created.teamRunId) + runtimeState.status = "orphaned" + const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId + + // when + const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute( + { teamRunId: created.teamRunId, force: true }, + createToolContext(memberSessionId ?? "member-a-session"), + )) + + // then + expect(result.deleted).toBe(true) + expect(hasRuntime(created.teamRunId)).toBe(false) + }) + + test("team_delete still rejects non-participants even with force=true", async () => { + // given + const createTool = createTeamCreateTool(config, mockClient, backgroundManager) + const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager) + const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + requireRuntime(created.teamRunId).status = "orphaned" + + // when + const result = deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext("outside-session")) + + // then + expect(result).rejects.toThrow("team_delete is lead-only") + }) + + test("team_delete force=true allows member participant to recover a stuck deleting team", async () => { + // given + const createTool = createTeamCreateTool(config, mockClient, backgroundManager) + const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager) + const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + const runtimeState = requireRuntime(created.teamRunId) + runtimeState.status = "deleting" + const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId + + // when + const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext(memberSessionId ?? "member-a-session"))) + + // then + expect(result.deleted).toBe(true) + expect(hasRuntime(created.teamRunId)).toBe(false) + }) + + test("team_delete force=false on orphaned team still requires lead", async () => { + // given + const createTool = createTeamCreateTool(config, mockClient, backgroundManager) + const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager) + const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + const runtimeState = requireRuntime(created.teamRunId) + runtimeState.status = "orphaned" + const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId + + // when + const result = deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext(memberSessionId ?? "member-a-session")) + + // then + expect(result).rejects.toThrow("team_delete is lead-only") + }) + + test("team_create is idempotent for the same spec and lead session", async () => { + // given + const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager) + + // when + const firstResult = parseToolResult<{ teamRunId: string }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + const secondResult = parseToolResult<{ teamRunId: string }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + + // then + expect(firstResult.teamRunId).toBe(secondResult.teamRunId) + expect(createTeamRunMock).toHaveBeenCalledTimes(2) + }) + + test("runs full lifecycle through create, request, approve, and delete", async () => { + // given + const createTool = createTeamCreateTool(config, mockClient, backgroundManager) + const requestTool = createTeamShutdownRequestTool(config, mockClient) + const approveTool = createTeamApproveShutdownTool(config, mockClient) + const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager) + const created = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session"))) + const memberSessionId = created.runtimeState.members.find((member) => member.name === "member-a")?.sessionId + + // when + const requestResult = parseToolResult<{ status: string }>(await requestTool.execute({ teamRunId: created.teamRunId, targetMemberName: "member-a" }, createToolContext("lead-session"))) + const approveResult = parseToolResult<{ status: string }>(await approveTool.execute({ teamRunId: created.teamRunId, memberName: "member-a" }, createToolContext(memberSessionId ?? "member-a-session"))) + const deleteResult = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext("lead-session"))) + + // then + expect(requestResult.status).toBe("shutdown_requested") + expect(approveResult.status).toBe("shutdown_approved") + expect(deleteResult.deleted).toBe(true) + expect(hasRuntime(created.teamRunId)).toBe(false) + }) + + test("team_reject_shutdown records the rejection reason", async () => { + // given + const createTool = createTeamCreateTool(config, mockClient, backgroundManager) + const requestTool = createTeamShutdownRequestTool(config, mockClient) + const rejectTool = createTeamRejectShutdownTool(config, mockClient) + const created = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await createTool.execute({ teamName: "alpha-team" }, createToolContext("lead-session"))) + const memberSessionId = created.runtimeState.members.find((member) => member.name === "member-a")?.sessionId + await requestTool.execute({ teamRunId: created.teamRunId, targetMemberName: "member-a" }, createToolContext("lead-session")) + + // when + const result = parseToolResult<{ teamRunId: string; memberName: string; rejectedBy: string; reason: string; status: string }>(await rejectTool.execute({ teamRunId: created.teamRunId, memberName: "member-a", reason: "still working" }, createToolContext(memberSessionId ?? "member-a-session"))) + + // then + expect(result).toEqual({ teamRunId: created.teamRunId, memberName: "member-a", rejectedBy: "member-a", reason: "still working", status: "shutdown_rejected" }) + expect(getLatestShutdownRequest(requireRuntime(created.teamRunId), "member-a")).toEqual(expect.objectContaining({ rejectedReason: "still working", rejectedAt: expect.any(Number) })) + }) +}) diff --git a/src/features/team-mode/tools/lifecycle.ts b/src/features/team-mode/tools/lifecycle.ts new file mode 100644 index 000000000..c332da5a9 --- /dev/null +++ b/src/features/team-mode/tools/lifecycle.ts @@ -0,0 +1,271 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import type { ToolContext } from "@opencode-ai/plugin/tool" +import { z } from "zod" + +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import type { CategoriesConfig, AgentOverrides } from "../../../config/schema" +import { mergeCategories } from "../../../shared/merge-categories" +import type { OpencodeClient } from "../../../tools/delegate-task/types" +import type { BackgroundManager } from "../../background-agent/manager" +import type { TmuxSessionManager } from "../../tmux-subagent/manager" +import { resolveCallerTeamLead } from "../resolve-caller-team-lead" +import { loadTeamSpec, normalizeTeamSpecInput } from "../team-registry/loader" +import { validateSpec } from "../team-registry/validator" +import { createTeamRun } from "../team-runtime/create" +import { approveShutdown, deleteTeam, rejectShutdown, requestShutdownOfMember } from "../team-runtime/shutdown" +import { listActiveTeams, loadRuntimeState } from "../team-state-store/store" +import { TeamSpecSchema, type RuntimeState, type TeamSpec } from "../types" + +const ACTIVE_RUNTIME_STATUSES = new Set(["creating", "active", "shutdown_requested"]) +const TEAM_CREATE_USAGE = "team_create requires exactly one of teamName or inline_spec. Use team_create({ teamName: \"existing-team\" }) or team_create({ inline_spec: { name: \"team-name\", members: [{ name: \"worker\", category: \"quick\", prompt: \"Do the assigned work.\" }] } })." + +const TeamCreateArgsSchema = z.object({ + teamName: z.string().min(1).optional(), + inline_spec: z.unknown().optional(), + leadSessionId: z.string().min(1).optional(), +}).superRefine((value, ctx) => { + const optionCount = Number(value.teamName !== undefined) + Number(value.inline_spec !== undefined) + if (optionCount !== 1) { + ctx.addIssue({ code: "custom", message: "Provide exactly one of teamName or inline_spec." }) + } +}) + +const TeamDeleteArgsSchema = z.object({ teamRunId: z.string().min(1), force: z.boolean().optional() }) +const TeamShutdownRequestArgsSchema = z.object({ teamRunId: z.string().min(1), targetMemberName: z.string().min(1) }) +const TeamApproveShutdownArgsSchema = z.object({ teamRunId: z.string().min(1), memberName: z.string().min(1) }) +const TeamRejectShutdownArgsSchema = z.object({ + teamRunId: z.string().min(1), + memberName: z.string().min(1), + reason: z.string().min(1), +}) + +type TeamLifecycleToolContext = ToolContext & { + sessionID: string + directory?: string +} + +type TeamParticipant = { role: "lead" | "member"; memberName: string } + +type TeamCreateArgs = z.infer + +function resolveDefaultInlineCategory(userCategories?: CategoriesConfig): string | undefined { + const userCategoryName = Object.entries(userCategories ?? {}).find(([, categoryConfig]) => categoryConfig.disable !== true)?.[0] + if (userCategoryName !== undefined) { + return userCategoryName + } + + return Object.keys(mergeCategories(userCategories))[0] +} + +function getLeadMemberName(runtimeState: RuntimeState): string { + const leadMember = runtimeState.members.find((member) => member.agentType === "leader") + if (!leadMember) throw new Error(`team '${runtimeState.teamRunId}' is missing a lead member`) + return leadMember.name +} + +function sanitizeRuntimeState(runtimeState: RuntimeState): Omit & { + members: Array> +} { + return { + ...runtimeState, + members: runtimeState.members.map(({ lastInjectedTurnMarker: _turnMarker, pendingInjectedMessageIds: _pendingIds, ...member }) => member), + } +} + +function parseTeamCreateArgs(rawArgs: unknown): TeamCreateArgs { + const result = TeamCreateArgsSchema.safeParse(rawArgs) + if (!result.success) { + throw new Error(TEAM_CREATE_USAGE) + } + + return result.data +} + +function formatZodIssuePath(path: PropertyKey[]): string { + return path.length > 0 ? path.join(".") : "" +} + +function formatTeamSpecIssues(error: z.ZodError): string { + return error.issues + .slice(0, 5) + .map((issue) => `${formatZodIssuePath(issue.path)}: ${issue.message}`) + .join("; ") +} + +function parseInlineTeamSpec( + rawSpec: unknown, + options?: Parameters[1], +): TeamSpec { + let specObject: unknown = rawSpec + if (typeof rawSpec === "string") { + try { + specObject = JSON.parse(rawSpec) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`inline_spec is a string but not valid JSON: ${message}`) + } + } + + const parsedSpecResult = TeamSpecSchema.safeParse(normalizeTeamSpecInput(specObject, options)) + if (!parsedSpecResult.success) { + throw new Error(`Invalid inline_spec for team_create: ${formatTeamSpecIssues(parsedSpecResult.error)}. Provide an object with name and members array. Example: team_create({ inline_spec: { name: "project-analysis-team", members: [{ name: "structure-analyst", category: "quick", prompt: "Analyze project structure." }] } }).`) + } + + const parsedSpec = parsedSpecResult.data + validateSpec(parsedSpec) + return parsedSpec +} + +async function findParticipantRuntime(sessionID: string, config: TeamModeConfig): Promise { + for (const activeTeam of await listActiveTeams(config)) { + const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config).catch(() => undefined) + if (!runtimeState || !ACTIVE_RUNTIME_STATUSES.has(runtimeState.status)) continue + if (runtimeState.leadSessionId === sessionID) return runtimeState + if (runtimeState.members.some((member) => member.sessionId === sessionID)) return runtimeState + } +} + +async function resolveParticipant(teamRunId: string, sessionID: string, config: TeamModeConfig): Promise<{ runtimeState: RuntimeState; participant?: TeamParticipant }> { + const runtimeState = await loadRuntimeState(teamRunId, config) + if (runtimeState.leadSessionId === sessionID) { + return { runtimeState, participant: { role: "lead", memberName: getLeadMemberName(runtimeState) } } + } + const member = runtimeState.members.find((candidate) => candidate.sessionId === sessionID) + return member ? { runtimeState, participant: { role: "member", memberName: member.name } } : { runtimeState } +} + +export type TeamCreateExecutorConfig = { + userCategories?: CategoriesConfig + sisyphusJuniorModel?: string + agentOverrides?: AgentOverrides +} + +export function createTeamCreateTool( + config: TeamModeConfig, + client: OpencodeClient, + bgMgr: BackgroundManager, + tmuxMgr?: TmuxSessionManager, + executorConfig?: TeamCreateExecutorConfig, +): ToolDefinition { + return tool({ + description: "Create a team run from a named or inline team spec.", + args: { + teamName: tool.schema.string().optional().describe("Named team spec to load. Provide exactly one of teamName or inline_spec."), + inline_spec: tool.schema.unknown().optional().describe("Inline team spec object or JSON string. Provide exactly one of teamName or inline_spec."), + leadSessionId: tool.schema.string().optional().describe("Optional non-empty session ID override. Usually omit this and let team_create use the current session."), + }, + async execute(rawArgs, toolContext) { + const args = parseTeamCreateArgs(rawArgs) + const runtimeContext = toolContext as TeamLifecycleToolContext + const leadSessionId = args.leadSessionId ?? runtimeContext.sessionID + if (!leadSessionId) throw new Error("team_create requires leadSessionId or tool context sessionID") + const projectRoot = typeof runtimeContext.directory === "string" ? runtimeContext.directory : process.cwd() + const callerTeamLead = resolveCallerTeamLead(runtimeContext.agent) + const defaultCategoryName = resolveDefaultInlineCategory(executorConfig?.userCategories) + const spec = args.teamName + ? await loadTeamSpec(args.teamName, config, projectRoot, { callerTeamLead }) + : parseInlineTeamSpec(args.inline_spec, { callerTeamLead, defaultCategoryName }) + const participantRuntime = await findParticipantRuntime(runtimeContext.sessionID, config) + if (participantRuntime && (participantRuntime.teamName !== spec.name || participantRuntime.leadSessionId !== leadSessionId)) { + throw new Error(`team_create denied: session is already a participant of team ${participantRuntime.teamRunId}`) + } + const runtimeState = await createTeamRun( + spec, + leadSessionId, + { + client, + manager: bgMgr, + directory: projectRoot, + userCategories: executorConfig?.userCategories, + sisyphusJuniorModel: executorConfig?.sisyphusJuniorModel, + agentOverrides: executorConfig?.agentOverrides, + }, + config, + bgMgr, + tmuxMgr, + { + callerAgentTypeId: callerTeamLead.agentTypeId, + parentMessageID: runtimeContext.messageID, + }, + ) + return JSON.stringify({ teamRunId: runtimeState.teamRunId, runtimeState: sanitizeRuntimeState(runtimeState) }) + }, + }) +} + +export function createTeamDeleteTool( + config: TeamModeConfig, + client: OpencodeClient, + backgroundManager: BackgroundManager, + tmuxMgr?: TmuxSessionManager, +): ToolDefinition { + void client + + return tool({ + description: "Delete a completed or shutdown-approved team run. Pass force=true to tear it down even while members are still active.", + args: { teamRunId: tool.schema.string(), force: tool.schema.boolean().optional() }, + async execute(rawArgs, toolContext) { + const args = TeamDeleteArgsSchema.parse(rawArgs) + const runtimeContext = toolContext as TeamLifecycleToolContext + const { runtimeState, participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config) + const isOrphanedForceDelete = args.force === true && runtimeState.status === "orphaned" + const isStuckDeletingForceDelete = args.force === true && runtimeState.status === "deleting" + const isForceBypass = (isStuckDeletingForceDelete || isOrphanedForceDelete) && participant !== undefined + if (!isForceBypass && participant?.role !== "lead") { + throw new Error("team_delete is lead-only") + } + return JSON.stringify({ teamRunId: args.teamRunId, teamName: runtimeState.teamName, deleted: true, ...(await deleteTeam(args.teamRunId, config, tmuxMgr, backgroundManager, { force: args.force })) }) + }, + }) +} + +export function createTeamShutdownRequestTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition { + void client + + return tool({ + description: "Request shutdown for a team member.", + args: { teamRunId: tool.schema.string(), targetMemberName: tool.schema.string() }, + async execute(rawArgs, toolContext) { + const args = TeamShutdownRequestArgsSchema.parse(rawArgs) + const runtimeContext = toolContext as TeamLifecycleToolContext + const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config) + if (participant?.role !== "lead") throw new Error("team_shutdown_request is lead-only") + await requestShutdownOfMember(args.teamRunId, args.targetMemberName, participant.memberName, config) + return JSON.stringify({ teamRunId: args.teamRunId, targetMemberName: args.targetMemberName, requesterName: participant.memberName, status: "shutdown_requested" }) + }, + }) +} + +export function createTeamApproveShutdownTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition { + void client + + return tool({ + description: "Approve a pending shutdown request.", + args: { teamRunId: tool.schema.string(), memberName: tool.schema.string() }, + async execute(rawArgs, toolContext) { + const args = TeamApproveShutdownArgsSchema.parse(rawArgs) + const runtimeContext = toolContext as TeamLifecycleToolContext + const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config) + if (!participant || (participant.role !== "lead" && participant.memberName !== args.memberName)) throw new Error("team_approve_shutdown: caller must be target member or team lead") + await approveShutdown(args.teamRunId, args.memberName, participant.memberName, config) + return JSON.stringify({ teamRunId: args.teamRunId, memberName: args.memberName, approverName: participant.memberName, status: "shutdown_approved" }) + }, + }) +} + +export function createTeamRejectShutdownTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition { + void client + + return tool({ + description: "Reject a pending shutdown request.", + args: { teamRunId: tool.schema.string(), memberName: tool.schema.string(), reason: tool.schema.string() }, + async execute(rawArgs, toolContext) { + const args = TeamRejectShutdownArgsSchema.parse(rawArgs) + const runtimeContext = toolContext as TeamLifecycleToolContext + const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config) + if (!participant || (participant.role !== "lead" && participant.memberName !== args.memberName)) throw new Error("team_reject_shutdown: caller must be target member or team lead") + await rejectShutdown(args.teamRunId, args.memberName, args.reason, config) + return JSON.stringify({ teamRunId: args.teamRunId, memberName: args.memberName, rejectedBy: participant.memberName, reason: args.reason, status: "shutdown_rejected" }) + }, + }) +}