feat(team-mode): add team tasks tools with tests

This commit is contained in:
YeonGyu-Kim
2026-04-28 10:47:05 +09:00
parent b71c6e1905
commit f2555feadb
2 changed files with 285 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
/// <reference types="bun-types" />
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<RuntimeState> => ({
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")
})
})
+132
View File
@@ -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<string> {
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<string> => {
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<string> => {
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<string> => {
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<string> => {
const task = await getTask(args.teamRunId, args.taskId, config)
return JSON.stringify({ task })
},
})
}