feat(team-mode): add team lifecycle tools with inline spec tests

This commit is contained in:
YeonGyu-Kim
2026-04-28 10:47:00 +09:00
parent a10ab16c39
commit f4327987d5
4 changed files with 1032 additions and 0 deletions
@@ -0,0 +1,302 @@
/// <reference types="bun-types" />
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<string, RuntimeState>()
let nextTeamRunNumber = 1
const lifecycleSpecifier = import.meta.resolve("./lifecycle")
const teamRuntimeCreateSpecifier = import.meta.resolve("../team-runtime/create")
function clone<TValue>(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<typeof import("./lifecycle").createTeamCreateTool> {
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" },
],
})
})
})
@@ -0,0 +1,170 @@
/// <reference types="bun-types" />
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<string, RuntimeState>()
const teamRuns = new Map<string, string>()
let nextTeamRunNumber = 1
function clone<TValue>(value: TValue): TValue {
return structuredClone(value)
}
export function parseToolResult<TValue>(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<RuntimeState["status"]>(["active", "shutdown_requested", "deleting", "deleted", "creating", "orphaned"])
: new Set<RuntimeState["status"]>(["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)
}
@@ -0,0 +1,289 @@
/// <reference types="bun-types" />
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) }))
})
})
+271
View File
@@ -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<RuntimeState["status"]>(["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<typeof TeamCreateArgsSchema>
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<RuntimeState, "members"> & {
members: Array<Omit<RuntimeState["members"][number], "lastInjectedTurnMarker" | "pendingInjectedMessageIds">>
} {
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(".") : "<root>"
}
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<typeof normalizeTeamSpecInput>[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<RuntimeState | undefined> {
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" })
},
})
}