summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 14:49:49 +0900
committerAdam Malczewski <[email protected]>2026-06-02 14:49:49 +0900
commitecb001ec7a2e573d8dedf5064e860e5a3e7788fd (patch)
tree531371ba6f449a144b5bb45a3d226b025983bd4b /packages/core/tests
parent7c527b4d8a72159954405e720d5bf776802dc0ff (diff)
downloaddispatch-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.ts158
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/);
+ });
+});