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