feat(team-mode): add team lifecycle tools with inline spec tests
This commit is contained in:
@@ -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) }))
|
||||
})
|
||||
})
|
||||
@@ -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" })
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user