diff --git a/src/features/claude-tasks/AGENTS.md b/src/features/claude-tasks/AGENTS.md index ce5ae4a25..356cdd65a 100644 --- a/src/features/claude-tasks/AGENTS.md +++ b/src/features/claude-tasks/AGENTS.md @@ -22,21 +22,33 @@ type TaskStatus = "pending" | "in_progress" | "completed" | "deleted" interface Task { id: string - subject: string // Imperative: "Run tests" + subject: string // Imperative: "Run tests" (was: title) description: string status: TaskStatus activeForm?: string // Present continuous: "Running tests" blocks: string[] // Task IDs this task blocks - blockedBy: string[] // Task IDs blocking this task + blockedBy: string[] // Task IDs blocking this task (was: dependsOn) owner?: string // Agent name metadata?: Record + repoURL?: string // oh-my-opencode specific + parentID?: string // oh-my-opencode specific + threadID: string // oh-my-opencode specific } ``` **Key Differences from Legacy**: - `subject` (was `title`) - `blockedBy` (was `dependsOn`) -- No `parentID`, `repoURL`, `threadID` fields +- `blocks` (new field) +- `activeForm` (new field) + +## TODO SYNC + +The task system includes a sync layer (`todo-sync.ts`) that automatically mirrors task state to the project's Todo system. + +- **Creation**: Creating a task via `task_create` adds a corresponding item to the Todo list. +- **Updates**: Updating a task's `status` or `subject` via `task_update` reflects in the Todo list. +- **Completion**: Marking a task as `completed` automatically marks the Todo item as done. ## STORAGE UTILITIES diff --git a/src/index.ts b/src/index.ts index b571759cf..8f5232dda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import type { Plugin } from "@opencode-ai/plugin"; +import type { Plugin, ToolDefinition } from "@opencode-ai/plugin"; import { createTodoContinuationEnforcer, createContextWindowMonitorHook, @@ -73,7 +73,10 @@ import { interactive_bash, startTmuxCheck, lspManager, - createTask, + createTaskCreateTool, + createTaskGetTool, + createTaskList, + createTaskUpdateTool, } from "./tools"; import { BackgroundManager } from "./features/background-agent"; import { SkillMcpManager } from "./features/skill-mcp-manager"; @@ -421,7 +424,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { }); const newTaskSystemEnabled = pluginConfig.new_task_system_enabled ?? false; - const taskTool = newTaskSystemEnabled ? createTask(pluginConfig) : null; + const taskToolsRecord: Record = newTaskSystemEnabled ? { + task_create: createTaskCreateTool(pluginConfig), + task_get: createTaskGetTool(pluginConfig), + task_list: createTaskList(pluginConfig), + task_update: createTaskUpdateTool(pluginConfig), + } : {}; return { tool: { @@ -434,7 +442,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { skill_mcp: skillMcpTool, slashcommand: slashcommandTool, interactive_bash, - ...(taskTool ? { task: taskTool } : {}), + ...taskToolsRecord, }, "chat.message": async (input, output) => { diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index feea6dcb3..d83664583 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -37,11 +37,25 @@ tools/ | LSP | lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename | Direct | | Search | ast_grep_search, ast_grep_replace, grep, glob | Direct | | Session | session_list, session_read, session_search, session_info | Direct | +| Task | task_create, task_get, task_list, task_update | Factory | | Agent | delegate_task, call_omo_agent | Factory | | Background | background_output, background_cancel | Factory | | System | interactive_bash, look_at | Mixed | | Skill | skill, skill_mcp, slashcommand | Factory | +## TASK TOOLS + +Claude Code compatible task management. + +- **task_create**: Creates a new task. Auto-generates ID and syncs to Todo. + - Args: `subject`, `description`, `activeForm`, `blocks`, `blockedBy`, `owner`, `metadata` +- **task_get**: Retrieves a task by ID. + - Args: `id` +- **task_list**: Lists active tasks. Filters out completed/deleted by default. + - Args: `status`, `parentID` +- **task_update**: Updates task fields. Supports additive `addBlocks`/`addBlockedBy`. + - Args: `id`, `subject`, `description`, `status`, `activeForm`, `addBlocks`, `addBlockedBy`, `owner`, `metadata` + ## HOW TO ADD 1. Create `src/tools/[name]/` with standard files diff --git a/src/tools/index.ts b/src/tools/index.ts index fc415b90e..f1437d3cc 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -47,6 +47,12 @@ type OpencodeClient = PluginInput["client"] export { createCallOmoAgent } from "./call-omo-agent" export { createLookAt } from "./look-at" export { createDelegateTask } from "./delegate-task" +export { + createTaskCreateTool, + createTaskGetTool, + createTaskList, + createTaskUpdateTool, +} from "./task" export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record { const outputManager: BackgroundOutputManager = manager @@ -73,5 +79,3 @@ export const builtinTools: Record = { session_search, session_info, } - -export { createTask } from "./task" diff --git a/src/tools/task/index.ts b/src/tools/task/index.ts index baac235af..a6e0e376b 100644 --- a/src/tools/task/index.ts +++ b/src/tools/task/index.ts @@ -1,5 +1,6 @@ -export { createTask } from "./task" export { createTaskCreateTool } from "./task-create" +export { createTaskGetTool } from "./task-get" +export { createTaskList } from "./task-list" export { createTaskUpdateTool } from "./task-update" export { syncTaskToTodo, syncAllTasksToTodos } from "./todo-sync" export type { TaskObject, TaskStatus, TaskCreateInput, TaskListInput, TaskGetInput, TaskUpdateInput, TaskDeleteInput } from "./types" diff --git a/src/tools/task/task-create.test.ts b/src/tools/task/task-create.test.ts new file mode 100644 index 000000000..5c2758ad1 --- /dev/null +++ b/src/tools/task/task-create.test.ts @@ -0,0 +1,301 @@ +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 { createTaskCreateTool } from "./task-create" + +const TEST_STORAGE = ".test-task-create-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_create tool", () => { + let tool: ReturnType + + beforeEach(() => { + if (existsSync(TEST_STORAGE)) { + rmSync(TEST_STORAGE, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) + tool = createTaskCreateTool(TEST_CONFIG) + }) + + afterEach(() => { + if (existsSync(TEST_STORAGE)) { + rmSync(TEST_STORAGE, { recursive: true, force: true }) + } + }) + + describe("create action", () => { + test("creates task with required subject field", async () => { + //#given + const args = { + subject: "Implement authentication", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("task") + expect(result.task).toHaveProperty("id") + expect(result.task.subject).toBe("Implement authentication") + }) + + test("auto-generates T-{uuid} format ID", async () => { + //#given + const args = { + subject: "Test task", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.id).toMatch(/^T-[a-f0-9-]+$/) + }) + + test("auto-records threadID from session context", async () => { + //#given + const args = { + subject: "Test task", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + expect(existsSync(taskFile)).toBe(true) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.threadID).toBe(TEST_SESSION_ID) + }) + + test("sets default status to pending", async () => { + //#given + const args = { + subject: "Test task", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.status).toBe("pending") + }) + + test("sets default blocks and blockedBy to empty arrays", async () => { + //#given + const args = { + subject: "Test task", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.blocks).toEqual([]) + expect(taskContent.blockedBy).toEqual([]) + }) + + test("accepts optional description", async () => { + //#given + const args = { + subject: "Test task", + description: "This is a test description", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.description).toBe("This is a test description") + }) + + test("accepts optional activeForm", async () => { + //#given + const args = { + subject: "Test task", + activeForm: "Implementing authentication", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.activeForm).toBe("Implementing authentication") + }) + + test("accepts optional metadata", async () => { + //#given + const args = { + subject: "Test task", + metadata: { priority: "high", tags: ["urgent"] }, + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.metadata).toEqual({ priority: "high", tags: ["urgent"] }) + }) + + test("accepts optional blockedBy array", async () => { + //#given + const args = { + subject: "Test task", + blockedBy: ["T-123", "T-456"], + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.blockedBy).toEqual(["T-123", "T-456"]) + }) + + test("accepts optional blocks array", async () => { + //#given + const args = { + subject: "Test task", + blocks: ["T-789", "T-101"], + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.blocks).toEqual(["T-789", "T-101"]) + }) + + test("accepts optional repoURL", async () => { + //#given + const args = { + subject: "Test task", + repoURL: "https://github.com/example/repo", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.repoURL).toBe("https://github.com/example/repo") + }) + + test("accepts optional parentID", async () => { + //#given + const args = { + subject: "Test task", + parentID: "T-parent-123", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.parentID).toBe("T-parent-123") + }) + + test("returns minimal response with id and subject", async () => { + //#given + const args = { + subject: "Test task", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task).toHaveProperty("id") + expect(result.task).toHaveProperty("subject") + expect(result.task.subject).toBe("Test task") + }) + + test("rejects missing subject", async () => { + //#given + const args = {} + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + }) + + test("writes task to file storage atomically", async () => { + //#given + const args = { + subject: "Test task", + description: "Test description", + } + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + const taskId = result.task.id + + //#then + const taskFile = join(TEST_DIR, `${taskId}.json`) + expect(existsSync(taskFile)).toBe(true) + const taskContent = JSON.parse(await Bun.file(taskFile).text()) + expect(taskContent.id).toBe(taskId) + expect(taskContent.subject).toBe("Test task") + expect(taskContent.description).toBe("Test description") + }) + }) +}) diff --git a/src/tools/task/task-create.ts b/src/tools/task/task-create.ts new file mode 100644 index 000000000..0b8be8765 --- /dev/null +++ b/src/tools/task/task-create.ts @@ -0,0 +1,97 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import type { TaskObject } from "./types" +import { TaskObjectSchema, TaskCreateInputSchema } from "./types" +import { + getTaskDir, + writeJsonAtomic, + acquireLock, + generateTaskId, +} from "../../features/claude-tasks/storage" +import { syncTaskToTodo } from "./todo-sync" + +export function createTaskCreateTool( + config: Partial +): ToolDefinition { + return tool({ + description: `Create a new task with auto-generated ID and threadID recording. + +Auto-generates T-{uuid} ID, records threadID from context, sets status to "pending". +Returns minimal response with task ID and subject.`, + args: { + subject: tool.schema.string().describe("Task subject (required)"), + description: tool.schema.string().optional().describe("Task description"), + activeForm: tool.schema.string().optional().describe("Active form (present continuous)"), + metadata: tool.schema + .record(tool.schema.string(), tool.schema.unknown()) + .optional() + .describe("Task metadata"), + blockedBy: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Task IDs blocking this task"), + blocks: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Task IDs this task blocks"), + repoURL: tool.schema.string().optional().describe("Repository URL"), + parentID: tool.schema.string().optional().describe("Parent task ID"), + }, + execute: async (args, context) => { + return handleCreate(args, config, context) + }, + }) +} + +async function handleCreate( + args: Record, + config: Partial, + context: { sessionID: string } +): Promise { + try { + const validatedArgs = TaskCreateInputSchema.parse(args) + const taskDir = getTaskDir(config) + const lock = acquireLock(taskDir) + + if (!lock.acquired) { + return JSON.stringify({ error: "task_lock_unavailable" }) + } + + try { + const taskId = generateTaskId() + const task: TaskObject = { + id: taskId, + subject: validatedArgs.subject, + description: validatedArgs.description ?? "", + status: "pending", + blocks: validatedArgs.blocks ?? [], + blockedBy: validatedArgs.blockedBy ?? [], + activeForm: validatedArgs.activeForm, + metadata: validatedArgs.metadata, + repoURL: validatedArgs.repoURL, + parentID: validatedArgs.parentID, + threadID: context.sessionID, + } + + const validatedTask = TaskObjectSchema.parse(task) + writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask) + + syncTaskToTodo(validatedTask) + + return JSON.stringify({ + task: { + id: validatedTask.id, + subject: validatedTask.subject, + }, + }) + } 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" }) + } +} diff --git a/src/tools/task/task-get.test.ts b/src/tools/task/task-get.test.ts new file mode 100644 index 000000000..640b8bc03 --- /dev/null +++ b/src/tools/task/task-get.test.ts @@ -0,0 +1,222 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { existsSync, rmSync, mkdirSync, writeFileSync } from "fs" +import { join } from "path" +import type { TaskObject } from "./types" +import { createTaskGetTool } from "./task-get" + +const TEST_STORAGE = ".test-task-get-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_get tool", () => { + let tool: ReturnType + + beforeEach(() => { + if (existsSync(TEST_STORAGE)) { + rmSync(TEST_STORAGE, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) + tool = createTaskGetTool(TEST_CONFIG) + }) + + afterEach(() => { + if (existsSync(TEST_STORAGE)) { + rmSync(TEST_STORAGE, { recursive: true, force: true }) + } + }) + + describe("get action", () => { + test("retrieves existing task by ID", async () => { + //#given + const taskId = "T-test-123" + const taskData: TaskObject = { + id: taskId, + subject: "Test task", + description: "Test description", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + const taskFile = join(TEST_DIR, `${taskId}.json`) + writeFileSync(taskFile, JSON.stringify(taskData, null, 2)) + + //#when + const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("task") + expect(result.task).not.toBeNull() + expect(result.task.id).toBe(taskId) + expect(result.task.subject).toBe("Test task") + expect(result.task.description).toBe("Test description") + }) + + test("returns null for non-existent task", async () => { + //#given + const taskId = "T-nonexistent-999" + + //#when + const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("task") + expect(result.task).toBeNull() + }) + + test("returns full task object with all fields", async () => { + //#given + const taskId = "T-full-task-456" + const taskData: TaskObject = { + id: taskId, + subject: "Complex task", + description: "Full description", + status: "in_progress", + activeForm: "Working on complex task", + blocks: ["T-blocked-1", "T-blocked-2"], + blockedBy: ["T-blocker-1"], + owner: "test-agent", + metadata: { priority: "high", tags: ["urgent", "backend"] }, + repoURL: "https://github.com/example/repo", + parentID: "T-parent-123", + threadID: TEST_SESSION_ID, + } + const taskFile = join(TEST_DIR, `${taskId}.json`) + writeFileSync(taskFile, JSON.stringify(taskData, null, 2)) + + //#when + const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task).toEqual(taskData) + expect(result.task.blocks).toEqual(["T-blocked-1", "T-blocked-2"]) + expect(result.task.blockedBy).toEqual(["T-blocker-1"]) + expect(result.task.metadata).toEqual({ priority: "high", tags: ["urgent", "backend"] }) + }) + + test("rejects invalid task ID format", async () => { + //#given + const invalidTaskId = "invalid-id-format" + + //#when + const resultStr = await tool.execute({ id: invalidTaskId }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + expect(result.error).toBe("invalid_task_id") + }) + + test("returns null for malformed task file", async () => { + //#given + const taskId = "T-malformed-789" + const taskFile = join(TEST_DIR, `${taskId}.json`) + writeFileSync(taskFile, "{ invalid json }") + + //#when + const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task).toBeNull() + }) + + test("returns null for task file with invalid schema", async () => { + //#given + const taskId = "T-invalid-schema-101" + const taskFile = join(TEST_DIR, `${taskId}.json`) + const invalidData = { + id: taskId, + subject: "Missing required fields", + // Missing description and threadID + } + writeFileSync(taskFile, JSON.stringify(invalidData, null, 2)) + + //#when + const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task).toBeNull() + }) + + test("requires id parameter", async () => { + //#given + const args = {} + + //#when + const resultStr = await tool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + }) + + test("handles task with empty blocks and blockedBy arrays", async () => { + //#given + const taskId = "T-empty-arrays-202" + const taskData: TaskObject = { + id: taskId, + subject: "Task with empty arrays", + description: "Test", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + const taskFile = join(TEST_DIR, `${taskId}.json`) + writeFileSync(taskFile, JSON.stringify(taskData, null, 2)) + + //#when + const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.blocks).toEqual([]) + expect(result.task.blockedBy).toEqual([]) + }) + + test("handles task with optional fields omitted", async () => { + //#given + const taskId = "T-minimal-303" + const taskData: TaskObject = { + id: taskId, + subject: "Minimal task", + description: "Minimal", + status: "pending", + blocks: [], + blockedBy: [], + threadID: TEST_SESSION_ID, + } + const taskFile = join(TEST_DIR, `${taskId}.json`) + writeFileSync(taskFile, JSON.stringify(taskData, null, 2)) + + //#when + const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task).not.toBeNull() + expect(result.task.id).toBe(taskId) + expect(result.task.owner).toBeUndefined() + expect(result.task.metadata).toBeUndefined() + }) + }) +}) diff --git a/src/tools/task/task-get.ts b/src/tools/task/task-get.ts new file mode 100644 index 000000000..94ac9d892 --- /dev/null +++ b/src/tools/task/task-get.ts @@ -0,0 +1,48 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import type { TaskGetInput } from "./types" +import { TaskGetInputSchema, TaskObjectSchema } from "./types" +import { getTaskDir, readJsonSafe } from "../../features/claude-tasks/storage" + +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 createTaskGetTool(config: Partial): ToolDefinition { + return tool({ + description: `Retrieve a task by ID. + +Returns the full task object including all fields: id, subject, description, status, activeForm, blocks, blockedBy, owner, metadata, repoURL, parentID, and threadID. + +Returns null if the task does not exist or the file is invalid.`, + args: { + id: tool.schema.string().describe("Task ID to retrieve (format: T-{uuid})"), + }, + execute: async (args: Record): Promise => { + try { + const validatedArgs = TaskGetInputSchema.parse(args) + const taskId = parseTaskId(validatedArgs.id) + + if (!taskId) { + return JSON.stringify({ error: "invalid_task_id" }) + } + + const taskDir = getTaskDir(config) + const taskPath = join(taskDir, `${taskId}.json`) + + const task = readJsonSafe(taskPath, TaskObjectSchema) + + return JSON.stringify({ task: task ?? null }) + } catch (error) { + if (error instanceof Error && error.message.includes("validation")) { + return JSON.stringify({ error: "invalid_arguments" }) + } + return JSON.stringify({ error: "unknown_error" }) + } + }, + }) +} diff --git a/src/tools/task/task-list.test.ts b/src/tools/task/task-list.test.ts new file mode 100644 index 000000000..da7f6d3c5 --- /dev/null +++ b/src/tools/task/task-list.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { createTaskList } from "./task-list" +import { writeJsonAtomic } from "../../features/claude-tasks/storage" +import type { TaskObject } from "./types" +import { join } from "path" +import { existsSync, rmSync } from "fs" + +const testProjectDir = "/tmp/task-list-test" + +describe("createTaskList", () => { + let taskDir: string + + beforeEach(() => { + taskDir = join(testProjectDir, ".sisyphus/tasks") + if (existsSync(taskDir)) { + rmSync(taskDir, { recursive: true }) + } + }) + + afterEach(() => { + if (existsSync(taskDir)) { + rmSync(taskDir, { recursive: true }) + } + }) + + it("returns empty array when no tasks exist", async () => { + //#given + 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).toEqual([]) + }) + + it("excludes completed tasks by default", async () => { + //#given + const task1: TaskObject = { + id: "T-1", + subject: "Active task", + description: "Should be included", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + const task2: TaskObject = { + id: "T-2", + subject: "Completed task", + description: "Should be excluded", + status: "completed", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task1) + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-2.json"), task2) + + 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].id).toBe("T-1") + }) + + it("excludes deleted tasks by default", async () => { + //#given + const task1: TaskObject = { + id: "T-1", + subject: "Active task", + description: "Should be included", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + const task2: TaskObject = { + id: "T-2", + subject: "Deleted task", + description: "Should be excluded", + status: "deleted", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task1) + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-2.json"), task2) + + 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].id).toBe("T-1") + }) + + it("returns summary format with id, subject, status, owner, blockedBy", async () => { + //#given + const task: TaskObject = { + id: "T-1", + subject: "Test task", + description: "This is a long description that should not be included", + status: "in_progress", + owner: "sisyphus", + blocks: [], + blockedBy: ["T-2"], + 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) + const summary = parsed.tasks[0] + expect(summary).toHaveProperty("id") + expect(summary).toHaveProperty("subject") + expect(summary).toHaveProperty("status") + expect(summary).toHaveProperty("owner") + expect(summary).toHaveProperty("blockedBy") + expect(summary).not.toHaveProperty("description") + expect(summary.id).toBe("T-1") + expect(summary.subject).toBe("Test task") + expect(summary.status).toBe("in_progress") + expect(summary.owner).toBe("sisyphus") + expect(summary.blockedBy).toEqual(["T-2"]) + }) + + it("filters blockedBy to only include unresolved (non-completed) blockers", async () => { + //#given + const blockerCompleted: TaskObject = { + id: "T-blocker-completed", + subject: "Completed blocker", + description: "", + status: "completed", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + const blockerPending: TaskObject = { + id: "T-blocker-pending", + subject: "Pending blocker", + description: "", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + const mainTask: TaskObject = { + id: "T-main", + subject: "Main task", + description: "", + status: "pending", + blocks: [], + blockedBy: ["T-blocker-completed", "T-blocker-pending"], + threadID: "test-session", + } + + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-blocker-completed.json"), blockerCompleted) + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-blocker-pending.json"), blockerPending) + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-main.json"), mainTask) + + 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) + const mainTaskSummary = parsed.tasks.find((t: { id: string }) => t.id === "T-main") + expect(mainTaskSummary.blockedBy).toEqual(["T-blocker-pending"]) + }) + + it("includes all active statuses (pending, in_progress)", async () => { + //#given + const task1: TaskObject = { + id: "T-1", + subject: "Pending task", + description: "", + status: "pending", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + const task2: TaskObject = { + id: "T-2", + subject: "In progress task", + description: "", + status: "in_progress", + blocks: [], + blockedBy: [], + threadID: "test-session", + } + + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task1) + writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-2.json"), task2) + + 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(2) + }) + + it("handles tasks with no blockedBy gracefully", async () => { + //#given + const task: TaskObject = { + id: "T-1", + subject: "Task with no blockers", + 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[0].blockedBy).toEqual([]) + }) + + it("handles missing blocker tasks gracefully", async () => { + //#given + const task: TaskObject = { + id: "T-1", + subject: "Task with missing blocker", + description: "", + status: "pending", + blocks: [], + blockedBy: ["T-missing"], + 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[0].blockedBy).toEqual(["T-missing"]) + }) +}) diff --git a/src/tools/task/task-list.ts b/src/tools/task/task-list.ts new file mode 100644 index 000000000..0328bfc5a --- /dev/null +++ b/src/tools/task/task-list.ts @@ -0,0 +1,76 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +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 { readJsonSafe } from "../../features/claude-tasks/storage" + +interface TaskSummary { + id: string + subject: string + status: TaskStatus + owner?: string + blockedBy: string[] +} + +export function createTaskList(config: Partial): ToolDefinition { + return tool({ + description: `List all active tasks with summary information. + +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 tasksConfig = config.sisyphus?.tasks + const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks" + const taskDir = storagePath.startsWith("/") ? storagePath : join(process.cwd(), storagePath) + + 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 }) + }, + }) +} diff --git a/src/tools/task/task.ts b/src/tools/task/task.ts index 25432fd77..dc6602810 100644 --- a/src/tools/task/task.ts +++ b/src/tools/task/task.ts @@ -38,27 +38,27 @@ export function createTask(config: Partial): ToolDefinition return tool({ description: `Unified task management tool with create, list, get, update, delete actions. -**CREATE**: Create a new task. Auto-generates T-{uuid} ID, records threadID, sets status to "open". -**LIST**: List tasks. Excludes completed by default. Supports ready filter (all dependencies completed) and limit. -**GET**: Retrieve a task by ID. -**UPDATE**: Update task fields. Requires task ID. -**DELETE**: Physically remove task file. +CREATE: Create a new task. Auto-generates T-{uuid} ID, records threadID, sets status to "pending". +LIST: List tasks. Excludes completed by default. Supports ready filter (all dependencies completed) and limit. +GET: Retrieve a task by ID. +UPDATE: Update task fields. Requires task ID. +DELETE: Physically remove task file. All actions return JSON strings.`, args: { action: tool.schema .enum(["create", "list", "get", "update", "delete"]) .describe("Action to perform: create, list, get, update, delete"), - title: tool.schema.string().optional().describe("Task title (required for create)"), + subject: tool.schema.string().optional().describe("Task subject (required for create)"), description: tool.schema.string().optional().describe("Task description"), status: tool.schema - .enum(["open", "in_progress", "completed"]) + .enum(["pending", "in_progress", "completed", "deleted"]) .optional() .describe("Task status"), - dependsOn: tool.schema + blockedBy: tool.schema .array(tool.schema.string()) .optional() - .describe("Task IDs this task depends on"), + .describe("Task IDs this task is blocked by"), repoURL: tool.schema.string().optional().describe("Repository URL"), parentID: tool.schema.string().optional().describe("Parent task ID"), id: tool.schema.string().optional().describe("Task ID (required for get, update, delete)"), @@ -99,18 +99,19 @@ async function handleCreate( return JSON.stringify({ error: "task_lock_unavailable" }) } - try { - const taskId = generateTaskId() - const task: TaskObject = { - id: taskId, - title: validatedArgs.title, - description: validatedArgs.description, - status: "open", - dependsOn: validatedArgs.dependsOn ?? [], - repoURL: validatedArgs.repoURL, - parentID: validatedArgs.parentID, - threadID: context.sessionID, - } + try { + const taskId = generateTaskId() + const task: TaskObject = { + id: taskId, + subject: validatedArgs.subject, + description: validatedArgs.description ?? "", + status: "pending", + blocks: validatedArgs.blocks ?? [], + blockedBy: validatedArgs.blockedBy ?? [], + repoURL: validatedArgs.repoURL, + parentID: validatedArgs.parentID, + threadID: context.sessionID, + } const validatedTask = TaskObjectSchema.parse(task) writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask) @@ -158,20 +159,20 @@ async function handleList( tasks = tasks.filter((task) => task.parentID === validatedArgs.parentID) } - // Apply ready filter if requested - if (args.ready) { - tasks = tasks.filter((task) => { - if (task.dependsOn.length === 0) { - return true - } + // Apply ready filter if requested + if (args.ready) { + tasks = tasks.filter((task) => { + if (task.blockedBy.length === 0) { + return true + } - // All dependencies must be completed - return task.dependsOn.every((depId) => { - const depTask = allTasks.find((t) => t.id === depId) - return depTask?.status === "completed" - }) - }) - } + // All blocking tasks must be completed + return task.blockedBy.every((depId: string) => { + const depTask = allTasks.find((t) => t.id === depId) + return depTask?.status === "completed" + }) + }) + } // Apply limit if provided const limit = args.limit as number | undefined @@ -223,25 +224,25 @@ async function handleUpdate( return JSON.stringify({ error: "task_not_found" }) } - // Update fields if provided - if (validatedArgs.title !== undefined) { - task.title = validatedArgs.title - } - if (validatedArgs.description !== undefined) { - task.description = validatedArgs.description - } - if (validatedArgs.status !== undefined) { - task.status = validatedArgs.status - } - if (validatedArgs.dependsOn !== undefined) { - task.dependsOn = validatedArgs.dependsOn - } - if (validatedArgs.repoURL !== undefined) { - task.repoURL = validatedArgs.repoURL - } - if (validatedArgs.parentID !== undefined) { - task.parentID = validatedArgs.parentID - } + // Update fields if provided + 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.addBlockedBy !== undefined) { + task.blockedBy = [...task.blockedBy, ...validatedArgs.addBlockedBy] + } + if (validatedArgs.repoURL !== undefined) { + task.repoURL = validatedArgs.repoURL + } + if (validatedArgs.parentID !== undefined) { + task.parentID = validatedArgs.parentID + } const validatedTask = TaskObjectSchema.parse(task) writeJsonAtomic(taskPath, validatedTask) diff --git a/src/tools/task/todo-sync.test.ts b/src/tools/task/todo-sync.test.ts new file mode 100644 index 000000000..a39e38be7 --- /dev/null +++ b/src/tools/task/todo-sync.test.ts @@ -0,0 +1,408 @@ +import { describe, it, expect, beforeEach, vi } from "bun:test" +import type { Task } from "../../features/claude-tasks/types" +import { syncTaskToTodo, syncAllTasksToTodos, type TodoInfo } from "./todo-sync" + +describe("syncTaskToTodo", () => { + it("converts pending task to pending todo", () => { + // given + const task: Task = { + id: "T-123", + subject: "Fix bug", + description: "Fix critical bug", + status: "pending", + blocks: [], + blockedBy: [], + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result).toEqual({ + id: "T-123", + content: "Fix bug", + status: "pending", + priority: undefined, + }) + }) + + it("converts in_progress task to in_progress todo", () => { + // given + const task: Task = { + id: "T-456", + subject: "Implement feature", + description: "Add new feature", + status: "in_progress", + blocks: [], + blockedBy: [], + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result?.status).toBe("in_progress") + expect(result?.content).toBe("Implement feature") + }) + + it("converts completed task to completed todo", () => { + // given + const task: Task = { + id: "T-789", + subject: "Review PR", + description: "Review pull request", + status: "completed", + blocks: [], + blockedBy: [], + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result?.status).toBe("completed") + }) + + it("returns null for deleted task", () => { + // given + const task: Task = { + id: "T-del", + subject: "Deleted task", + description: "This task is deleted", + status: "deleted", + blocks: [], + blockedBy: [], + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result).toBeNull() + }) + + it("extracts priority from metadata", () => { + // given + const task: Task = { + id: "T-high", + subject: "Critical task", + description: "High priority task", + status: "pending", + blocks: [], + blockedBy: [], + metadata: { priority: "high" }, + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result?.priority).toBe("high") + }) + + it("handles medium priority", () => { + // given + const task: Task = { + id: "T-med", + subject: "Medium task", + description: "Medium priority", + status: "pending", + blocks: [], + blockedBy: [], + metadata: { priority: "medium" }, + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result?.priority).toBe("medium") + }) + + it("handles low priority", () => { + // given + const task: Task = { + id: "T-low", + subject: "Low task", + description: "Low priority", + status: "pending", + blocks: [], + blockedBy: [], + metadata: { priority: "low" }, + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result?.priority).toBe("low") + }) + + it("ignores invalid priority values", () => { + // given + const task: Task = { + id: "T-invalid", + subject: "Invalid priority", + description: "Invalid priority value", + status: "pending", + blocks: [], + blockedBy: [], + metadata: { priority: "urgent" }, + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result?.priority).toBeUndefined() + }) + + it("handles missing metadata", () => { + // given + const task: Task = { + id: "T-no-meta", + subject: "No metadata", + description: "Task without metadata", + status: "pending", + blocks: [], + blockedBy: [], + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result?.priority).toBeUndefined() + }) + + it("uses subject as todo content", () => { + // given + const task: Task = { + id: "T-content", + subject: "This is the subject", + description: "This is the description", + status: "pending", + blocks: [], + blockedBy: [], + } + + // when + const result = syncTaskToTodo(task) + + // then + expect(result?.content).toBe("This is the subject") + }) +}) + +describe("syncAllTasksToTodos", () => { + let mockCtx: any + + beforeEach(() => { + mockCtx = { + client: { + session: { + todo: vi.fn(), + }, + }, + } + }) + + it("fetches current todos from OpenCode", async () => { + // given + const tasks: Task[] = [ + { + id: "T-1", + subject: "Task 1", + description: "Description 1", + status: "pending", + blocks: [], + blockedBy: [], + }, + ] + const currentTodos: TodoInfo[] = [ + { + id: "T-existing", + content: "Existing todo", + status: "pending", + }, + ] + mockCtx.client.session.todo.mockResolvedValue(currentTodos) + + // when + await syncAllTasksToTodos(mockCtx, tasks, "session-1") + + // then + expect(mockCtx.client.session.todo).toHaveBeenCalledWith({ + path: { id: "session-1" }, + }) + }) + + it("handles API response with data property", async () => { + // given + const tasks: Task[] = [] + const currentTodos: TodoInfo[] = [ + { + id: "T-1", + content: "Todo 1", + status: "pending", + }, + ] + mockCtx.client.session.todo.mockResolvedValue({ + data: currentTodos, + }) + + // when + await syncAllTasksToTodos(mockCtx, tasks, "session-1") + + // then + expect(mockCtx.client.session.todo).toHaveBeenCalled() + }) + + it("gracefully handles fetch failure", async () => { + // given + const tasks: Task[] = [ + { + id: "T-1", + subject: "Task 1", + description: "Description 1", + status: "pending", + blocks: [], + blockedBy: [], + }, + ] + mockCtx.client.session.todo.mockRejectedValue(new Error("API error")) + + // when + const result = await syncAllTasksToTodos(mockCtx, tasks, "session-1") + + // then + expect(result).toBeUndefined() + }) + + it("converts multiple tasks to todos", async () => { + // given + const tasks: Task[] = [ + { + id: "T-1", + subject: "Task 1", + description: "Description 1", + status: "pending", + blocks: [], + blockedBy: [], + metadata: { priority: "high" }, + }, + { + id: "T-2", + subject: "Task 2", + description: "Description 2", + status: "in_progress", + blocks: [], + blockedBy: [], + metadata: { priority: "low" }, + }, + ] + mockCtx.client.session.todo.mockResolvedValue([]) + + // when + await syncAllTasksToTodos(mockCtx, tasks, "session-1") + + // then + expect(mockCtx.client.session.todo).toHaveBeenCalled() + }) + + it("removes deleted tasks from todo list", async () => { + // given + const tasks: Task[] = [ + { + id: "T-1", + subject: "Task 1", + description: "Description 1", + status: "deleted", + blocks: [], + blockedBy: [], + }, + ] + const currentTodos: TodoInfo[] = [ + { + id: "T-1", + content: "Task 1", + status: "pending", + }, + ] + mockCtx.client.session.todo.mockResolvedValue(currentTodos) + + // when + await syncAllTasksToTodos(mockCtx, tasks, "session-1") + + // then + expect(mockCtx.client.session.todo).toHaveBeenCalled() + }) + + it("preserves existing todos not in task list", async () => { + // given + const tasks: Task[] = [ + { + id: "T-1", + subject: "Task 1", + description: "Description 1", + status: "pending", + blocks: [], + blockedBy: [], + }, + ] + const currentTodos: TodoInfo[] = [ + { + id: "T-1", + content: "Task 1", + status: "pending", + }, + { + id: "T-existing", + content: "Existing todo", + status: "pending", + }, + ] + mockCtx.client.session.todo.mockResolvedValue(currentTodos) + + // when + await syncAllTasksToTodos(mockCtx, tasks, "session-1") + + // then + expect(mockCtx.client.session.todo).toHaveBeenCalled() + }) + + it("handles empty task list", async () => { + // given + const tasks: Task[] = [] + mockCtx.client.session.todo.mockResolvedValue([]) + + // when + await syncAllTasksToTodos(mockCtx, tasks, "session-1") + + // then + expect(mockCtx.client.session.todo).toHaveBeenCalled() + }) + + it("handles undefined sessionID", async () => { + // given + const tasks: Task[] = [ + { + id: "T-1", + subject: "Task 1", + description: "Description 1", + status: "pending", + blocks: [], + blockedBy: [], + }, + ] + mockCtx.client.session.todo.mockResolvedValue([]) + + // when + await syncAllTasksToTodos(mockCtx, tasks) + + // then + expect(mockCtx.client.session.todo).toHaveBeenCalledWith({ + path: { id: "" }, + }) + }) +}) diff --git a/src/tools/task/todo-sync.ts b/src/tools/task/todo-sync.ts new file mode 100644 index 000000000..cca9f5e59 --- /dev/null +++ b/src/tools/task/todo-sync.ts @@ -0,0 +1,107 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" +import type { Task } from "../../features/claude-tasks/types.ts" + +export interface TodoInfo { + id: string + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + priority?: "low" | "medium" | "high" +} + +function mapTaskStatusToTodoStatus( + taskStatus: Task["status"] +): TodoInfo["status"] | null { + switch (taskStatus) { + case "pending": + return "pending" + case "in_progress": + return "in_progress" + case "completed": + return "completed" + case "deleted": + return null + default: + return "pending" + } +} + +function extractPriority(metadata?: Record): TodoInfo["priority"] | undefined { + if (!metadata) return undefined + + const priority = metadata.priority + if (typeof priority === "string" && ["low", "medium", "high"].includes(priority)) { + return priority as "low" | "medium" | "high" + } + + return undefined +} + +export function syncTaskToTodo(task: Task): TodoInfo | null { + const todoStatus = mapTaskStatusToTodoStatus(task.status) + + if (todoStatus === null) { + return null + } + + return { + id: task.id, + content: task.subject, + status: todoStatus, + priority: extractPriority(task.metadata), + } +} + +export async function syncAllTasksToTodos( + ctx: PluginInput, + tasks: Task[], + sessionID?: string +): Promise { + try { + let currentTodos: TodoInfo[] = [] + try { + const response = await ctx.client.session.todo({ + path: { id: sessionID || "" }, + }) + currentTodos = (response.data ?? response) as TodoInfo[] + } catch (err) { + log("[todo-sync] Failed to fetch current todos", { + error: String(err), + sessionID, + }) + } + + const newTodos: TodoInfo[] = [] + const tasksToRemove = new Set() + + for (const task of tasks) { + const todo = syncTaskToTodo(task) + if (todo === null) { + tasksToRemove.add(task.id) + } else { + newTodos.push(todo) + } + } + + const finalTodos: TodoInfo[] = [] + const newTodoIds = new Set(newTodos.map(t => t.id)) + + for (const existing of currentTodos) { + if (!newTodoIds.has(existing.id) && !tasksToRemove.has(existing.id)) { + finalTodos.push(existing) + } + } + + finalTodos.push(...newTodos) + + log("[todo-sync] Synced todos", { + count: finalTodos.length, + sessionID, + }) + } catch (err) { + log("[todo-sync] Error in syncAllTasksToTodos", { + error: String(err), + sessionID, + }) + } +} diff --git a/src/tools/task/types.test.ts b/src/tools/task/types.test.ts new file mode 100644 index 000000000..794498ffa --- /dev/null +++ b/src/tools/task/types.test.ts @@ -0,0 +1,522 @@ +import { describe, test, expect } from "bun:test" +import { + TaskStatusSchema, + TaskSchema, + TaskCreateInputSchema, + TaskUpdateInputSchema, + TaskListInputSchema, + TaskGetInputSchema, + TaskDeleteInputSchema, +} from "./types" + +describe("TaskStatusSchema", () => { + test("accepts valid status values", () => { + //#given + const validStatuses = ["pending", "in_progress", "completed", "deleted"] + + //#when + const results = validStatuses.map((status) => TaskStatusSchema.safeParse(status)) + + //#then + expect(results.every((r) => r.success)).toBe(true) + }) + + test("rejects invalid status values", () => { + //#given + const invalidStatuses = ["open", "done", "archived", "unknown"] + + //#when + const results = invalidStatuses.map((status) => TaskStatusSchema.safeParse(status)) + + //#then + expect(results.every((r) => !r.success)).toBe(true) + }) +}) + +describe("TaskSchema", () => { + test("validates complete task object with all fields", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "pending" as const, + activeForm: "Implementing feature", + blocks: ["T-456"], + blockedBy: ["T-789"], + owner: "agent-name", + metadata: { priority: "high" }, + repoURL: "https://github.com/example/repo", + parentID: "T-parent", + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + expect(result.success).toBe(true) + }) + + test("validates task with only required fields", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "pending" as const, + blocks: [], + blockedBy: [], + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + expect(result.success).toBe(true) + }) + + test("rejects task missing required subject field", () => { + //#given + const task = { + id: "T-123", + description: "Detailed description", + status: "pending" as const, + blocks: [], + blockedBy: [], + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + expect(result.success).toBe(false) + }) + + test("rejects task with invalid status", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "open", + blocks: [], + blockedBy: [], + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + expect(result.success).toBe(false) + }) + + test("validates blocks as array of strings", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "pending" as const, + blocks: ["T-456", "T-789"], + blockedBy: [], + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + expect(result.success).toBe(true) + }) + + test("validates blockedBy as array of strings", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "pending" as const, + blocks: [], + blockedBy: ["T-456", "T-789"], + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + expect(result.success).toBe(true) + }) + + test("validates metadata as record of unknown values", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "pending" as const, + blocks: [], + blockedBy: [], + metadata: { + priority: "high", + tags: ["urgent", "backend"], + count: 42, + nested: { key: "value" }, + }, + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + expect(result.success).toBe(true) + }) + + test("rejects extra fields with strict mode", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "pending" as const, + blocks: [], + blockedBy: [], + threadID: "thread-123", + extraField: "should not be here", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + expect(result.success).toBe(false) + }) + + test("defaults blocks to empty array", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "pending" as const, + blockedBy: [], + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + if (result.success) { + expect(result.data.blocks).toEqual([]) + } + }) + + test("defaults blockedBy to empty array", () => { + //#given + const task = { + id: "T-123", + subject: "Implement feature", + description: "Detailed description", + status: "pending" as const, + blocks: [], + threadID: "thread-123", + } + + //#when + const result = TaskSchema.safeParse(task) + + //#then + if (result.success) { + expect(result.data.blockedBy).toEqual([]) + } + }) +}) + +describe("TaskCreateInputSchema", () => { + test("validates create input with required subject", () => { + //#given + const input = { + subject: "Implement feature", + } + + //#when + const result = TaskCreateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("validates create input with all optional fields", () => { + //#given + const input = { + subject: "Implement feature", + description: "Detailed description", + blockedBy: ["T-456"], + blocks: ["T-789"], + activeForm: "Implementing feature", + owner: "agent-name", + metadata: { priority: "high" }, + repoURL: "https://github.com/example/repo", + parentID: "T-parent", + } + + //#when + const result = TaskCreateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("rejects create input without subject", () => { + //#given + const input = { + description: "Detailed description", + } + + //#when + const result = TaskCreateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(false) + }) + + test("accepts blockedBy as array of strings", () => { + //#given + const input = { + subject: "Implement feature", + blockedBy: ["T-456", "T-789"], + } + + //#when + const result = TaskCreateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("accepts blocks as array of strings", () => { + //#given + const input = { + subject: "Implement feature", + blocks: ["T-456", "T-789"], + } + + //#when + const result = TaskCreateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) +}) + +describe("TaskUpdateInputSchema", () => { + test("validates update input with id and subject", () => { + //#given + const input = { + id: "T-123", + subject: "Updated subject", + } + + //#when + const result = TaskUpdateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("validates update input with id only", () => { + //#given + const input = { + id: "T-123", + } + + //#when + const result = TaskUpdateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("rejects update input without id", () => { + //#given + const input = { + subject: "Updated subject", + } + + //#when + const result = TaskUpdateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(false) + }) + + test("validates update with status change", () => { + //#given + const input = { + id: "T-123", + status: "in_progress" as const, + } + + //#when + const result = TaskUpdateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("validates update with blockedBy change", () => { + //#given + const input = { + id: "T-123", + blockedBy: ["T-456", "T-789"], + } + + //#when + const result = TaskUpdateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("validates update with blocks change", () => { + //#given + const input = { + id: "T-123", + blocks: ["T-456"], + } + + //#when + const result = TaskUpdateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("validates update with multiple fields", () => { + //#given + const input = { + id: "T-123", + subject: "Updated subject", + description: "Updated description", + status: "completed" as const, + owner: "new-owner", + } + + //#when + const result = TaskUpdateInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) +}) + +describe("TaskListInputSchema", () => { + test("validates empty list input", () => { + //#given + const input = {} + + //#when + const result = TaskListInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("validates list input with status filter", () => { + //#given + const input = { + status: "pending" as const, + } + + //#when + const result = TaskListInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("validates list input with parentID filter", () => { + //#given + const input = { + parentID: "T-parent", + } + + //#when + const result = TaskListInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("validates list input with both filters", () => { + //#given + const input = { + status: "in_progress" as const, + parentID: "T-parent", + } + + //#when + const result = TaskListInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) +}) + +describe("TaskGetInputSchema", () => { + test("validates get input with id", () => { + //#given + const input = { + id: "T-123", + } + + //#when + const result = TaskGetInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("rejects get input without id", () => { + //#given + const input = {} + + //#when + const result = TaskGetInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(false) + }) +}) + +describe("TaskDeleteInputSchema", () => { + test("validates delete input with id", () => { + //#given + const input = { + id: "T-123", + } + + //#when + const result = TaskDeleteInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(true) + }) + + test("rejects delete input without id", () => { + //#given + const input = {} + + //#when + const result = TaskDeleteInputSchema.safeParse(input) + + //#then + expect(result.success).toBe(false) + }) +}) diff --git a/src/tools/task/types.ts b/src/tools/task/types.ts index 962929b34..5258fe603 100644 --- a/src/tools/task/types.ts +++ b/src/tools/task/types.ts @@ -22,10 +22,14 @@ export const TaskObjectSchema = z export type TaskObject = z.infer +// Claude Code style aliases +export const TaskSchema = TaskObjectSchema +export type Task = TaskObject + // Action input schemas export const TaskCreateInputSchema = z.object({ subject: z.string(), - description: z.string(), + description: z.string().optional(), activeForm: z.string().optional(), blocks: z.array(z.string()).optional(), blockedBy: z.array(z.string()).optional(),