feat(task): implement TaskUpdate tool with additive blocks/blockedBy and metadata merge
This commit is contained in:
@@ -1,2 +1,6 @@
|
||||
export { createTask } from "./task"
|
||||
export { createTaskCreateTool } from "./task-create"
|
||||
export { createTaskUpdateTool } from "./task-update"
|
||||
export { syncTaskToTodo, syncAllTasksToTodos } from "./todo-sync"
|
||||
export type { TaskObject, TaskStatus, TaskCreateInput, TaskListInput, TaskGetInput, TaskUpdateInput, TaskDeleteInput } from "./types"
|
||||
export type { TodoInfo } from "./todo-sync"
|
||||
|
||||
432
src/tools/task/task-update.test.ts
Normal file
432
src/tools/task/task-update.test.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
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 { createTaskUpdateTool } from "./task-update"
|
||||
|
||||
const TEST_STORAGE = ".test-task-update-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_update tool", () => {
|
||||
let tool: ReturnType<typeof createTaskUpdateTool>
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(TEST_STORAGE)) {
|
||||
rmSync(TEST_STORAGE, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
tool = createTaskUpdateTool(TEST_CONFIG)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_STORAGE)) {
|
||||
rmSync(TEST_STORAGE, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("update action", () => {
|
||||
test("updates task subject when provided", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-123"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Original subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
subject: "Updated subject",
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("task")
|
||||
expect(result.task.subject).toBe("Updated subject")
|
||||
expect(result.task.description).toBe("Test description")
|
||||
})
|
||||
|
||||
test("updates task description when provided", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-124"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Original description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
description: "Updated description",
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.description).toBe("Updated description")
|
||||
})
|
||||
|
||||
test("updates task status when provided", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-125"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
status: "in_progress" as const,
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.status).toBe("in_progress")
|
||||
})
|
||||
|
||||
test("additively appends to blocks array without replacing", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-126"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: ["T-existing-1"],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
addBlocks: ["T-new-1", "T-new-2"],
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.blocks).toContain("T-existing-1")
|
||||
expect(result.task.blocks).toContain("T-new-1")
|
||||
expect(result.task.blocks).toContain("T-new-2")
|
||||
expect(result.task.blocks.length).toBe(3)
|
||||
})
|
||||
|
||||
test("avoids duplicate blocks when adding", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-127"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: ["T-existing-1"],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
addBlocks: ["T-existing-1", "T-new-1"],
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.blocks).toContain("T-existing-1")
|
||||
expect(result.task.blocks).toContain("T-new-1")
|
||||
expect(result.task.blocks.length).toBe(2)
|
||||
})
|
||||
|
||||
test("additively appends to blockedBy array without replacing", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-128"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: ["T-blocker-1"],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
addBlockedBy: ["T-blocker-2", "T-blocker-3"],
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.blockedBy).toContain("T-blocker-1")
|
||||
expect(result.task.blockedBy).toContain("T-blocker-2")
|
||||
expect(result.task.blockedBy).toContain("T-blocker-3")
|
||||
expect(result.task.blockedBy.length).toBe(3)
|
||||
})
|
||||
|
||||
test("merges metadata without replacing entire object", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-129"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: {
|
||||
priority: "high",
|
||||
assignee: "alice",
|
||||
},
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
metadata: {
|
||||
priority: "low",
|
||||
tags: ["bug"],
|
||||
},
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.metadata.priority).toBe("low")
|
||||
expect(result.task.metadata.assignee).toBe("alice")
|
||||
expect(result.task.metadata.tags).toEqual(["bug"])
|
||||
})
|
||||
|
||||
test("deletes metadata keys when set to null", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-130"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: {
|
||||
priority: "high",
|
||||
assignee: "alice",
|
||||
tags: ["bug"],
|
||||
},
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
metadata: {
|
||||
assignee: null,
|
||||
},
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.metadata.priority).toBe("high")
|
||||
expect(result.task.metadata.assignee).toBeUndefined()
|
||||
expect(result.task.metadata.tags).toEqual(["bug"])
|
||||
})
|
||||
|
||||
test("updates activeForm when provided", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-131"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
activeForm: "implementing feature X",
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.activeForm).toBe("implementing feature X")
|
||||
})
|
||||
|
||||
test("updates owner when provided", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-132"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Test subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
owner: "sisyphus",
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.owner).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("returns error when task not found", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
id: "T-nonexistent",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
expect(result.error).toBe("task_not_found")
|
||||
})
|
||||
|
||||
test("returns error for invalid task ID format", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
id: "invalid-id",
|
||||
}
|
||||
|
||||
//#when
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("error")
|
||||
expect(result.error).toBe("invalid_task_id")
|
||||
})
|
||||
|
||||
test("persists changes to file storage", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-133"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Original subject",
|
||||
description: "Test description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
subject: "Updated subject",
|
||||
}
|
||||
await tool.execute(args, TEST_CONTEXT)
|
||||
|
||||
//#then
|
||||
const savedContent = await Bun.file(taskPath).text()
|
||||
const savedTask = JSON.parse(savedContent)
|
||||
expect(savedTask.subject).toBe("Updated subject")
|
||||
})
|
||||
|
||||
test("updates multiple fields in single call", async () => {
|
||||
//#given
|
||||
const taskId = "T-test-134"
|
||||
const taskPath = join(TEST_DIR, `${taskId}.json`)
|
||||
const initialTask: TaskObject = {
|
||||
id: taskId,
|
||||
subject: "Original subject",
|
||||
description: "Original description",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
threadID: TEST_SESSION_ID,
|
||||
}
|
||||
await Bun.write(taskPath, JSON.stringify(initialTask))
|
||||
|
||||
//#when
|
||||
const args = {
|
||||
id: taskId,
|
||||
subject: "New subject",
|
||||
description: "New description",
|
||||
status: "in_progress" as const,
|
||||
owner: "alice",
|
||||
}
|
||||
const resultStr = await tool.execute(args, TEST_CONTEXT)
|
||||
const result = JSON.parse(resultStr)
|
||||
|
||||
//#then
|
||||
expect(result.task.subject).toBe("New subject")
|
||||
expect(result.task.description).toBe("New description")
|
||||
expect(result.task.status).toBe("in_progress")
|
||||
expect(result.task.owner).toBe("alice")
|
||||
})
|
||||
})
|
||||
})
|
||||
137
src/tools/task/task-update.ts
Normal file
137
src/tools/task/task-update.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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"
|
||||
|
||||
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 createTaskUpdateTool(
|
||||
config: Partial<OhMyOpenCodeConfig>
|
||||
): ToolDefinition {
|
||||
return tool({
|
||||
description: `Update an existing task with new values.
|
||||
|
||||
Supports updating: subject, description, status, activeForm, owner, metadata.
|
||||
For blocks/blockedBy: use addBlocks/addBlockedBy to append (additive, not replacement).
|
||||
For metadata: merge with existing, set key to null to delete.
|
||||
Syncs to OpenCode Todo API after update.`,
|
||||
args: {
|
||||
id: tool.schema.string().describe("Task ID (required)"),
|
||||
subject: tool.schema.string().optional().describe("Task subject"),
|
||||
description: tool.schema.string().optional().describe("Task description"),
|
||||
status: tool.schema
|
||||
.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)"),
|
||||
addBlocks: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Task IDs to add to blocks (additive, not replacement)"),
|
||||
addBlockedBy: tool.schema
|
||||
.array(tool.schema.string())
|
||||
.optional()
|
||||
.describe("Task IDs to add to blockedBy (additive, not replacement)"),
|
||||
metadata: tool.schema
|
||||
.record(tool.schema.string(), tool.schema.unknown())
|
||||
.optional()
|
||||
.describe("Task metadata to merge (set key to null to delete)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
return handleUpdate(args, config, context)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUpdate(
|
||||
args: Record<string, unknown>,
|
||||
config: Partial<OhMyOpenCodeConfig>,
|
||||
context: { sessionID: string }
|
||||
): Promise<string> {
|
||||
try {
|
||||
const validatedArgs = TaskUpdateInputSchema.parse(args)
|
||||
const taskId = parseTaskId(validatedArgs.id)
|
||||
if (!taskId) {
|
||||
return JSON.stringify({ error: "invalid_task_id" })
|
||||
}
|
||||
|
||||
const taskDir = getTaskDir(config)
|
||||
const lock = acquireLock(taskDir)
|
||||
|
||||
if (!lock.acquired) {
|
||||
return JSON.stringify({ error: "task_lock_unavailable" })
|
||||
}
|
||||
|
||||
try {
|
||||
const taskPath = join(taskDir, `${taskId}.json`)
|
||||
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
||||
|
||||
if (!task) {
|
||||
return JSON.stringify({ error: "task_not_found" })
|
||||
}
|
||||
|
||||
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.activeForm !== undefined) {
|
||||
task.activeForm = validatedArgs.activeForm
|
||||
}
|
||||
if (validatedArgs.owner !== undefined) {
|
||||
task.owner = validatedArgs.owner
|
||||
}
|
||||
|
||||
const addBlocks = args.addBlocks as string[] | undefined
|
||||
if (addBlocks) {
|
||||
task.blocks = [...new Set([...task.blocks, ...addBlocks])]
|
||||
}
|
||||
|
||||
const addBlockedBy = args.addBlockedBy as string[] | undefined
|
||||
if (addBlockedBy) {
|
||||
task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])]
|
||||
}
|
||||
|
||||
if (validatedArgs.metadata !== undefined) {
|
||||
task.metadata = { ...task.metadata, ...validatedArgs.metadata }
|
||||
Object.keys(task.metadata).forEach((key) => {
|
||||
if (task.metadata?.[key] === null) {
|
||||
delete task.metadata[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const validatedTask = TaskObjectSchema.parse(task)
|
||||
writeJsonAtomic(taskPath, validatedTask)
|
||||
|
||||
syncTaskToTodo(validatedTask)
|
||||
|
||||
return JSON.stringify({ task: validatedTask })
|
||||
} 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" })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user