diff --git a/src/tools/task/task-create.ts b/src/tools/task/task-create.ts index 0b8be8765..8fce7413e 100644 --- a/src/tools/task/task-create.ts +++ b/src/tools/task/task-create.ts @@ -1,18 +1,20 @@ -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 type { PluginInput } from "@opencode-ai/plugin"; +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" +} from "../../features/claude-tasks/storage"; +import { syncTaskTodoUpdate } from "./todo-sync"; export function createTaskCreateTool( - config: Partial + config: Partial, + ctx?: PluginInput, ): ToolDefinition { return tool({ description: `Create a new task with auto-generated ID and threadID recording. @@ -22,7 +24,10 @@ 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)"), + activeForm: tool.schema + .string() + .optional() + .describe("Active form (present continuous)"), metadata: tool.schema .record(tool.schema.string(), tool.schema.unknown()) .optional() @@ -39,27 +44,28 @@ Returns minimal response with task ID and subject.`, parentID: tool.schema.string().optional().describe("Parent task ID"), }, execute: async (args, context) => { - return handleCreate(args, config, context) + return handleCreate(args, config, ctx, context); }, - }) + }); } async function handleCreate( args: Record, config: Partial, - context: { sessionID: string } + ctx: PluginInput | undefined, + context: { sessionID: string }, ): Promise { try { - const validatedArgs = TaskCreateInputSchema.parse(args) - const taskDir = getTaskDir(config) - const lock = acquireLock(taskDir) + const validatedArgs = TaskCreateInputSchema.parse(args); + const taskDir = getTaskDir(config); + const lock = acquireLock(taskDir); if (!lock.acquired) { - return JSON.stringify({ error: "task_lock_unavailable" }) + return JSON.stringify({ error: "task_lock_unavailable" }); } try { - const taskId = generateTaskId() + const taskId = generateTaskId(); const task: TaskObject = { id: taskId, subject: validatedArgs.subject, @@ -72,26 +78,29 @@ async function handleCreate( repoURL: validatedArgs.repoURL, parentID: validatedArgs.parentID, threadID: context.sessionID, - } + }; - const validatedTask = TaskObjectSchema.parse(task) - writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask) + const validatedTask = TaskObjectSchema.parse(task); + writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask); - syncTaskToTodo(validatedTask) + await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID); return JSON.stringify({ task: { id: validatedTask.id, subject: validatedTask.subject, }, - }) + }); } finally { - lock.release() + 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: "validation_error", + message: error.message, + }); } - return JSON.stringify({ error: "internal_error" }) + return JSON.stringify({ error: "internal_error" }); } } diff --git a/src/tools/task/task-update.ts b/src/tools/task/task-update.ts index 583e8ae3d..c56382ea1 100644 --- a/src/tools/task/task-update.ts +++ b/src/tools/task/task-update.ts @@ -1,25 +1,27 @@ -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 type { PluginInput } from "@opencode-ai/plugin"; +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" +} from "../../features/claude-tasks/storage"; +import { syncTaskTodoUpdate } from "./todo-sync"; -const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/ +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 + if (!TASK_ID_PATTERN.test(id)) return null; + return id; } export function createTaskUpdateTool( - config: Partial + config: Partial, + ctx?: PluginInput, ): ToolDefinition { return tool({ description: `Update an existing task with new values. @@ -36,8 +38,14 @@ Syncs to OpenCode Todo API after update.`, .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)"), + 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() @@ -52,86 +60,90 @@ Syncs to OpenCode Todo API after update.`, .describe("Task metadata to merge (set key to null to delete)"), }, execute: async (args, context) => { - return handleUpdate(args, config, context) + return handleUpdate(args, config, ctx, context); }, - }) + }); } async function handleUpdate( args: Record, config: Partial, - context: { sessionID: string } + ctx: PluginInput | undefined, + context: { sessionID: string }, ): Promise { try { - const validatedArgs = TaskUpdateInputSchema.parse(args) - const taskId = parseTaskId(validatedArgs.id) + const validatedArgs = TaskUpdateInputSchema.parse(args); + const taskId = parseTaskId(validatedArgs.id); if (!taskId) { - return JSON.stringify({ error: "invalid_task_id" }) + return JSON.stringify({ error: "invalid_task_id" }); } - const taskDir = getTaskDir(config) - const lock = acquireLock(taskDir) + const taskDir = getTaskDir(config); + const lock = acquireLock(taskDir); if (!lock.acquired) { - return JSON.stringify({ error: "task_lock_unavailable" }) + return JSON.stringify({ error: "task_lock_unavailable" }); } try { - const taskPath = join(taskDir, `${taskId}.json`) - const task = readJsonSafe(taskPath, TaskObjectSchema) + const taskPath = join(taskDir, `${taskId}.json`); + const task = readJsonSafe(taskPath, TaskObjectSchema); if (!task) { - return JSON.stringify({ error: "task_not_found" }) + return JSON.stringify({ error: "task_not_found" }); } if (validatedArgs.subject !== undefined) { - task.subject = validatedArgs.subject + task.subject = validatedArgs.subject; } if (validatedArgs.description !== undefined) { - task.description = validatedArgs.description + task.description = validatedArgs.description; } if (validatedArgs.status !== undefined) { - task.status = validatedArgs.status + task.status = validatedArgs.status; } if (validatedArgs.activeForm !== undefined) { - task.activeForm = validatedArgs.activeForm + task.activeForm = validatedArgs.activeForm; } if (validatedArgs.owner !== undefined) { - task.owner = validatedArgs.owner + task.owner = validatedArgs.owner; } - const addBlocks = args.addBlocks as string[] | undefined + const addBlocks = args.addBlocks as string[] | undefined; if (addBlocks) { - task.blocks = [...new Set([...task.blocks, ...addBlocks])] + task.blocks = [...new Set([...task.blocks, ...addBlocks])]; } - const addBlockedBy = args.addBlockedBy as string[] | undefined + const addBlockedBy = args.addBlockedBy as string[] | undefined; if (addBlockedBy) { - task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])] + task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])]; } if (validatedArgs.metadata !== undefined) { - task.metadata = { ...task.metadata, ...validatedArgs.metadata } + task.metadata = { ...task.metadata, ...validatedArgs.metadata }; Object.keys(task.metadata).forEach((key) => { if (task.metadata?.[key] === null) { - delete task.metadata[key] + delete task.metadata[key]; } - }) + }); } - const validatedTask = TaskObjectSchema.parse(task) - writeJsonAtomic(taskPath, validatedTask) + const validatedTask = TaskObjectSchema.parse(task); + writeJsonAtomic(taskPath, validatedTask); - syncTaskToTodo(validatedTask) + await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID); - return JSON.stringify({ task: validatedTask }) + return JSON.stringify({ task: validatedTask }); } finally { - lock.release() + 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: "validation_error", + message: error.message, + }); } - return JSON.stringify({ error: "internal_error" }) + return JSON.stringify({ error: "internal_error" }); } } diff --git a/src/tools/task/todo-sync.test.ts b/src/tools/task/todo-sync.test.ts index a39e38be7..f194aec5f 100644 --- a/src/tools/task/todo-sync.test.ts +++ b/src/tools/task/todo-sync.test.ts @@ -1,6 +1,39 @@ -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" +/// +type TestBody = () => unknown | Promise; +type TestDecl = (name: string, fn?: TestBody) => void; +type Hook = (fn: TestBody) => void; +type Expectation = { + toBe: (...args: unknown[]) => unknown; + toBeNull: (...args: unknown[]) => unknown; + toEqual: (...args: unknown[]) => unknown; + toHaveProperty: (...args: unknown[]) => unknown; + toMatch: (...args: unknown[]) => unknown; + toBeDefined: (...args: unknown[]) => unknown; + toContain: (...args: unknown[]) => unknown; + toBeUndefined: (...args: unknown[]) => unknown; + toHaveBeenCalled: (...args: unknown[]) => unknown; + toHaveBeenCalledTimes: (...args: unknown[]) => unknown; + toHaveBeenCalledWith: (...args: unknown[]) => unknown; + not: Expectation; +}; +type Expect = (value?: unknown) => Expectation; + +declare const describe: TestDecl; +declare const it: TestDecl; +declare const test: TestDecl; +declare const expect: Expect; +declare const beforeEach: Hook; +declare const afterEach: Hook; +declare const beforeAll: Hook; +declare const afterAll: Hook; +declare const vi: { fn: (...args: unknown[]) => unknown }; +import type { Task } from "../../features/claude-tasks/types"; +import { + syncTaskToTodo, + syncAllTasksToTodos, + syncTaskTodoUpdate, + type TodoInfo, +} from "./todo-sync"; describe("syncTaskToTodo", () => { it("converts pending task to pending todo", () => { @@ -12,10 +45,10 @@ describe("syncTaskToTodo", () => { status: "pending", blocks: [], blockedBy: [], - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then expect(result).toEqual({ @@ -23,8 +56,8 @@ describe("syncTaskToTodo", () => { content: "Fix bug", status: "pending", priority: undefined, - }) - }) + }); + }); it("converts in_progress task to in_progress todo", () => { // given @@ -35,15 +68,15 @@ describe("syncTaskToTodo", () => { status: "in_progress", blocks: [], blockedBy: [], - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result?.status).toBe("in_progress") - expect(result?.content).toBe("Implement feature") - }) + expect(result?.status).toBe("in_progress"); + expect(result?.content).toBe("Implement feature"); + }); it("converts completed task to completed todo", () => { // given @@ -54,14 +87,14 @@ describe("syncTaskToTodo", () => { status: "completed", blocks: [], blockedBy: [], - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result?.status).toBe("completed") - }) + expect(result?.status).toBe("completed"); + }); it("returns null for deleted task", () => { // given @@ -72,14 +105,14 @@ describe("syncTaskToTodo", () => { status: "deleted", blocks: [], blockedBy: [], - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result).toBeNull() - }) + expect(result).toBeNull(); + }); it("extracts priority from metadata", () => { // given @@ -91,14 +124,14 @@ describe("syncTaskToTodo", () => { blocks: [], blockedBy: [], metadata: { priority: "high" }, - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result?.priority).toBe("high") - }) + expect(result?.priority).toBe("high"); + }); it("handles medium priority", () => { // given @@ -110,14 +143,14 @@ describe("syncTaskToTodo", () => { blocks: [], blockedBy: [], metadata: { priority: "medium" }, - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result?.priority).toBe("medium") - }) + expect(result?.priority).toBe("medium"); + }); it("handles low priority", () => { // given @@ -129,14 +162,14 @@ describe("syncTaskToTodo", () => { blocks: [], blockedBy: [], metadata: { priority: "low" }, - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result?.priority).toBe("low") - }) + expect(result?.priority).toBe("low"); + }); it("ignores invalid priority values", () => { // given @@ -148,14 +181,14 @@ describe("syncTaskToTodo", () => { blocks: [], blockedBy: [], metadata: { priority: "urgent" }, - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result?.priority).toBeUndefined() - }) + expect(result?.priority).toBeUndefined(); + }); it("handles missing metadata", () => { // given @@ -166,14 +199,14 @@ describe("syncTaskToTodo", () => { status: "pending", blocks: [], blockedBy: [], - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result?.priority).toBeUndefined() - }) + expect(result?.priority).toBeUndefined(); + }); it("uses subject as todo content", () => { // given @@ -184,18 +217,18 @@ describe("syncTaskToTodo", () => { status: "pending", blocks: [], blockedBy: [], - } + }; // when - const result = syncTaskToTodo(task) + const result = syncTaskToTodo(task); // then - expect(result?.content).toBe("This is the subject") - }) -}) + expect(result?.content).toBe("This is the subject"); + }); +}); -describe("syncAllTasksToTodos", () => { - let mockCtx: any +describe("syncTaskTodoUpdate", () => { + let mockCtx: any; beforeEach(() => { mockCtx = { @@ -204,8 +237,103 @@ describe("syncAllTasksToTodos", () => { todo: vi.fn(), }, }, - } - }) + }; + }); + + it("writes updated todo and preserves existing items", async () => { + // given + const task: Task = { + id: "T-1", + subject: "Updated task", + description: "", + status: "in_progress", + blocks: [], + blockedBy: [], + }; + const currentTodos: TodoInfo[] = [ + { id: "T-1", content: "Old task", status: "pending" }, + { id: "T-2", content: "Keep task", status: "pending" }, + ]; + mockCtx.client.session.todo.mockResolvedValue({ data: currentTodos }); + const payload: { sessionID: string; todos: TodoInfo[] } = { + sessionID: "", + todos: [], + }; + let calls = 0; + const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => { + calls += 1; + payload.sessionID = input.sessionID; + payload.todos = input.todos; + }; + + // when + await syncTaskTodoUpdate(mockCtx, task, "session-1", writer); + + // then + expect(calls).toBe(1); + expect(payload.sessionID).toBe("session-1"); + expect(payload.todos.length).toBe(2); + expect( + payload.todos.find((todo: TodoInfo) => todo.id === "T-1")?.content, + ).toBe("Updated task"); + expect(payload.todos.some((todo: TodoInfo) => todo.id === "T-2")).toBe( + true, + ); + }); + + it("removes deleted task from todos", async () => { + // given + const task: Task = { + id: "T-1", + subject: "Deleted task", + description: "", + status: "deleted", + blocks: [], + blockedBy: [], + }; + const currentTodos: TodoInfo[] = [ + { id: "T-1", content: "Old task", status: "pending" }, + { id: "T-2", content: "Keep task", status: "pending" }, + ]; + mockCtx.client.session.todo.mockResolvedValue(currentTodos); + const payload: { sessionID: string; todos: TodoInfo[] } = { + sessionID: "", + todos: [], + }; + let calls = 0; + const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => { + calls += 1; + payload.sessionID = input.sessionID; + payload.todos = input.todos; + }; + + // when + await syncTaskTodoUpdate(mockCtx, task, "session-1", writer); + + // then + expect(calls).toBe(1); + expect(payload.todos.length).toBe(1); + expect(payload.todos.some((todo: TodoInfo) => todo.id === "T-1")).toBe( + false, + ); + expect(payload.todos.some((todo: TodoInfo) => todo.id === "T-2")).toBe( + true, + ); + }); +}); + +describe("syncAllTasksToTodos", () => { + let mockCtx: any; + + beforeEach(() => { + mockCtx = { + client: { + session: { + todo: vi.fn(), + }, + }, + }; + }); it("fetches current todos from OpenCode", async () => { // given @@ -218,45 +346,45 @@ describe("syncAllTasksToTodos", () => { blocks: [], blockedBy: [], }, - ] + ]; const currentTodos: TodoInfo[] = [ { id: "T-existing", content: "Existing todo", status: "pending", }, - ] - mockCtx.client.session.todo.mockResolvedValue(currentTodos) + ]; + mockCtx.client.session.todo.mockResolvedValue(currentTodos); // when - await syncAllTasksToTodos(mockCtx, tasks, "session-1") + 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 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") + await syncAllTasksToTodos(mockCtx, tasks, "session-1"); // then - expect(mockCtx.client.session.todo).toHaveBeenCalled() - }) + expect(mockCtx.client.session.todo).toHaveBeenCalled(); + }); it("gracefully handles fetch failure", async () => { // given @@ -269,15 +397,15 @@ describe("syncAllTasksToTodos", () => { blocks: [], blockedBy: [], }, - ] - mockCtx.client.session.todo.mockRejectedValue(new Error("API error")) + ]; + mockCtx.client.session.todo.mockRejectedValue(new Error("API error")); // when - const result = await syncAllTasksToTodos(mockCtx, tasks, "session-1") + const result = await syncAllTasksToTodos(mockCtx, tasks, "session-1"); // then - expect(result).toBeUndefined() - }) + expect(result).toBeUndefined(); + }); it("converts multiple tasks to todos", async () => { // given @@ -300,15 +428,15 @@ describe("syncAllTasksToTodos", () => { blockedBy: [], metadata: { priority: "low" }, }, - ] - mockCtx.client.session.todo.mockResolvedValue([]) + ]; + mockCtx.client.session.todo.mockResolvedValue([]); // when - await syncAllTasksToTodos(mockCtx, tasks, "session-1") + await syncAllTasksToTodos(mockCtx, tasks, "session-1"); // then - expect(mockCtx.client.session.todo).toHaveBeenCalled() - }) + expect(mockCtx.client.session.todo).toHaveBeenCalled(); + }); it("removes deleted tasks from todo list", async () => { // given @@ -321,22 +449,22 @@ describe("syncAllTasksToTodos", () => { blocks: [], blockedBy: [], }, - ] + ]; const currentTodos: TodoInfo[] = [ { id: "T-1", content: "Task 1", status: "pending", }, - ] - mockCtx.client.session.todo.mockResolvedValue(currentTodos) + ]; + mockCtx.client.session.todo.mockResolvedValue(currentTodos); // when - await syncAllTasksToTodos(mockCtx, tasks, "session-1") + await syncAllTasksToTodos(mockCtx, tasks, "session-1"); // then - expect(mockCtx.client.session.todo).toHaveBeenCalled() - }) + expect(mockCtx.client.session.todo).toHaveBeenCalled(); + }); it("preserves existing todos not in task list", async () => { // given @@ -349,7 +477,7 @@ describe("syncAllTasksToTodos", () => { blocks: [], blockedBy: [], }, - ] + ]; const currentTodos: TodoInfo[] = [ { id: "T-1", @@ -361,27 +489,27 @@ describe("syncAllTasksToTodos", () => { content: "Existing todo", status: "pending", }, - ] - mockCtx.client.session.todo.mockResolvedValue(currentTodos) + ]; + mockCtx.client.session.todo.mockResolvedValue(currentTodos); // when - await syncAllTasksToTodos(mockCtx, tasks, "session-1") + await syncAllTasksToTodos(mockCtx, tasks, "session-1"); // then - expect(mockCtx.client.session.todo).toHaveBeenCalled() - }) + expect(mockCtx.client.session.todo).toHaveBeenCalled(); + }); it("handles empty task list", async () => { // given - const tasks: Task[] = [] - mockCtx.client.session.todo.mockResolvedValue([]) + const tasks: Task[] = []; + mockCtx.client.session.todo.mockResolvedValue([]); // when - await syncAllTasksToTodos(mockCtx, tasks, "session-1") + await syncAllTasksToTodos(mockCtx, tasks, "session-1"); // then - expect(mockCtx.client.session.todo).toHaveBeenCalled() - }) + expect(mockCtx.client.session.todo).toHaveBeenCalled(); + }); it("handles undefined sessionID", async () => { // given @@ -394,15 +522,15 @@ describe("syncAllTasksToTodos", () => { blocks: [], blockedBy: [], }, - ] - mockCtx.client.session.todo.mockResolvedValue([]) + ]; + mockCtx.client.session.todo.mockResolvedValue([]); // when - await syncAllTasksToTodos(mockCtx, tasks) + 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 index cca9f5e59..3243e723f 100644 --- a/src/tools/task/todo-sync.ts +++ b/src/tools/task/todo-sync.ts @@ -1,47 +1,57 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { log } from "../../shared/logger" -import type { Task } from "../../features/claude-tasks/types.ts" +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" + id: string; + content: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + priority?: "low" | "medium" | "high"; } +type TodoWriter = (input: { + sessionID: string; + todos: TodoInfo[]; +}) => Promise; + function mapTaskStatusToTodoStatus( - taskStatus: Task["status"] + taskStatus: Task["status"], ): TodoInfo["status"] | null { switch (taskStatus) { case "pending": - return "pending" + return "pending"; case "in_progress": - return "in_progress" + return "in_progress"; case "completed": - return "completed" + return "completed"; case "deleted": - return null + return null; default: - return "pending" + return "pending"; } } -function extractPriority(metadata?: Record): TodoInfo["priority"] | undefined { - if (!metadata) return undefined +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" + const priority = metadata.priority; + if ( + typeof priority === "string" && + ["low", "medium", "high"].includes(priority) + ) { + return priority as "low" | "medium" | "high"; } - return undefined + return undefined; } export function syncTaskToTodo(task: Task): TodoInfo | null { - const todoStatus = mapTaskStatusToTodoStatus(task.status) + const todoStatus = mapTaskStatusToTodoStatus(task.status); if (todoStatus === null) { - return null + return null; } return { @@ -49,59 +59,115 @@ export function syncTaskToTodo(task: Task): TodoInfo | null { content: task.subject, status: todoStatus, priority: extractPriority(task.metadata), + }; +} + +async function resolveTodoWriter(): Promise { + try { + const loader = "opencode/session/todo"; + const mod = await import(loader); + const update = (mod as { Todo?: { update?: unknown } }).Todo?.update; + if (typeof update === "function") { + return update as TodoWriter; + } + } catch (err) { + log("[todo-sync] Failed to resolve Todo.update", { error: String(err) }); + } + return null; +} + +function extractTodos(response: unknown): TodoInfo[] { + const payload = response as { data?: unknown }; + if (Array.isArray(payload?.data)) { + return payload.data as TodoInfo[]; + } + if (Array.isArray(response)) { + return response as TodoInfo[]; + } + return []; +} + +export async function syncTaskTodoUpdate( + ctx: PluginInput | undefined, + task: Task, + sessionID: string, + writer?: TodoWriter, +): Promise { + if (!ctx) return; + + try { + const response = await ctx.client.session.todo({ + path: { id: sessionID }, + }); + const currentTodos = extractTodos(response); + const nextTodos = currentTodos.filter((todo) => todo.id !== task.id); + const todo = syncTaskToTodo(task); + + if (todo) { + nextTodos.push(todo); + } + + const resolvedWriter = writer ?? (await resolveTodoWriter()); + if (!resolvedWriter) return; + await resolvedWriter({ sessionID, todos: nextTodos }); + } catch (err) { + log("[todo-sync] Failed to sync task todo", { + error: String(err), + sessionID, + }); } } export async function syncAllTasksToTodos( ctx: PluginInput, tasks: Task[], - sessionID?: string + sessionID?: string, ): Promise { try { - let currentTodos: TodoInfo[] = [] + let currentTodos: TodoInfo[] = []; try { const response = await ctx.client.session.todo({ path: { id: sessionID || "" }, - }) - currentTodos = (response.data ?? response) as TodoInfo[] + }); + currentTodos = extractTodos(response); } catch (err) { log("[todo-sync] Failed to fetch current todos", { error: String(err), sessionID, - }) + }); } - const newTodos: TodoInfo[] = [] - const tasksToRemove = new Set() + const newTodos: TodoInfo[] = []; + const tasksToRemove = new Set(); for (const task of tasks) { - const todo = syncTaskToTodo(task) + const todo = syncTaskToTodo(task); if (todo === null) { - tasksToRemove.add(task.id) + tasksToRemove.add(task.id); } else { - newTodos.push(todo) + newTodos.push(todo); } } - const finalTodos: TodoInfo[] = [] - const newTodoIds = new Set(newTodos.map(t => t.id)) + 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(existing); } } - finalTodos.push(...newTodos) + finalTodos.push(...newTodos); log("[todo-sync] Synced todos", { count: finalTodos.length, sessionID, - }) + }); } catch (err) { log("[todo-sync] Error in syncAllTasksToTodos", { error: String(err), sessionID, - }) + }); } }