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