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:
@@ -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<OhMyOpenCodeConfig>
|
||||
config: Partial<OhMyOpenCodeConfig>,
|
||||
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<string, unknown>,
|
||||
config: Partial<OhMyOpenCodeConfig>,
|
||||
context: { sessionID: string }
|
||||
ctx: PluginInput | undefined,
|
||||
context: { sessionID: string },
|
||||
): Promise<string> {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OhMyOpenCodeConfig>
|
||||
config: Partial<OhMyOpenCodeConfig>,
|
||||
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<string, unknown>,
|
||||
config: Partial<OhMyOpenCodeConfig>,
|
||||
context: { sessionID: string }
|
||||
ctx: PluginInput | undefined,
|
||||
context: { sessionID: string },
|
||||
): Promise<string> {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/// <reference path="../../types/bun-test.d.ts" />
|
||||
type TestBody = () => unknown | Promise<unknown>;
|
||||
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: "" },
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
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<string, unknown>): TodoInfo["priority"] | undefined {
|
||||
if (!metadata) return undefined
|
||||
function extractPriority(
|
||||
metadata?: Record<string, unknown>,
|
||||
): 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<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(
|
||||
ctx: PluginInput,
|
||||
tasks: Task[],
|
||||
sessionID?: string
|
||||
sessionID?: string,
|
||||
): Promise<void> {
|
||||
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<string>()
|
||||
const newTodos: TodoInfo[] = [];
|
||||
const tasksToRemove = new Set<string>();
|
||||
|
||||
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,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user