feat(task): add real-time single-task todo sync via OpenCode API

- Add syncTaskTodoUpdate function for immediate todo updates
- Integrate with TaskCreate and TaskUpdate tools
- Preserve existing todos when updating single task
- Add comprehensive tests for new sync function

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim 2026-02-02 15:05:07 +09:00
parent 418cf35886
commit 0ea92124a7
4 changed files with 415 additions and 200 deletions

View File

@ -1,18 +1,20 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import type { PluginInput } from "@opencode-ai/plugin";
import { join } from "path" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
import type { OhMyOpenCodeConfig } from "../../config/schema" import { join } from "path";
import type { TaskObject } from "./types" import type { OhMyOpenCodeConfig } from "../../config/schema";
import { TaskObjectSchema, TaskCreateInputSchema } from "./types" import type { TaskObject } from "./types";
import { TaskObjectSchema, TaskCreateInputSchema } from "./types";
import { import {
getTaskDir, getTaskDir,
writeJsonAtomic, writeJsonAtomic,
acquireLock, acquireLock,
generateTaskId, generateTaskId,
} from "../../features/claude-tasks/storage" } from "../../features/claude-tasks/storage";
import { syncTaskToTodo } from "./todo-sync" import { syncTaskTodoUpdate } from "./todo-sync";
export function createTaskCreateTool( export function createTaskCreateTool(
config: Partial<OhMyOpenCodeConfig> config: Partial<OhMyOpenCodeConfig>,
ctx?: PluginInput,
): ToolDefinition { ): ToolDefinition {
return tool({ return tool({
description: `Create a new task with auto-generated ID and threadID recording. 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: { args: {
subject: tool.schema.string().describe("Task subject (required)"), subject: tool.schema.string().describe("Task subject (required)"),
description: tool.schema.string().optional().describe("Task description"), 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 metadata: tool.schema
.record(tool.schema.string(), tool.schema.unknown()) .record(tool.schema.string(), tool.schema.unknown())
.optional() .optional()
@ -39,27 +44,28 @@ Returns minimal response with task ID and subject.`,
parentID: tool.schema.string().optional().describe("Parent task ID"), parentID: tool.schema.string().optional().describe("Parent task ID"),
}, },
execute: async (args, context) => { execute: async (args, context) => {
return handleCreate(args, config, context) return handleCreate(args, config, ctx, context);
}, },
}) });
} }
async function handleCreate( async function handleCreate(
args: Record<string, unknown>, args: Record<string, unknown>,
config: Partial<OhMyOpenCodeConfig>, config: Partial<OhMyOpenCodeConfig>,
context: { sessionID: string } ctx: PluginInput | undefined,
context: { sessionID: string },
): Promise<string> { ): Promise<string> {
try { try {
const validatedArgs = TaskCreateInputSchema.parse(args) const validatedArgs = TaskCreateInputSchema.parse(args);
const taskDir = getTaskDir(config) const taskDir = getTaskDir(config);
const lock = acquireLock(taskDir) const lock = acquireLock(taskDir);
if (!lock.acquired) { if (!lock.acquired) {
return JSON.stringify({ error: "task_lock_unavailable" }) return JSON.stringify({ error: "task_lock_unavailable" });
} }
try { try {
const taskId = generateTaskId() const taskId = generateTaskId();
const task: TaskObject = { const task: TaskObject = {
id: taskId, id: taskId,
subject: validatedArgs.subject, subject: validatedArgs.subject,
@ -72,26 +78,29 @@ async function handleCreate(
repoURL: validatedArgs.repoURL, repoURL: validatedArgs.repoURL,
parentID: validatedArgs.parentID, parentID: validatedArgs.parentID,
threadID: context.sessionID, threadID: context.sessionID,
} };
const validatedTask = TaskObjectSchema.parse(task) const validatedTask = TaskObjectSchema.parse(task);
writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask) writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask);
syncTaskToTodo(validatedTask) await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
return JSON.stringify({ return JSON.stringify({
task: { task: {
id: validatedTask.id, id: validatedTask.id,
subject: validatedTask.subject, subject: validatedTask.subject,
}, },
}) });
} finally { } finally {
lock.release() lock.release();
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.includes("Required")) { 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" });
} }
} }

View File

@ -1,25 +1,27 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import type { PluginInput } from "@opencode-ai/plugin";
import { join } from "path" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
import type { OhMyOpenCodeConfig } from "../../config/schema" import { join } from "path";
import type { TaskObject, TaskUpdateInput } from "./types" import type { OhMyOpenCodeConfig } from "../../config/schema";
import { TaskObjectSchema, TaskUpdateInputSchema } from "./types" import type { TaskObject, TaskUpdateInput } from "./types";
import { TaskObjectSchema, TaskUpdateInputSchema } from "./types";
import { import {
getTaskDir, getTaskDir,
readJsonSafe, readJsonSafe,
writeJsonAtomic, writeJsonAtomic,
acquireLock, acquireLock,
} from "../../features/claude-tasks/storage" } from "../../features/claude-tasks/storage";
import { syncTaskToTodo } from "./todo-sync" 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 { function parseTaskId(id: string): string | null {
if (!TASK_ID_PATTERN.test(id)) return null if (!TASK_ID_PATTERN.test(id)) return null;
return id return id;
} }
export function createTaskUpdateTool( export function createTaskUpdateTool(
config: Partial<OhMyOpenCodeConfig> config: Partial<OhMyOpenCodeConfig>,
ctx?: PluginInput,
): ToolDefinition { ): ToolDefinition {
return tool({ return tool({
description: `Update an existing task with new values. 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"]) .enum(["pending", "in_progress", "completed", "deleted"])
.optional() .optional()
.describe("Task status"), .describe("Task status"),
activeForm: tool.schema.string().optional().describe("Active form (present continuous)"), activeForm: tool.schema
owner: tool.schema.string().optional().describe("Task owner (agent name)"), .string()
.optional()
.describe("Active form (present continuous)"),
owner: tool.schema
.string()
.optional()
.describe("Task owner (agent name)"),
addBlocks: tool.schema addBlocks: tool.schema
.array(tool.schema.string()) .array(tool.schema.string())
.optional() .optional()
@ -52,86 +60,90 @@ Syncs to OpenCode Todo API after update.`,
.describe("Task metadata to merge (set key to null to delete)"), .describe("Task metadata to merge (set key to null to delete)"),
}, },
execute: async (args, context) => { execute: async (args, context) => {
return handleUpdate(args, config, context) return handleUpdate(args, config, ctx, context);
}, },
}) });
} }
async function handleUpdate( async function handleUpdate(
args: Record<string, unknown>, args: Record<string, unknown>,
config: Partial<OhMyOpenCodeConfig>, config: Partial<OhMyOpenCodeConfig>,
context: { sessionID: string } ctx: PluginInput | undefined,
context: { sessionID: string },
): Promise<string> { ): Promise<string> {
try { try {
const validatedArgs = TaskUpdateInputSchema.parse(args) const validatedArgs = TaskUpdateInputSchema.parse(args);
const taskId = parseTaskId(validatedArgs.id) const taskId = parseTaskId(validatedArgs.id);
if (!taskId) { if (!taskId) {
return JSON.stringify({ error: "invalid_task_id" }) return JSON.stringify({ error: "invalid_task_id" });
} }
const taskDir = getTaskDir(config) const taskDir = getTaskDir(config);
const lock = acquireLock(taskDir) const lock = acquireLock(taskDir);
if (!lock.acquired) { if (!lock.acquired) {
return JSON.stringify({ error: "task_lock_unavailable" }) return JSON.stringify({ error: "task_lock_unavailable" });
} }
try { try {
const taskPath = join(taskDir, `${taskId}.json`) const taskPath = join(taskDir, `${taskId}.json`);
const task = readJsonSafe(taskPath, TaskObjectSchema) const task = readJsonSafe(taskPath, TaskObjectSchema);
if (!task) { if (!task) {
return JSON.stringify({ error: "task_not_found" }) return JSON.stringify({ error: "task_not_found" });
} }
if (validatedArgs.subject !== undefined) { if (validatedArgs.subject !== undefined) {
task.subject = validatedArgs.subject task.subject = validatedArgs.subject;
} }
if (validatedArgs.description !== undefined) { if (validatedArgs.description !== undefined) {
task.description = validatedArgs.description task.description = validatedArgs.description;
} }
if (validatedArgs.status !== undefined) { if (validatedArgs.status !== undefined) {
task.status = validatedArgs.status task.status = validatedArgs.status;
} }
if (validatedArgs.activeForm !== undefined) { if (validatedArgs.activeForm !== undefined) {
task.activeForm = validatedArgs.activeForm task.activeForm = validatedArgs.activeForm;
} }
if (validatedArgs.owner !== undefined) { 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) { 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) { if (addBlockedBy) {
task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])] task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])];
} }
if (validatedArgs.metadata !== undefined) { if (validatedArgs.metadata !== undefined) {
task.metadata = { ...task.metadata, ...validatedArgs.metadata } task.metadata = { ...task.metadata, ...validatedArgs.metadata };
Object.keys(task.metadata).forEach((key) => { Object.keys(task.metadata).forEach((key) => {
if (task.metadata?.[key] === null) { if (task.metadata?.[key] === null) {
delete task.metadata[key] delete task.metadata[key];
} }
}) });
} }
const validatedTask = TaskObjectSchema.parse(task) const validatedTask = TaskObjectSchema.parse(task);
writeJsonAtomic(taskPath, validatedTask) writeJsonAtomic(taskPath, validatedTask);
syncTaskToTodo(validatedTask) await syncTaskTodoUpdate(ctx, validatedTask, context.sessionID);
return JSON.stringify({ task: validatedTask }) return JSON.stringify({ task: validatedTask });
} finally { } finally {
lock.release() lock.release();
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message.includes("Required")) { 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" });
} }
} }

