diff --git a/src/features/team-mode/tools/tasks.test.ts b/src/features/team-mode/tools/tasks.test.ts new file mode 100644 index 000000000..3781ee6a3 --- /dev/null +++ b/src/features/team-mode/tools/tasks.test.ts @@ -0,0 +1,153 @@ +/// + +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { ToolContext } from "@opencode-ai/plugin/tool" + +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import type { OpencodeClient } from "../../../tools/delegate-task/types" +import type { RuntimeState, Task } from "../types" + +const mockClient = {} as OpencodeClient + +const createTaskMock = mock(async () => ({ id: "1", subject: "task one" } as Task)) +const listTasksMock = mock(async () => [{ id: "1", status: "pending" } as Task]) +const claimTaskMock = mock(async () => ({ id: "1", status: "claimed" } as Task)) +const updateTaskStatusMock = mock(async (_teamRunId: string, _taskId: string, status: Task["status"]) => ({ + id: "1", + status, +} as Task)) +const getTaskMock = mock(async () => ({ id: "1", status: "completed" } as Task)) +const loadRuntimeStateMock = mock(async (): Promise => ({ + version: 1, + teamRunId: "team-run-1", + teamName: "team-alpha", + specSource: "project", + createdAt: 1, + status: "active", + leadSessionId: "lead-session", + members: [ + { name: "lead-member", sessionId: "lead-session", agentType: "leader", status: "running", pendingInjectedMessageIds: [] }, + { name: "member-a", sessionId: "member-session-a", agentType: "general-purpose", status: "running", pendingInjectedMessageIds: [] }, + ], + shutdownRequests: [], + bounds: { + maxMembers: 8, + maxParallelMembers: 4, + maxMessagesPerRun: 10_000, + maxWallClockMinutes: 120, + maxMemberTurns: 500, + }, +})) + +mock.module("../team-state-store", () => ({ loadRuntimeState: loadRuntimeStateMock })) +mock.module("../team-tasklist", () => ({ + createTask: createTaskMock, + listTasks: listTasksMock, + claimTask: claimTaskMock, + updateTaskStatus: updateTaskStatusMock, + getTask: getTaskMock, +})) + +const { + createTeamTaskCreateTool, + createTeamTaskListTool, + createTeamTaskUpdateTool, + createTeamTaskGetTool, +} = await import("./tasks") + +function createConfig(): TeamModeConfig { + return { + enabled: true, + tmux_visualization: false, + max_parallel_members: 4, + max_members: 8, + max_messages_per_run: 10_000, + max_wall_clock_minutes: 120, + max_member_turns: 500, + message_payload_max_bytes: 32_768, + recipient_unread_max_bytes: 262_144, + mailbox_poll_interval_ms: 3_000, + } +} + +function createContext(sessionID: string) { + return { + sessionID, + messageID: "message-1", + agent: "test-agent", + directory: "/tmp/team-mode", + worktree: "/tmp/team-mode/worktree", + abort: new AbortController().signal, + metadata: mock(() => {}), + ask: async () => {}, + } satisfies ToolContext +} + +describe("team task tools", () => { + beforeEach(() => { + createTaskMock.mockClear() + listTasksMock.mockClear() + claimTaskMock.mockClear() + updateTaskStatusMock.mockClear() + getTaskMock.mockClear() + loadRuntimeStateMock.mockClear() + }) + + test("create -> list -> claim -> complete flow", async () => { + // given + const config = createConfig() + const createTool = createTeamTaskCreateTool(config, mockClient) + const listTool = createTeamTaskListTool(config, mockClient) + const updateTool = createTeamTaskUpdateTool(config, mockClient) + const getTool = createTeamTaskGetTool(config, mockClient) + + // when + const created = JSON.parse(await createTool.execute({ teamRunId: "team-run-1", subject: "task one", description: "desc" }, createContext("member-session-a"))) + const listed = JSON.parse(await listTool.execute({ teamRunId: "team-run-1", status: "pending", owner: "member-a" }, createContext("member-session-a"))) + const claimed = JSON.parse(await updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "claimed" }, createContext("member-session-a"))) + const inProgress = JSON.parse(await updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "in_progress", owner: "member-a" }, createContext("member-session-a"))) + const completed = JSON.parse(await updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "completed", owner: "member-a" }, createContext("member-session-a"))) + const fetched = JSON.parse(await getTool.execute({ teamRunId: "team-run-1", taskId: "1" }, createContext("member-session-a"))) + + // then + expect(created.taskId).toBe("1") + expect(created.task.subject).toBe("task one") + expect(listed.tasks).toHaveLength(1) + expect(claimed.task.status).toBe("claimed") + expect(inProgress.task.status).toBe("in_progress") + expect(completed.task.status).toBe("completed") + expect(fetched.task.status).toBe("completed") + expect(createTaskMock).toHaveBeenCalledWith("team-run-1", expect.objectContaining({ subject: "task one", description: "desc", blockedBy: [], status: "pending" }), config) + expect(listTasksMock).toHaveBeenCalledWith("team-run-1", config, { status: "pending", owner: "member-a" }) + expect(claimTaskMock).toHaveBeenCalledWith("team-run-1", "1", "member-a", config) + expect(updateTaskStatusMock).toHaveBeenCalledWith("team-run-1", "1", "in_progress", "member-a", config) + expect(updateTaskStatusMock).toHaveBeenCalledWith("team-run-1", "1", "completed", "member-a", config) + expect(getTaskMock).toHaveBeenCalledWith("team-run-1", "1", config) + }) + + test("cross-owner update rejected", async () => { + // given + const config = createConfig() + updateTaskStatusMock.mockImplementationOnce(async () => { throw new Error("CrossOwnerUpdateError") }) + const updateTool = createTeamTaskUpdateTool(config, mockClient) + + // when + const result = updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "in_progress", owner: "member-b" }, createContext("member-session-a")) + + // then + expect(result).rejects.toThrow("CrossOwnerUpdateError") + }) + + test("blockedBy enforcement", async () => { + // given + const config = createConfig() + claimTaskMock.mockImplementationOnce(async () => { throw new Error("blocked by 2") }) + const updateTool = createTeamTaskUpdateTool(config, mockClient) + + // when + const result = updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "claimed" }, createContext("member-session-a")) + + // then + expect(result).rejects.toThrow("blocked by 2") + }) +}) diff --git a/src/features/team-mode/tools/tasks.ts b/src/features/team-mode/tools/tasks.ts new file mode 100644 index 000000000..5fb35305e --- /dev/null +++ b/src/features/team-mode/tools/tasks.ts @@ -0,0 +1,132 @@ +import { tool, type ToolDefinition, type ToolContext } from "@opencode-ai/plugin/tool" + +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import type { OpencodeClient } from "../../../tools/delegate-task/types" +import { loadRuntimeState } from "../team-state-store" +import { createTask, getTask, listTasks, updateTaskStatus, claimTask } from "../team-tasklist" + +type TeamTaskToolContext = ToolContext & { + sessionID?: string +} + +type TeamTaskListFilter = { + status?: "pending" | "claimed" | "in_progress" | "completed" | "deleted" + owner?: string +} + +type TeamTaskCreateArgs = { + teamRunId: string + subject: string + description: string + blockedBy?: string[] +} + +type TeamTaskListArgs = { + teamRunId: string + status?: TeamTaskListFilter["status"] + owner?: string +} + +type TeamTaskUpdateArgs = { + teamRunId: string + taskId: string + status: "pending" | "claimed" | "in_progress" | "completed" | "deleted" + owner?: string +} + +type TeamTaskGetArgs = { + teamRunId: string + taskId: string +} + +async function resolveSenderName(teamRunId: string, config: TeamModeConfig, sessionID: string | undefined): Promise { + const runtimeState = await loadRuntimeState(teamRunId, config) + const matchedMember = runtimeState.members.find((member) => member.sessionId === sessionID) + if (matchedMember) return matchedMember.name + + const leadMember = runtimeState.members.find((member) => member.agentType === "leader") + if (leadMember) return leadMember.name + + throw new Error(`team member not found for session ${sessionID ?? "unknown"}`) +} + +export function createTeamTaskCreateTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition { + void client + + return tool({ + description: "Create a team task.", + args: { + teamRunId: tool.schema.string().describe("Team run ID"), + subject: tool.schema.string().describe("Task subject"), + description: tool.schema.string().describe("Task description"), + blockedBy: tool.schema.array(tool.schema.string()).optional().describe("Blocking task IDs"), + }, + execute: async (args: TeamTaskCreateArgs): Promise => { + const createdTask = await createTask(args.teamRunId, { + subject: args.subject, + description: args.description, + blocks: [], + blockedBy: args.blockedBy ?? [], + status: "pending", + }, config) + + return JSON.stringify({ taskId: createdTask.id, task: createdTask }) + }, + }) +} + +export function createTeamTaskListTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition { + void client + + return tool({ + description: "List team tasks.", + args: { + teamRunId: tool.schema.string().describe("Team run ID"), + status: tool.schema.enum(["pending", "claimed", "in_progress", "completed", "deleted"]).optional(), + owner: tool.schema.string().optional(), + }, + execute: async (args: TeamTaskListArgs): Promise => { + const tasks = await listTasks(args.teamRunId, config, { status: args.status, owner: args.owner }) + return JSON.stringify({ tasks }) + }, + }) +} + +export function createTeamTaskUpdateTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition { + void client + + return tool({ + description: "Update a team task.", + args: { + teamRunId: tool.schema.string().describe("Team run ID"), + taskId: tool.schema.string().describe("Task ID"), + status: tool.schema.enum(["pending", "claimed", "in_progress", "completed", "deleted"]).describe("Task status"), + owner: tool.schema.string().optional().describe("Task owner"), + }, + execute: async (args: TeamTaskUpdateArgs, ctx?: TeamTaskToolContext): Promise => { + const senderName = await resolveSenderName(args.teamRunId, config, ctx?.sessionID) + + const updatedTask = args.status === "claimed" + ? await claimTask(args.teamRunId, args.taskId, senderName, config) + : await updateTaskStatus(args.teamRunId, args.taskId, args.status, args.owner ?? senderName, config) + + return JSON.stringify({ task: updatedTask }) + }, + }) +} + +export function createTeamTaskGetTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition { + void client + + return tool({ + description: "Get a team task.", + args: { + teamRunId: tool.schema.string().describe("Team run ID"), + taskId: tool.schema.string().describe("Task ID"), + }, + execute: async (args: TeamTaskGetArgs): Promise => { + const task = await getTask(args.teamRunId, args.taskId, config) + return JSON.stringify({ task }) + }, + }) +}