diff --git a/src/tools/task/index.ts b/src/tools/task/index.ts index 0df3ba704..baac235af 100644 --- a/src/tools/task/index.ts +++ b/src/tools/task/index.ts @@ -1,2 +1,6 @@ export { createTask } from "./task" +export { createTaskCreateTool } from "./task-create" +export { createTaskUpdateTool } from "./task-update" +export { syncTaskToTodo, syncAllTasksToTodos } from "./todo-sync" export type { TaskObject, TaskStatus, TaskCreateInput, TaskListInput, TaskGetInput, TaskUpdateInput, TaskDeleteInput } from "./types" +export type { TodoInfo } from "./todo-sync" diff --git a/src/tools/task/task-update.test.ts b/src/tools/task/task-update.test.ts new file mode 100644 index 000000000..c7d2bb181 --- /dev/null +++ b/src/tools/task/task-update.test.ts @@ -0,0 +1,432 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { existsSync, rmSync, mkdirSync } from "fs" +import { join } from "path" +import type { TaskObject } from "./types" +import { createTaskUpdateTool } from "./task-update" + +const TEST_STORAGE = ".test-task-update-tool" +const TEST_DIR = join(process.cwd(), TEST_STORAGE) +const TEST_CONFIG = { + sisyphus: { + tasks: { + storage_path: TEST_STORAGE, + }, + }, +} +const TEST_SESSION_ID = "test-session-123" +const TEST_ABORT_CONTROLLER = new AbortController() +const TEST_CONTEXT = { + sessionID: TEST_SESSION_ID, + messageID: "test-message-123", + agent: "test-agent", + abort: TEST_ABORT_CONTROLLER.signal, +} + +describe("task_update tool", () => { + let tool: ReturnType + + beforeEach(() => { + if (existsSync(TEST_STORAGE)) { + rmSync(TEST_STORAGE, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) + tool = createTaskUpdateTool(TEST_CONFIG) + }) + + afterEach(() => { + if (existsSync(TEST_STORAGE)) { + rmSync(TEST_STORAGE, { recursive: true, force: true }) + } + }) + + describe("update action", () => { + test("updates task subject when provided", async () => { + //#given + const taskId = "T-test-123" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Original subject", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + subject: "Updated subject", + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("task") + expect(result.task.subject).toBe("Updated subject") + expect(result.task.description).toBe("Test description") + }) + + test("updates task description when provided", async () => { + //#given + const taskId = "T-test-124" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Original description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + description: "Updated description", + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.description).toBe("Updated description") + }) + + test("updates task status when provided", async () => { + //#given + const taskId = "T-test-125" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + status: "in_progress" as const, + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.status).toBe("in_progress") + }) + + test("additively appends to blocks array without replacing", async () => { + //#given + const taskId = "T-test-126" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Test description", + status: "pending", + blocks: ["T-existing-1"], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + addBlocks: ["T-new-1", "T-new-2"], + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.blocks).toContain("T-existing-1") + expect(result.task.blocks).toContain("T-new-1") + expect(result.task.blocks).toContain("T-new-2") + expect(result.task.blocks.length).toBe(3) + }) + + test("avoids duplicate blocks when adding", async () => { + //#given + const taskId = "T-test-127" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Test description", + status: "pending", + blocks: ["T-existing-1"], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + addBlocks: ["T-existing-1", "T-new-1"], + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.blocks).toContain("T-existing-1") + expect(result.task.blocks).toContain("T-new-1") + expect(result.task.blocks.length).toBe(2) + }) + + test("additively appends to blockedBy array without replacing", async () => { + //#given + const taskId = "T-test-128" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: ["T-blocker-1"], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + addBlockedBy: ["T-blocker-2", "T-blocker-3"], + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.blockedBy).toContain("T-blocker-1") + expect(result.task.blockedBy).toContain("T-blocker-2") + expect(result.task.blockedBy).toContain("T-blocker-3") + expect(result.task.blockedBy.length).toBe(3) + }) + + test("merges metadata without replacing entire object", async () => { + //#given + const taskId = "T-test-129" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + metadata: { + priority: "high", + assignee: "alice", + }, + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + metadata: { + priority: "low", + tags: ["bug"], + }, + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.metadata.priority).toBe("low") + expect(result.task.metadata.assignee).toBe("alice") + expect(result.task.metadata.tags).toEqual(["bug"]) + }) + + test("deletes metadata keys when set to null", async () => { + //#given + const taskId = "T-test-130" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + metadata: { + priority: "high", + assignee: "alice", + tags: ["bug"], + }, + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + metadata: { + assignee: null, + }, + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.metadata.priority).toBe("high") + expect(result.task.metadata.assignee).toBeUndefined() + expect(result.task.metadata.tags).toEqual(["bug"]) + }) + + test("updates activeForm when provided", async () => { + //#given + const taskId = "T-test-131" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + activeForm: "implementing feature X", + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.activeForm).toBe("implementing feature X") + }) + + test("updates owner when provided", async () => { + //#given + const taskId = "T-test-132" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Test subject", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + owner: "sisyphus", + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.owner).toBe("sisyphus") + }) + + test("returns error when task not found", async () => { + //#given + const args = { + id: "T-nonexistent", + } + + //#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") + }) + + test("returns error for invalid task ID format", async () => { + //#given + const args = { + id: "invalid-id", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + expect(result.error).toBe("invalid_task_id") + }) + + test("persists changes to file storage", async () => { + //#given + const taskId = "T-test-133" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Original subject", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + subject: "Updated subject", + } + await tool.execute(args, TEST_CONTEXT) + + //#then + const savedContent = await Bun.file(taskPath).text() + const savedTask = JSON.parse(savedContent) + expect(savedTask.subject).toBe("Updated subject") + }) + + test("updates multiple fields in single call", async () => { + //#given + const taskId = "T-test-134" + const taskPath = join(TEST_DIR, `${taskId}.json`) + const initialTask: TaskObject = { + id: taskId, + subject: "Original subject", + description: "Original description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + await Bun.write(taskPath, JSON.stringify(initialTask)) + + //#when + const args = { + id: taskId, + subject: "New subject", + description: "New description", + status: "in_progress" as const, + owner: "alice", + } + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.subject).toBe("New subject") + expect(result.task.description).toBe("New description") + expect(result.task.status).toBe("in_progress") + expect(result.task.owner).toBe("alice") + }) + }) +}) diff --git a/src/tools/task/task-update.ts b/src/tools/task/task-update.ts new file mode 100644 index 000000000..583e8ae3d --- /dev/null +++ b/src/tools/task/task-update.ts @@ -0,0 +1,137 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import type { TaskObject, TaskUpdateInput } from "./types" +import { TaskObjectSchema, TaskUpdateInputSchema } from "./types" +import { + getTaskDir, + readJsonSafe, + writeJsonAtomic, + acquireLock, +} from "../../features/claude-tasks/storage" +import { syncTaskToTodo } from "./todo-sync" + +const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/ + +function parseTaskId(id: string): string | null { + if (!TASK_ID_PATTERN.test(id)) return null + return id +} + +export function createTaskUpdateTool( + config: Partial +): ToolDefinition { + return tool({ + description: `Update an existing task with new values. + +Supports updating: subject, description, status, activeForm, owner, metadata. +For blocks/blockedBy: use addBlocks/addBlockedBy to append (additive, not replacement). +For metadata: merge with existing, set key to null to delete. +Syncs to OpenCode Todo API after update.`, + 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, context) + }, + }) +} + +async function handleUpdate( + args: Record, + config: Partial, + context: { sessionID: string } +): Promise { + try { + const validatedArgs = TaskUpdateInputSchema.parse(args) + const taskId = parseTaskId(validatedArgs.id) + if (!taskId) { + 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 (!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) + + syncTaskToTodo(validatedTask) + + return JSON.stringify({ task: validatedTask }) + } finally { + lock.release() + } + } catch (error) { + if (error instanceof Error && error.message.includes("Required")) { + return JSON.stringify({ error: "validation_error", message: error.message }) + } + return JSON.stringify({ error: "internal_error" }) + } +}