diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 14:49:49 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 14:49:49 +0900 |
| commit | ecb001ec7a2e573d8dedf5064e860e5a3e7788fd (patch) | |
| tree | 531371ba6f449a144b5bb45a3d226b025983bd4b /packages/core/src | |
| parent | 7c527b4d8a72159954405e720d5bf776802dc0ff (diff) | |
| download | dispatch-ecb001ec7a2e573d8dedf5064e860e5a3e7788fd.tar.gz dispatch-ecb001ec7a2e573d8dedf5064e860e5a3e7788fd.zip | |
feat(todo): port opencode's declarative whole-list todo tool
Replace the imperative id-based CRUD todo tool (add/update/list/get/remove)
with opencode's declarative whole-list design: a single `todos` param that
replaces the entire list each call. No model-visible ids, no delta reasoning,
no "task not found" spirals.
- core: TaskItem { id, content, status }; statuses pending|in_progress|
completed|cancelled. TaskList.setTasks/getTasks/onChange. New rich
TODO_DESCRIPTION adapted from opencode's todowrite.txt.
- api: TASK_MANAGEMENT_GUIDANCE system-prompt section (from anthropic.txt);
updated TOOL_DESCRIPTIONS.todo. Reload fix: TabStatusSnapshot now carries
per-tab tasks so getAllStatuses rehydrates the panel on reconnect.
- frontend: mirror types; hydrate tasks from snapshot in both restore paths;
upgrade sidebar Tasks panel to render content + all four statuses + progress.
- tests: new core task-list.test.ts (15); updated api TaskList mocks +
getAllStatuses task-snapshot coverage.
bun run check clean; 569 tests pass; all packages typecheck.
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/core/src/tools/task-list.ts | 206 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 18 |
3 files changed, 122 insertions, 104 deletions
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7818024..a7b1cad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,7 +107,7 @@ export { toAvailableSubagents, toAvailableUserAgents, } from "./tools/summon.js"; -export { createTaskListTool, TaskList } from "./tools/task-list.js"; +export { createTaskListTool, TaskList, TODO_DESCRIPTION } from "./tools/task-list.js"; export { clearSpillForTab } from "./tools/truncate.js"; export { createWebSearchTool } from "./tools/web-search.js"; export { createWriteFileTool } from "./tools/write-file.js"; diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts index 29f1543..98dcf01 100644 --- a/packages/core/src/tools/task-list.ts +++ b/packages/core/src/tools/task-list.ts @@ -1,9 +1,37 @@ import { z } from "zod"; import type { TaskItem, TaskStatus, ToolDefinition } from "../types/index.js"; +/** + * Valid task statuses. Matches opencode's todo lifecycle: + * - pending not started + * - in_progress actively working (exactly ONE at a time) + * - completed finished successfully + * - cancelled no longer needed + */ +const VALID_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([ + "pending", + "in_progress", + "completed", + "cancelled", +]); + +function normalizeStatus(value: unknown): TaskStatus { + return typeof value === "string" && VALID_STATUSES.has(value as TaskStatus) + ? (value as TaskStatus) + : "pending"; +} + +/** + * Declarative, whole-list task store (ported from opencode's `todowrite`). + * + * The model never sees ids and never issues per-item mutations. Instead it + * sends the ENTIRE desired list on every call and {@link setTasks} rebuilds the + * stored list, assigning fresh positional ids. This is idempotent and + * eliminates the id-bookkeeping / "task not found" / delta-reasoning failure + * modes of the old imperative CRUD interface. + */ export class TaskList { private tasks: TaskItem[] = []; - private counter = 0; private listeners: Array<(tasks: TaskItem[]) => void> = []; private notify(): void { @@ -14,40 +42,22 @@ export class TaskList { } getTasks(): TaskItem[] { - return [...this.tasks]; - } - - getTask(id: string): TaskItem | undefined { - return this.tasks.find((t) => t.id === id); - } - - addTask(title: string, description: string): TaskItem { - this.counter++; - const task: TaskItem = { - id: `task-${this.counter}`, - title, - description, - status: "pending", - }; - this.tasks.push(task); - this.notify(); - return task; - } - - updateTask(id: string, status: TaskStatus): TaskItem | undefined { - const task = this.tasks.find((t) => t.id === id); - if (!task) return undefined; - task.status = status; - this.notify(); - return { ...task }; + return this.tasks.map((t) => ({ ...t })); } - removeTask(id: string): boolean { - const index = this.tasks.findIndex((t) => t.id === id); - if (index === -1) return false; - this.tasks.splice(index, 1); + /** + * Replace the entire list. Each item is assigned a fresh positional id + * (`task-1`, `task-2`, …). Invalid/missing statuses fall back to + * `pending`; an empty array clears the list. Always notifies listeners. + */ + setTasks(items: Array<{ content: string; status?: unknown }>): TaskItem[] { + this.tasks = items.map((item, index) => ({ + id: `task-${index + 1}`, + content: item.content, + status: normalizeStatus(item.status), + })); this.notify(); - return true; + return this.getTasks(); } onChange(callback: (tasks: TaskItem[]) => void): () => void { @@ -58,86 +68,82 @@ export class TaskList { } } +/** + * Rich tool description adapted from opencode's `todowrite.txt`. Teaches the + * declarative whole-list cadence and the status lifecycle. + */ +export const TODO_DESCRIPTION = `Create and maintain a structured todo list for the current session to track progress and surface your plan to the user. + +This is a DECLARATIVE, whole-list tool. There are no ids and no per-item actions: every call sends the ENTIRE list in the \`todos\` parameter and REPLACES the previous list. To change one item, resend the whole list with that item changed. To clear the list, send an empty array. + +## When to use +- The task requires 3+ distinct steps and benefits from planning +- The user provides multiple tasks (numbered or comma-separated) or asks for a todo list +- New instructions arrive — capture them as todos +- You start a task — mark it in_progress (only one at a time) before working +- You finish a task — mark it completed and add any follow-ups discovered + +## When NOT to use +- A single, straightforward task (or fewer than 3 trivial steps) +- Purely informational or conversational requests +- When tracking adds no organizational value + +## States +- pending — not started +- in_progress — actively working (exactly ONE at a time) +- completed — finished successfully +- cancelled — no longer needed + +## Rules +- Send the full desired list every time; the tool replaces the stored list +- Update status in real time; do not batch completions +- Mark completed only after the work is actually done (including any required verification), never on intent +- Keep exactly one in_progress while work remains +- If blocked or partial, keep it in_progress and add a follow-up todo describing the blocker +- Items should be specific and actionable; break large work into smaller steps`; + export function createTaskListTool(taskList: TaskList): ToolDefinition { return { name: "todo", - description: - "Manage a todo list for planning and tracking work. Add items, update their status, list all items, or get details on a specific item.", + description: TODO_DESCRIPTION, parameters: z.object({ - action: z.enum(["add", "update", "list", "get", "remove"]).describe("The action to perform"), - title: z.string().optional().describe("Task title (required for 'add')"), - description: z - .string() - .optional() - .describe("Task description (for 'add', defaults to empty)"), - task_id: z.string().optional().describe("Task ID (required for 'update', 'get', 'remove')"), - status: z - .enum(["pending", "in_progress", "done"]) - .optional() - .describe("New status (required for 'update')"), + todos: z + .array( + z.object({ + content: z.string().describe("Brief, actionable description of the task"), + status: z + .enum(["pending", "in_progress", "completed", "cancelled"]) + .describe("Current status of the task"), + }), + ) + .describe("The complete, updated todo list. Replaces the previous list entirely."), }), execute: async (args: Record<string, unknown>): Promise<string> => { - const action = args.action as string; - - if (action === "add") { - const title = args.title as string | undefined; - if (!title) { - return "Error: 'title' is required for the 'add' action."; - } - const description = (args.description as string | undefined) ?? ""; - const task = taskList.addTask(title, description); - return JSON.stringify(task); - } - - if (action === "update") { - const task_id = args.task_id as string | undefined; - const status = args.status as TaskStatus | undefined; - if (!task_id) { - return "Error: 'task_id' is required for the 'update' action."; - } - if (!status) { - return "Error: 'status' is required for the 'update' action."; - } - const updated = taskList.updateTask(task_id, status); - if (!updated) { - return `Error: Task with ID '${task_id}' not found.`; - } - return JSON.stringify(updated); - } - - if (action === "get") { - const task_id = args.task_id as string | undefined; - if (!task_id) { - return "Error: 'task_id' is required for the 'get' action."; - } - const task = taskList.getTask(task_id); - if (!task) { - return `Error: Task with ID '${task_id}' not found.`; - } - return JSON.stringify(task); - } - - if (action === "list") { - const tasks = taskList.getTasks(); - if (tasks.length === 0) { - return "No tasks."; - } - return JSON.stringify(tasks); + const rawTodos = args.todos; + if (!Array.isArray(rawTodos)) { + return "Error: 'todos' must be an array of { content, status } items (send the whole list)."; } - if (action === "remove") { - const task_id = args.task_id as string | undefined; - if (!task_id) { - return "Error: 'task_id' is required for the 'remove' action."; + const items: Array<{ content: string; status?: unknown }> = []; + for (const entry of rawTodos) { + if (!entry || typeof entry !== "object") { + return "Error: each todo must be an object with a 'content' string and a 'status'."; } - const removed = taskList.removeTask(task_id); - if (!removed) { - return `Error: Task with ID '${task_id}' not found.`; + const content = (entry as Record<string, unknown>).content; + if (typeof content !== "string" || content.trim() === "") { + return "Error: each todo requires a non-empty 'content' string."; } - return `Task '${task_id}' removed successfully.`; + items.push({ + content, + status: (entry as Record<string, unknown>).status, + }); } - return `Error: Unknown action '${action}'.`; + const stored = taskList.setTasks(items); + // Echo the canonical stored list back WITHOUT ids — the model must + // never start tracking ids; it always resends the whole list. + const echo = stored.map((t) => ({ content: t.content, status: t.status })); + return JSON.stringify(echo); }, }; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 30afbd9..a22b2b7 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -252,6 +252,12 @@ export interface TabStatusSnapshot { * way `turn-start` would, so they reconcile cleanly when the turn seals. */ currentTurnId?: string; + /** + * The tab's current todo list. Included for ALL tabs (not just running + * ones) so a freshly-reloaded frontend rehydrates the Tasks panel from the + * backend instead of blanking it. Omitted when the list is empty. + */ + tasks?: TaskItem[]; } export type AgentEvent = @@ -473,12 +479,18 @@ export interface AgentSkillMapping { // ─── Task List Types ───────────────────────────────────────────── -export type TaskStatus = "pending" | "in_progress" | "done" | "blocked"; +export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled"; export interface TaskItem { + /** + * Stable positional id used purely for UI keying and the + * `task-list-update` event contract. It is NEVER exposed to the model: + * the `todo` tool is a declarative whole-list write (the model sends the + * entire desired list every call), so there are no ids for the model to + * track. Ids are reassigned positionally on every `setTasks`. + */ id: string; - title: string; - description: string; + content: string; status: TaskStatus; } |
