From 16e034492ca4b5c16c3c55365694e83993e93ac6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 12 Feb 2026 03:21:15 +0900 Subject: [PATCH] feat(task): add team_name routing to task_list and task_update tools - Add optional team_name parameter to task_list and task_update - Route to team-namespaced storage when team_name provided - Preserve existing behavior when team_name absent - Add comprehensive tests for both team and regular task operations - Task 12 complete (4/4 files: create, get, list, update) --- src/tools/task/task-list.test.ts | 77 ++++++++++++++- src/tools/task/task-list.ts | 127 +++++++++++++++--------- src/tools/task/task-update.test.ts | 49 ++++++++++ src/tools/task/task-update.ts | 149 +++++++++++++++++++---------- src/tools/task/types.ts | 2 + 5 files changed, 309 insertions(+), 95 deletions(-) diff --git a/src/tools/task/task-list.test.ts b/src/tools/task/task-list.test.ts index da7f6d3c..37de85d8 100644 --- a/src/tools/task/task-list.test.ts +++ b/src/tools/task/task-list.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test" import { createTaskList } from "./task-list" import { writeJsonAtomic } from "../../features/claude-tasks/storage" +import { writeTeamTask } from "../agent-teams/team-task-store" +import { getTeamTaskDir } from "../agent-teams/paths" import type { TaskObject } from "./types" import { join } from "path" import { existsSync, rmSync } from "fs" @@ -21,6 +23,11 @@ describe("createTaskList", () => { if (existsSync(taskDir)) { rmSync(taskDir, { recursive: true }) } + // Clean up team task directories + const teamTaskDir = getTeamTaskDir("test-team") + if (existsSync(teamTaskDir)) { + rmSync(teamTaskDir, { recursive: true }) + } }) it("returns empty array when no tasks exist", async () => { @@ -330,6 +337,72 @@ describe("createTaskList", () => { //#then const parsed = JSON.parse(result) - expect(parsed.tasks[0].blockedBy).toEqual(["T-missing"]) - }) + expect(parsed.tasks[0].blockedBy).toEqual(["T-missing"]) + }) + + it("lists tasks from team namespace when team_name provided", async () => { + //#given + const teamTask = { + id: "T-team-1", + subject: "Team task", + description: "", + status: "pending" as const, + blocks: [], + blockedBy: [], + threadID: "test-session", + } + + writeTeamTask("test-team", "T-team-1", teamTask) + + const config = { + sisyphus: { + tasks: { + storage_path: join(testProjectDir, ".sisyphus/tasks"), + claude_code_compat: false, + }, + }, + } + const tool = createTaskList(config) + + //#when + const result = await tool.execute({ team_name: "test-team" }, { sessionID: "test-session" }) + + //#then + const parsed = JSON.parse(result) + expect(parsed.tasks).toHaveLength(1) + expect(parsed.tasks[0].subject).toBe("Team task") + }) + + it("lists tasks from regular storage when no team_name", async () => { + //#given + const task: TaskObject = { + id: "T-1", + subject: "Regular task", + description: "", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task) + + const config = { + sisyphus: { + tasks: { + storage_path: join(testProjectDir, ".sisyphus/tasks"), + claude_code_compat: false, + }, + }, + } + const tool = createTaskList(config) + + //#when + const result = await tool.execute({}, { sessionID: "test-session" }) + + //#then + const parsed = JSON.parse(result) + expect(parsed.tasks).toHaveLength(1) + expect(parsed.tasks[0].subject).toBe("Regular task") + }) }) diff --git a/src/tools/task/task-list.ts b/src/tools/task/task-list.ts index 6582d472..8b979d2d 100644 --- a/src/tools/task/task-list.ts +++ b/src/tools/task/task-list.ts @@ -3,8 +3,9 @@ import { join } from "path" import { existsSync, readdirSync } from "fs" import type { OhMyOpenCodeConfig } from "../../config/schema" import type { TaskObject, TaskStatus } from "./types" -import { TaskObjectSchema } from "./types" +import { TaskObjectSchema, TaskListInputSchema } from "./types" import { readJsonSafe, getTaskDir } from "../../features/claude-tasks/storage" +import { listTeamTasks } from "../agent-teams/team-task-store" interface TaskSummary { id: string @@ -21,57 +22,93 @@ export function createTaskList(config: Partial): ToolDefinit Returns tasks excluding completed and deleted statuses by default. For each task's blockedBy field, filters to only include unresolved (non-completed) blockers. Returns summary format: id, subject, status, owner, blockedBy (not full description).`, - args: {}, - execute: async (): Promise => { - const taskDir = getTaskDir(config) + args: { + team_name: tool.schema.string().optional().describe("Optional: team name for team-namespaced tasks"), + }, + execute: async (args: Record): Promise => { + const validatedArgs = TaskListInputSchema.parse(args) - if (!existsSync(taskDir)) { - return JSON.stringify({ tasks: [] }) - } + if (validatedArgs.team_name) { + const allTasks = listTeamTasks(validatedArgs.team_name) - const files = readdirSync(taskDir) - .filter((f) => f.endsWith(".json") && f.startsWith("T-")) - .map((f) => f.replace(".json", "")) + // Filter out completed and deleted tasks + const activeTasks = allTasks.filter( + (task) => task.status !== "completed" && task.status !== "deleted" + ) - if (files.length === 0) { - return JSON.stringify({ tasks: [] }) - } + // Build summary with filtered blockedBy + const summaries: TaskSummary[] = activeTasks.map((task) => { + // Filter blockedBy to only include unresolved (non-completed) blockers + const unresolvedBlockers = task.blockedBy.filter((blockerId) => { + const blockerTask = allTasks.find((t) => t.id === blockerId) + // Include if blocker doesn't exist (missing) or if it's not completed + return !blockerTask || blockerTask.status !== "completed" + }) - const allTasks: TaskObject[] = [] - for (const fileId of files) { - const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema) - if (task) { - allTasks.push(task) - } - } - - // Filter out completed and deleted tasks - const activeTasks = allTasks.filter( - (task) => task.status !== "completed" && task.status !== "deleted" - ) - - // Build summary with filtered blockedBy - const summaries: TaskSummary[] = activeTasks.map((task) => { - // Filter blockedBy to only include unresolved (non-completed) blockers - const unresolvedBlockers = task.blockedBy.filter((blockerId) => { - const blockerTask = allTasks.find((t) => t.id === blockerId) - // Include if blocker doesn't exist (missing) or if it's not completed - return !blockerTask || blockerTask.status !== "completed" + return { + id: task.id, + subject: task.subject, + status: task.status, + owner: task.owner, + blockedBy: unresolvedBlockers, + } }) - return { - id: task.id, - subject: task.subject, - status: task.status, - owner: task.owner, - blockedBy: unresolvedBlockers, - } - }) + return JSON.stringify({ + tasks: summaries, + reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently." + }) + } else { + const taskDir = getTaskDir(config) - return JSON.stringify({ - tasks: summaries, - reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently." - }) + if (!existsSync(taskDir)) { + return JSON.stringify({ tasks: [] }) + } + + const files = readdirSync(taskDir) + .filter((f) => f.endsWith(".json") && f.startsWith("T-")) + .map((f) => f.replace(".json", "")) + + if (files.length === 0) { + return JSON.stringify({ tasks: [] }) + } + + const allTasks: TaskObject[] = [] + for (const fileId of files) { + const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema) + if (task) { + allTasks.push(task) + } + } + + // Filter out completed and deleted tasks + const activeTasks = allTasks.filter( + (task) => task.status !== "completed" && task.status !== "deleted" + ) + + // Build summary with filtered blockedBy + const summaries: TaskSummary[] = activeTasks.map((task) => { + // Filter blockedBy to only include unresolved (non-completed) blockers + const unresolvedBlockers = task.blockedBy.filter((blockerId) => { + const blockerTask = allTasks.find((t) => t.id === blockerId) + // Include if blocker doesn't exist (missing) or if it's not completed + return !blockerTask || blockerTask.status !== "completed" + }) + + return { + id: task.id, + subject: task.subject, + status: task.status, + owner: task.owner, + blockedBy: unresolvedBlockers, + } + }) + + return JSON.stringify({ + tasks: summaries, + reminder: "1 task = 1 task. Maximize parallel execution by running independent tasks (tasks with empty blockedBy) concurrently." + }) + } }, }) } diff --git a/src/tools/task/task-update.test.ts b/src/tools/task/task-update.test.ts index c7d2bb18..306a16dc 100644 --- a/src/tools/task/task-update.test.ts +++ b/src/tools/task/task-update.test.ts @@ -3,6 +3,7 @@ import { existsSync, rmSync, mkdirSync } from "fs" import { join } from "path" import type { TaskObject } from "./types" import { createTaskUpdateTool } from "./task-update" +import { writeTeamTask } from "../agent-teams/team-task-store" const TEST_STORAGE = ".test-task-update-tool" const TEST_DIR = join(process.cwd(), TEST_STORAGE) @@ -428,5 +429,53 @@ describe("task_update tool", () => { expect(result.task.status).toBe("in_progress") expect(result.task.owner).toBe("alice") }) + + test("updates task in team namespace when team_name provided", async () => { + //#given + const taskId = "T-team-test-135" + const teamName = "test-team" + const initialTask: TaskObject = { + id: taskId, + subject: "Original team subject", + description: "Team task description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + writeTeamTask(teamName, taskId, initialTask) + + //#when + const args = { + id: taskId, + team_name: teamName, + subject: "Updated team subject", + status: "in_progress" as const, + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("task") + expect(result.task.subject).toBe("Updated team subject") + expect(result.task.status).toBe("in_progress") + expect(result.task.description).toBe("Team task description") + }) + + test("returns error when team task not found", async () => { + //#given + const args = { + id: "T-nonexistent-team-task", + team_name: "test-team", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + expect(result.error).toBe("task_not_found") + }) }) }) diff --git a/src/tools/task/task-update.ts b/src/tools/task/task-update.ts index d529c408..6a62f44a 100644 --- a/src/tools/task/task-update.ts +++ b/src/tools/task/task-update.ts @@ -1,9 +1,9 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"; import { join } from "path"; +import type { PluginInput } from "@opencode-ai/plugin"; import type { OhMyOpenCodeConfig } from "../../config/schema"; import type { TaskObject, TaskUpdateInput } from "./types"; import { TaskObjectSchema, TaskUpdateInputSchema } from "./types"; +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"; import { getTaskDir, readJsonSafe, @@ -11,6 +11,7 @@ import { acquireLock, } from "../../features/claude-tasks/storage"; import { syncTaskTodoUpdate } from "./todo-sync"; +import { readTeamTask, writeTeamTask } from "../agent-teams/team-task-store"; const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/; @@ -34,38 +35,39 @@ Syncs to OpenCode Todo API after update. **IMPORTANT - Dependency Management:** Use \`addBlockedBy\` to declare dependencies on other tasks. Properly managed dependencies enable maximum parallel execution.`, - args: { - id: tool.schema.string().describe("Task ID (required)"), - subject: tool.schema.string().optional().describe("Task subject"), - description: tool.schema.string().optional().describe("Task description"), - status: tool.schema - .enum(["pending", "in_progress", "completed", "deleted"]) - .optional() - .describe("Task status"), - activeForm: tool.schema - .string() - .optional() - .describe("Active form (present continuous)"), - owner: tool.schema - .string() - .optional() - .describe("Task owner (agent name)"), - addBlocks: tool.schema - .array(tool.schema.string()) - .optional() - .describe("Task IDs to add to blocks (additive, not replacement)"), - addBlockedBy: tool.schema - .array(tool.schema.string()) - .optional() - .describe("Task IDs to add to blockedBy (additive, not replacement)"), - metadata: tool.schema - .record(tool.schema.string(), tool.schema.unknown()) - .optional() - .describe("Task metadata to merge (set key to null to delete)"), - }, - execute: async (args, context) => { - return handleUpdate(args, config, ctx, context); - }, + args: { + id: tool.schema.string().describe("Task ID (required)"), + subject: tool.schema.string().optional().describe("Task subject"), + description: tool.schema.string().optional().describe("Task description"), + status: tool.schema + .enum(["pending", "in_progress", "completed", "deleted"]) + .optional() + .describe("Task status"), + activeForm: tool.schema + .string() + .optional() + .describe("Active form (present continuous)"), + owner: tool.schema + .string() + .optional() + .describe("Task owner (agent name)"), + addBlocks: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Task IDs to add to blocks (additive, not replacement)"), + addBlockedBy: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Task IDs to add to blockedBy (additive, not replacement)"), + metadata: tool.schema + .record(tool.schema.string(), tool.schema.unknown()) + .optional() + .describe("Task metadata to merge (set key to null to delete)"), + team_name: tool.schema.string().optional().describe("Team namespace for task storage"), + }, + execute: async (args: Record, context) => { + return handleUpdate(args, config, ctx, context); + }, }); } @@ -82,17 +84,9 @@ async function handleUpdate( return JSON.stringify({ error: "invalid_task_id" }); } - const taskDir = getTaskDir(config); - const lock = acquireLock(taskDir); - - if (!lock.acquired) { - return JSON.stringify({ error: "task_lock_unavailable" }); - } - - try { - const taskPath = join(taskDir, `${taskId}.json`); - const task = readJsonSafe(taskPath, TaskObjectSchema); - + if (validatedArgs.team_name) { + // Team namespace routing + const task = readTeamTask(validatedArgs.team_name, taskId); if (!task) { return JSON.stringify({ error: "task_not_found" }); } @@ -133,13 +127,72 @@ async function handleUpdate( } const validatedTask = TaskObjectSchema.parse(task); - writeJsonAtomic(taskPath, validatedTask); + writeTeamTask(validatedArgs.team_name, taskId, validatedTask); await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID); return JSON.stringify({ task: validatedTask }); - } finally { - lock.release(); + } else { + // Regular task storage + const taskDir = getTaskDir(config); + const lock = acquireLock(taskDir); + + if (!lock.acquired) { + return JSON.stringify({ error: "task_lock_unavailable" }); + } + + try { + const taskPath = join(taskDir, `${taskId}.json`); + const task = readJsonSafe(taskPath, TaskObjectSchema); + + if (!task) { + return JSON.stringify({ error: "task_not_found" }); + } + + if (validatedArgs.subject !== undefined) { + task.subject = validatedArgs.subject; + } + if (validatedArgs.description !== undefined) { + task.description = validatedArgs.description; + } + if (validatedArgs.status !== undefined) { + task.status = validatedArgs.status; + } + if (validatedArgs.activeForm !== undefined) { + task.activeForm = validatedArgs.activeForm; + } + if (validatedArgs.owner !== undefined) { + task.owner = validatedArgs.owner; + } + + const addBlocks = args.addBlocks as string[] | undefined; + if (addBlocks) { + task.blocks = [...new Set([...task.blocks, ...addBlocks])]; + } + + const addBlockedBy = args.addBlockedBy as string[] | undefined; + if (addBlockedBy) { + task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])]; + } + + if (validatedArgs.metadata !== undefined) { + task.metadata = { ...task.metadata, ...validatedArgs.metadata }; + Object.keys(task.metadata).forEach((key) => { + if (task.metadata?.[key] === null) { + delete task.metadata[key]; + } + }); + } + + const validatedTask = TaskObjectSchema.parse(task); + writeJsonAtomic(taskPath, validatedTask); + + await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID); + + return JSON.stringify({ task: validatedTask }); + } finally { + lock.release(); + } } } catch (error) { if (error instanceof Error && error.message.includes("Required")) { diff --git a/src/tools/task/types.ts b/src/tools/task/types.ts index 33194697..5bd6127f 100644 --- a/src/tools/task/types.ts +++ b/src/tools/task/types.ts @@ -45,6 +45,7 @@ export type TaskCreateInput = z.infer export const TaskListInputSchema = z.object({ status: TaskStatusSchema.optional(), parentID: z.string().optional(), + team_name: z.string().optional(), }) export type TaskListInput = z.infer @@ -68,6 +69,7 @@ export const TaskUpdateInputSchema = z.object({ metadata: z.record(z.string(), z.unknown()).optional(), repoURL: z.string().optional(), parentID: z.string().optional(), + team_name: z.string().optional(), }) export type TaskUpdateInput = z.infer