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/tests | |
| 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/tests')
| -rw-r--r-- | packages/core/tests/tools/task-list.test.ts | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/packages/core/tests/tools/task-list.test.ts b/packages/core/tests/tools/task-list.test.ts new file mode 100644 index 0000000..5903fec --- /dev/null +++ b/packages/core/tests/tools/task-list.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTaskListTool, TaskList } from "../../src/tools/task-list.js"; +import type { TaskItem } from "../../src/types/index.js"; + +describe("TaskList (declarative store)", () => { + it("starts empty", () => { + const list = new TaskList(); + expect(list.getTasks()).toEqual([]); + }); + + it("setTasks replaces the whole list and assigns positional ids", () => { + const list = new TaskList(); + const result = list.setTasks([ + { content: "first", status: "in_progress" }, + { content: "second", status: "pending" }, + ]); + expect(result).toEqual([ + { id: "task-1", content: "first", status: "in_progress" }, + { id: "task-2", content: "second", status: "pending" }, + ]); + expect(list.getTasks()).toEqual(result); + }); + + it("a second setTasks fully replaces the previous list (no append)", () => { + const list = new TaskList(); + list.setTasks([ + { content: "a", status: "completed" }, + { content: "b", status: "completed" }, + { content: "c", status: "pending" }, + ]); + const next = list.setTasks([{ content: "only", status: "in_progress" }]); + expect(next).toEqual([{ id: "task-1", content: "only", status: "in_progress" }]); + expect(list.getTasks()).toHaveLength(1); + }); + + it("preserves all four statuses", () => { + const list = new TaskList(); + const result = list.setTasks([ + { content: "p", status: "pending" }, + { content: "i", status: "in_progress" }, + { content: "c", status: "completed" }, + { content: "x", status: "cancelled" }, + ]); + expect(result.map((t) => t.status)).toEqual([ + "pending", + "in_progress", + "completed", + "cancelled", + ]); + }); + + it("defaults missing/invalid status to pending", () => { + const list = new TaskList(); + const result = list.setTasks([ + { content: "no status" }, + { content: "bogus", status: "done" }, + { content: "junk", status: 42 }, + ]); + expect(result.map((t) => t.status)).toEqual(["pending", "pending", "pending"]); + }); + + it("an empty array clears the list", () => { + const list = new TaskList(); + list.setTasks([{ content: "x", status: "pending" }]); + expect(list.setTasks([])).toEqual([]); + expect(list.getTasks()).toEqual([]); + }); + + it("getTasks returns copies (no external mutation leaks in)", () => { + const list = new TaskList(); + list.setTasks([{ content: "x", status: "pending" }]); + const snapshot = list.getTasks(); + snapshot[0].content = "mutated"; + expect(list.getTasks()[0].content).toBe("x"); + }); + + it("onChange fires on every setTasks with the new snapshot", () => { + const list = new TaskList(); + const seen: TaskItem[][] = []; + const unsubscribe = list.onChange((tasks) => seen.push(tasks)); + list.setTasks([{ content: "a", status: "pending" }]); + list.setTasks([{ content: "b", status: "completed" }]); + expect(seen).toHaveLength(2); + expect(seen[0]).toEqual([{ id: "task-1", content: "a", status: "pending" }]); + expect(seen[1]).toEqual([{ id: "task-1", content: "b", status: "completed" }]); + unsubscribe(); + list.setTasks([{ content: "c", status: "pending" }]); + expect(seen).toHaveLength(2); + }); +}); + +describe("createTaskListTool", () => { + it("exposes a single declarative `todos` parameter and the name `todo`", () => { + const tool = createTaskListTool(new TaskList()); + expect(tool.name).toBe("todo"); + // One top-level param: the whole-list `todos` array. + const shape = (tool.parameters as { shape: Record<string, unknown> }).shape; + expect(Object.keys(shape)).toEqual(["todos"]); + }); + + it("execute updates the store and echoes the list WITHOUT ids", async () => { + const list = new TaskList(); + const tool = createTaskListTool(list); + const out = await tool.execute({ + todos: [ + { content: "plan", status: "completed" }, + { content: "build", status: "in_progress" }, + ], + }); + expect(JSON.parse(out)).toEqual([ + { content: "plan", status: "completed" }, + { content: "build", status: "in_progress" }, + ]); + // Store has ids; the echo does not. + expect(list.getTasks()).toEqual([ + { id: "task-1", content: "plan", status: "completed" }, + { id: "task-2", content: "build", status: "in_progress" }, + ]); + }); + + it("execute fires onChange so the UI broadcast is wired", async () => { + const list = new TaskList(); + const cb = vi.fn(); + list.onChange(cb); + const tool = createTaskListTool(list); + await tool.execute({ todos: [{ content: "x", status: "pending" }] }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("execute with an empty array clears the store", async () => { + const list = new TaskList(); + list.setTasks([{ content: "x", status: "pending" }]); + const tool = createTaskListTool(list); + const out = await tool.execute({ todos: [] }); + expect(JSON.parse(out)).toEqual([]); + expect(list.getTasks()).toEqual([]); + }); + + it("execute defaults invalid status to pending in both store and echo", async () => { + const list = new TaskList(); + const tool = createTaskListTool(list); + const out = await tool.execute({ todos: [{ content: "x", status: "done" }] }); + expect(JSON.parse(out)).toEqual([{ content: "x", status: "pending" }]); + expect(list.getTasks()[0].status).toBe("pending"); + }); + + it("execute rejects a non-array todos param", async () => { + const tool = createTaskListTool(new TaskList()); + const out = await tool.execute({ todos: "nope" }); + expect(out).toMatch(/Error/); + }); + + it("execute rejects items missing a content string", async () => { + const tool = createTaskListTool(new TaskList()); + const out = await tool.execute({ todos: [{ status: "pending" }] }); + expect(out).toMatch(/Error/); + }); +}); |