View File

@ -1,6 +1,39 @@
import { describe, it, expect, beforeEach, vi } from "bun:test" /// <reference path="../../types/bun-test.d.ts" />
import type { Task } from "../../features/claude-tasks/types" type TestBody = () => unknown | Promise<unknown>;
import { syncTaskToTodo, syncAllTasksToTodos, type TodoInfo } from "./todo-sync" 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", () => { describe("syncTaskToTodo", () => {
it("converts pending task to pending todo", () => { it("converts pending task to pending todo", () => {
@ -12,10 +45,10 @@ describe("syncTaskToTodo", () => {
status: "pending", status: "pending",
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result).toEqual({ expect(result).toEqual({
@ -23,8 +56,8 @@ describe("syncTaskToTodo", () => {
content: "Fix bug", content: "Fix bug",
status: "pending", status: "pending",
priority: undefined, priority: undefined,
}) });
}) });
it("converts in_progress task to in_progress todo", () => { it("converts in_progress task to in_progress todo", () => {
// given // given
@ -35,15 +68,15 @@ describe("syncTaskToTodo", () => {
status: "in_progress", status: "in_progress",
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result?.status).toBe("in_progress") expect(result?.status).toBe("in_progress");
expect(result?.content).toBe("Implement feature") expect(result?.content).toBe("Implement feature");
}) });
it("converts completed task to completed todo", () => { it("converts completed task to completed todo", () => {
// given // given
@ -54,14 +87,14 @@ describe("syncTaskToTodo", () => {
status: "completed", status: "completed",
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result?.status).toBe("completed") expect(result?.status).toBe("completed");
}) });
it("returns null for deleted task", () => { it("returns null for deleted task", () => {
// given // given
@ -72,14 +105,14 @@ describe("syncTaskToTodo", () => {
status: "deleted", status: "deleted",
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result).toBeNull() expect(result).toBeNull();
}) });
it("extracts priority from metadata", () => { it("extracts priority from metadata", () => {
// given // given
@ -91,14 +124,14 @@ describe("syncTaskToTodo", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
metadata: { priority: "high" }, metadata: { priority: "high" },
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result?.priority).toBe("high") expect(result?.priority).toBe("high");
}) });
it("handles medium priority", () => { it("handles medium priority", () => {
// given // given
@ -110,14 +143,14 @@ describe("syncTaskToTodo", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
metadata: { priority: "medium" }, metadata: { priority: "medium" },
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result?.priority).toBe("medium") expect(result?.priority).toBe("medium");
}) });
it("handles low priority", () => { it("handles low priority", () => {
// given // given
@ -129,14 +162,14 @@ describe("syncTaskToTodo", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
metadata: { priority: "low" }, metadata: { priority: "low" },
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result?.priority).toBe("low") expect(result?.priority).toBe("low");
}) });
it("ignores invalid priority values", () => { it("ignores invalid priority values", () => {
// given // given
@ -148,14 +181,14 @@ describe("syncTaskToTodo", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
metadata: { priority: "urgent" }, metadata: { priority: "urgent" },
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result?.priority).toBeUndefined() expect(result?.priority).toBeUndefined();
}) });
it("handles missing metadata", () => { it("handles missing metadata", () => {
// given // given
@ -166,14 +199,14 @@ describe("syncTaskToTodo", () => {
status: "pending", status: "pending",
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result?.priority).toBeUndefined() expect(result?.priority).toBeUndefined();
}) });
it("uses subject as todo content", () => { it("uses subject as todo content", () => {
// given // given
@ -184,18 +217,18 @@ describe("syncTaskToTodo", () => {
status: "pending", status: "pending",
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
} };
// when // when
const result = syncTaskToTodo(task) const result = syncTaskToTodo(task);
// then // then
expect(result?.content).toBe("This is the subject") expect(result?.content).toBe("This is the subject");
}) });
}) });
describe("syncAllTasksToTodos", () => { describe("syncTaskTodoUpdate", () => {
let mockCtx: any let mockCtx: any;
beforeEach(() => { beforeEach(() => {
mockCtx = { mockCtx = {
@ -204,8 +237,103 @@ describe("syncAllTasksToTodos", () => {
todo: vi.fn(), 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 () => { it("fetches current todos from OpenCode", async () => {
// given // given
@ -218,45 +346,45 @@ describe("syncAllTasksToTodos", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
}, },
] ];
const currentTodos: TodoInfo[] = [ const currentTodos: TodoInfo[] = [
{ {
id: "T-existing", id: "T-existing",
content: "Existing todo", content: "Existing todo",
status: "pending", status: "pending",
}, },
] ];
mockCtx.client.session.todo.mockResolvedValue(currentTodos) mockCtx.client.session.todo.mockResolvedValue(currentTodos);
// when // when
await syncAllTasksToTodos(mockCtx, tasks, "session-1") await syncAllTasksToTodos(mockCtx, tasks, "session-1");
// then // then
expect(mockCtx.client.session.todo).toHaveBeenCalledWith({ expect(mockCtx.client.session.todo).toHaveBeenCalledWith({
path: { id: "session-1" }, path: { id: "session-1" },
}) });
}) });
it("handles API response with data property", async () => { it("handles API response with data property", async () => {
// given // given
const tasks: Task[] = [] const tasks: Task[] = [];
const currentTodos: TodoInfo[] = [ const currentTodos: TodoInfo[] = [
{ {
id: "T-1", id: "T-1",
content: "Todo 1", content: "Todo 1",
status: "pending", status: "pending",
}, },
] ];
mockCtx.client.session.todo.mockResolvedValue({ mockCtx.client.session.todo.mockResolvedValue({
data: currentTodos, data: currentTodos,
}) });
// when // when
await syncAllTasksToTodos(mockCtx, tasks, "session-1") await syncAllTasksToTodos(mockCtx, tasks, "session-1");
// then // then
expect(mockCtx.client.session.todo).toHaveBeenCalled() expect(mockCtx.client.session.todo).toHaveBeenCalled();
}) });
it("gracefully handles fetch failure", async () => { it("gracefully handles fetch failure", async () => {
// given // given
@ -269,15 +397,15 @@ describe("syncAllTasksToTodos", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
}, },
] ];
mockCtx.client.session.todo.mockRejectedValue(new Error("API error")) mockCtx.client.session.todo.mockRejectedValue(new Error("API error"));
// when // when
const result = await syncAllTasksToTodos(mockCtx, tasks, "session-1") const result = await syncAllTasksToTodos(mockCtx, tasks, "session-1");
// then // then
expect(result).toBeUndefined() expect(result).toBeUndefined();
}) });
it("converts multiple tasks to todos", async () => { it("converts multiple tasks to todos", async () => {
// given // given
@ -300,15 +428,15 @@ describe("syncAllTasksToTodos", () => {
blockedBy: [], blockedBy: [],
metadata: { priority: "low" }, metadata: { priority: "low" },
}, },
] ];
mockCtx.client.session.todo.mockResolvedValue([]) mockCtx.client.session.todo.mockResolvedValue([]);
// when // when
await syncAllTasksToTodos(mockCtx, tasks, "session-1") await syncAllTasksToTodos(mockCtx, tasks, "session-1");
// then // then
expect(mockCtx.client.session.todo).toHaveBeenCalled() expect(mockCtx.client.session.todo).toHaveBeenCalled();
}) });
it("removes deleted tasks from todo list", async () => { it("removes deleted tasks from todo list", async () => {
// given // given
@ -321,22 +449,22 @@ describe("syncAllTasksToTodos", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
}, },
] ];
const currentTodos: TodoInfo[] = [ const currentTodos: TodoInfo[] = [
{ {
id: "T-1", id: "T-1",
content: "Task 1", content: "Task 1",
status: "pending", status: "pending",
}, },
] ];
mockCtx.client.session.todo.mockResolvedValue(currentTodos) mockCtx.client.session.todo.mockResolvedValue(currentTodos);
// when // when
await syncAllTasksToTodos(mockCtx, tasks, "session-1") await syncAllTasksToTodos(mockCtx, tasks, "session-1");
// then // then
expect(mockCtx.client.session.todo).toHaveBeenCalled() expect(mockCtx.client.session.todo).toHaveBeenCalled();
}) });
it("preserves existing todos not in task list", async () => { it("preserves existing todos not in task list", async () => {
// given // given
@ -349,7 +477,7 @@ describe("syncAllTasksToTodos", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
}, },
] ];
const currentTodos: TodoInfo[] = [ const currentTodos: TodoInfo[] = [
{ {
id: "T-1", id: "T-1",
@ -361,27 +489,27 @@ describe("syncAllTasksToTodos", () => {
content: "Existing todo", content: "Existing todo",
status: "pending", status: "pending",
}, },
] ];
mockCtx.client.session.todo.mockResolvedValue(currentTodos) mockCtx.client.session.todo.mockResolvedValue(currentTodos);
// when // when
await syncAllTasksToTodos(mockCtx, tasks, "session-1") await syncAllTasksToTodos(mockCtx, tasks, "session-1");
// then // then
expect(mockCtx.client.session.todo).toHaveBeenCalled() expect(mockCtx.client.session.todo).toHaveBeenCalled();
}) });
it("handles empty task list", async () => { it("handles empty task list", async () => {
// given // given
const tasks: Task[] = [] const tasks: Task[] = [];
mockCtx.client.session.todo.mockResolvedValue([]) mockCtx.client.session.todo.mockResolvedValue([]);
// when // when
await syncAllTasksToTodos(mockCtx, tasks, "session-1") await syncAllTasksToTodos(mockCtx, tasks, "session-1");
// then // then
expect(mockCtx.client.session.todo).toHaveBeenCalled() expect(mockCtx.client.session.todo).toHaveBeenCalled();
}) });
it("handles undefined sessionID", async () => { it("handles undefined sessionID", async () => {
// given // given
@ -394,15 +522,15 @@ describe("syncAllTasksToTodos", () => {
blocks: [], blocks: [],
blockedBy: [], blockedBy: [],
}, },
] ];
mockCtx.client.session.todo.mockResolvedValue([]) mockCtx.client.session.todo.mockResolvedValue([]);
// when // when
await syncAllTasksToTodos(mockCtx, tasks) await syncAllTasksToTodos(mockCtx, tasks);
// then // then
expect(mockCtx.client.session.todo).toHaveBeenCalledWith({ expect(mockCtx.client.session.todo).toHaveBeenCalledWith({
path: { id: "" }, path: { id: "" },
}) });
}) });
}) });

