- Split unified Task tool into individual tools (TaskCreate, TaskGet, TaskList, TaskUpdate) - Update schema to Claude Code field names (subject, blockedBy, blocks, activeForm, owner, metadata) - Add OpenCode Todo API sync layer (todo-sync.ts) - Implement Todo sync on task create/update for continuation enforcement - Add comprehensive tests for all tools (96 tests total) - Update AGENTS.md documentation Breaking Changes: - Field names changed: title→subject, dependsOn→blockedBy, open→pending - Tool names changed: task→task_create, task_get, task_list, task_update Closes: todo-continuation-enforcer now sees Task-created items
409 lines
8.7 KiB
TypeScript
409 lines
8.7 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "bun:test"
|
|
import type { Task } from "../../features/claude-tasks/types"
|
|
import { syncTaskToTodo, syncAllTasksToTodos, type TodoInfo } from "./todo-sync"
|
|
|
|
describe("syncTaskToTodo", () => {
|
|
it("converts pending task to pending todo", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-123",
|
|
subject: "Fix bug",
|
|
description: "Fix critical bug",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result).toEqual({
|
|
id: "T-123",
|
|
content: "Fix bug",
|
|
status: "pending",
|
|
priority: undefined,
|
|
})
|
|
})
|
|
|
|
it("converts in_progress task to in_progress todo", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-456",
|
|
subject: "Implement feature",
|
|
description: "Add new feature",
|
|
status: "in_progress",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result?.status).toBe("in_progress")
|
|
expect(result?.content).toBe("Implement feature")
|
|
})
|
|
|
|
it("converts completed task to completed todo", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-789",
|
|
subject: "Review PR",
|
|
description: "Review pull request",
|
|
status: "completed",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result?.status).toBe("completed")
|
|
})
|
|
|
|
it("returns null for deleted task", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-del",
|
|
subject: "Deleted task",
|
|
description: "This task is deleted",
|
|
status: "deleted",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it("extracts priority from metadata", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-high",
|
|
subject: "Critical task",
|
|
description: "High priority task",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "high" },
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result?.priority).toBe("high")
|
|
})
|
|
|
|
it("handles medium priority", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-med",
|
|
subject: "Medium task",
|
|
description: "Medium priority",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "medium" },
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result?.priority).toBe("medium")
|
|
})
|
|
|
|
it("handles low priority", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-low",
|
|
subject: "Low task",
|
|
description: "Low priority",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "low" },
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result?.priority).toBe("low")
|
|
})
|
|
|
|
it("ignores invalid priority values", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-invalid",
|
|
subject: "Invalid priority",
|
|
description: "Invalid priority value",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "urgent" },
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result?.priority).toBeUndefined()
|
|
})
|
|
|
|
it("handles missing metadata", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-no-meta",
|
|
subject: "No metadata",
|
|
description: "Task without metadata",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result?.priority).toBeUndefined()
|
|
})
|
|
|
|
it("uses subject as todo content", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-content",
|
|
subject: "This is the subject",
|
|
description: "This is the description",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
}
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task)
|
|
|
|
// then
|
|
expect(result?.content).toBe("This is the subject")
|
|
})
|
|
})
|
|
|
|
describe("syncAllTasksToTodos", () => {
|
|
let mockCtx: any
|
|
|
|
beforeEach(() => {
|
|
mockCtx = {
|
|
client: {
|
|
session: {
|
|
todo: vi.fn(),
|
|
},
|
|
},
|
|
}
|
|
})
|
|
|
|
it("fetches current todos from OpenCode", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
]
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-existing",
|
|
content: "Existing todo",
|
|
status: "pending",
|
|
},
|
|
]
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos)
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalledWith({
|
|
path: { id: "session-1" },
|
|
})
|
|
})
|
|
|
|
it("handles API response with data property", async () => {
|
|
// given
|
|
const tasks: Task[] = []
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-1",
|
|
content: "Todo 1",
|
|
status: "pending",
|
|
},
|
|
]
|
|
mockCtx.client.session.todo.mockResolvedValue({
|
|
data: currentTodos,
|
|
})
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled()
|
|
})
|
|
|
|
it("gracefully handles fetch failure", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
]
|
|
mockCtx.client.session.todo.mockRejectedValue(new Error("API error"))
|
|
|
|
// when
|
|
const result = await syncAllTasksToTodos(mockCtx, tasks, "session-1")
|
|
|
|
// then
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
it("converts multiple tasks to todos", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "high" },
|
|
},
|
|
{
|
|
id: "T-2",
|
|
subject: "Task 2",
|
|
description: "Description 2",
|
|
status: "in_progress",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "low" },
|
|
},
|
|
]
|
|
mockCtx.client.session.todo.mockResolvedValue([])
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled()
|
|
})
|
|
|
|
it("removes deleted tasks from todo list", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "deleted",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
]
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-1",
|
|
content: "Task 1",
|
|
status: "pending",
|
|
},
|
|
]
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos)
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled()
|
|
})
|
|
|
|
it("preserves existing todos not in task list", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
]
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-1",
|
|
content: "Task 1",
|
|
status: "pending",
|
|
},
|
|
{
|
|
id: "T-existing",
|
|
content: "Existing todo",
|
|
status: "pending",
|
|
},
|
|
]
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos)
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled()
|
|
})
|
|
|
|
it("handles empty task list", async () => {
|
|
// given
|
|
const tasks: Task[] = []
|
|
mockCtx.client.session.todo.mockResolvedValue([])
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled()
|
|
})
|
|
|
|
it("handles undefined sessionID", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
]
|
|
mockCtx.client.session.todo.mockResolvedValue([])
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks)
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalledWith({
|
|
path: { id: "" },
|
|
})
|
|
})
|
|
})
|