View File

@ -1,47 +1,57 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin";
import { log } from "../../shared/logger" import { log } from "../../shared/logger";
import type { Task } from "../../features/claude-tasks/types.ts" import type { Task } from "../../features/claude-tasks/types.ts";
export interface TodoInfo { export interface TodoInfo {
id: string id: string;
content: string content: string;
status: "pending" | "in_progress" | "completed" | "cancelled" status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high" priority?: "low" | "medium" | "high";
} }
type TodoWriter = (input: {
sessionID: string;
todos: TodoInfo[];
}) => Promise<void>;
function mapTaskStatusToTodoStatus( function mapTaskStatusToTodoStatus(
taskStatus: Task["status"] taskStatus: Task["status"],
): TodoInfo["status"] | null { ): TodoInfo["status"] | null {
switch (taskStatus) { switch (taskStatus) {
case "pending": case "pending":
return "pending" return "pending";
case "in_progress": case "in_progress":
return "in_progress" return "in_progress";
case "completed": case "completed":
return "completed" return "completed";
case "deleted": case "deleted":
return null return null;
default: default:
return "pending" return "pending";
} }
} }
function extractPriority(metadata?: Record<string, unknown>): TodoInfo["priority"] | undefined { function extractPriority(
if (!metadata) return undefined metadata?: Record<string, unknown>,
): TodoInfo["priority"] | undefined {
if (!metadata) return undefined;
const priority = metadata.priority const priority = metadata.priority;
if (typeof priority === "string" && ["low", "medium", "high"].includes(priority)) { if (
return priority as "low" | "medium" | "high" 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 { export function syncTaskToTodo(task: Task): TodoInfo | null {
const todoStatus = mapTaskStatusToTodoStatus(task.status) const todoStatus = mapTaskStatusToTodoStatus(task.status);
if (todoStatus === null) { if (todoStatus === null) {
return null return null;
} }
return { return {
@ -49,59 +59,115 @@ export function syncTaskToTodo(task: Task): TodoInfo | null {
content: task.subject, content: task.subject,
status: todoStatus, status: todoStatus,
priority: extractPriority(task.metadata), priority: extractPriority(task.metadata),
};
}
async function resolveTodoWriter(): Promise<TodoWriter | null> {
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<void> {
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( export async function syncAllTasksToTodos(
ctx: PluginInput, ctx: PluginInput,
tasks: Task[], tasks: Task[],
sessionID?: string sessionID?: string,
): Promise<void> { ): Promise<void> {
try { try {
let currentTodos: TodoInfo[] = [] let currentTodos: TodoInfo[] = [];
try { try {
const response = await ctx.client.session.todo({ const response = await ctx.client.session.todo({
path: { id: sessionID || "" }, path: { id: sessionID || "" },
}) });
currentTodos = (response.data ?? response) as TodoInfo[] currentTodos = extractTodos(response);
} catch (err) { } catch (err) {
log("[todo-sync] Failed to fetch current todos", { log("[todo-sync] Failed to fetch current todos", {
error: String(err), error: String(err),
sessionID, sessionID,
}) });
} }
const newTodos: TodoInfo[] = [] const newTodos: TodoInfo[] = [];
const tasksToRemove = new Set<string>() const tasksToRemove = new Set<string>();
for (const task of tasks) { for (const task of tasks) {
const todo = syncTaskToTodo(task) const todo = syncTaskToTodo(task);
if (todo === null) { if (todo === null) {
tasksToRemove.add(task.id) tasksToRemove.add(task.id);
} else { } else {
newTodos.push(todo) newTodos.push(todo);
} }
} }
const finalTodos: TodoInfo[] = [] const finalTodos: TodoInfo[] = [];
const newTodoIds = new Set(newTodos.map(t => t.id)) const newTodoIds = new Set(newTodos.map((t) => t.id));
for (const existing of currentTodos) { for (const existing of currentTodos) {
if (!newTodoIds.has(existing.id) && !tasksToRemove.has(existing.id)) { 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", { log("[todo-sync] Synced todos", {
count: finalTodos.length, count: finalTodos.length,
sessionID, sessionID,
}) });
} catch (err) { } catch (err) {
log("[todo-sync] Error in syncAllTasksToTodos", { log("[todo-sync] Error in syncAllTasksToTodos", {
error: String(err), error: String(err),
sessionID, sessionID,
}) });
} }
} }