import assert from "node:assert/strict"; import test from "node:test"; import { validateToolArguments } from "../node_modules/@earendil-works/pi-ai/dist/utils/validation.js"; import { TaskState } from "../src/state.ts"; import { registerTaskTools } from "../src/tools.ts"; function registeredTools(state = new TaskState()) { const tools = new Map(); let changes = 0; const autoClearRestarts: boolean[] = []; const pi = { registerTool(tool: any) { tools.set(tool.name, tool); }, }; registerTaskTools(pi as never, state, (_ctx, restartAutoClear = false) => { changes += 1; autoClearRestarts.push(restartAutoClear); }); return { tools, state, changes: () => changes, autoClearRestarts }; } async function execute(tool: any, params: unknown) { const prepared = tool.prepareArguments?.(params) ?? params; const validated = validateToolArguments(tool, { name: tool.name, arguments: prepared } as never); return tool.execute("call", validated, undefined, undefined, {}); } test("registers five focused tools without the old add_task alias", () => { const { tools } = registeredTools(); assert.deepEqual([...tools.keys()], ["set_tasks", "patch_tasks", "rm_task", "list_task", "get_task"]); assert.deepEqual(Object.keys(tools.get("set_tasks").parameters.properties), ["mode", "tasks", "force"]); assert.deepEqual(Object.keys(tools.get("patch_tasks").parameters.properties), ["tasks"]); assert.deepEqual(Object.keys(tools.get("rm_task").parameters.properties), ["ids", "reason"]); assert.deepEqual(Object.keys(tools.get("list_task").parameters.properties), []); assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]); assert.match(tools.get("patch_tasks").description, /do not recreate cleared completed tasks/); }); test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => { const { tools } = registeredTools(); const params = tools.get("set_tasks").parameters; const itemSchema = params.properties.tasks.items; // $defs must live at the document root: root-style refs resolve from there, // and moonshot-flavored validators reject nested definitions (Console Go). assert.ok(params.$defs.AddTask, "$defs hoisted to the parameter schema root"); assert.deepEqual(params.required, ["mode", "tasks", "force"]); assert.deepEqual(params.$defs.AddTask.required, ["name", "description", "status", "parentId", "children"]); assert.equal(itemSchema.$defs, undefined, "no $defs nested inside tasks items"); assert.equal(itemSchema.$ref, "#/$defs/AddTask"); assert.equal(params.$defs.AddTask.properties.children.items.$ref, "#/$defs/AddTask"); assert.equal(params.$defs.AddTask.$id, undefined, "synthetic $id stripped"); const serialized = JSON.stringify(params); assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain"); }); test("task schemas publish text and batch limits", () => { const { tools } = registeredTools(); const setParams = tools.get("set_tasks").parameters; const addTask = setParams.$defs.AddTask; const updateTask = tools.get("patch_tasks").parameters.properties.tasks.items; assert.equal(setParams.properties.tasks.maxItems, 50); assert.equal(addTask.properties.children.maxItems, 50); assert.equal(addTask.properties.name.maxLength, 200); assert.match(addTask.properties.name.pattern, /061C/i); assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000); assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i); assert.equal(tools.get("patch_tasks").parameters.properties.tasks.maxItems, 50); assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200); assert.equal(updateTask.properties.description.anyOf[0].maxLength, 4_000); assert.equal(tools.get("rm_task").parameters.properties.ids.maxItems, 50); assert.equal(tools.get("get_task").parameters.properties.ids.maxItems, 50); const setTool = tools.get("set_tasks"); const validUnicode = setTool.prepareArguments({ mode: "append", tasks: [{ name: "šŸ˜€".repeat(200), description: "line one\nline two" }], }); assert.doesNotThrow(() => validateToolArguments(setTool, { name: setTool.name, arguments: validUnicode } as never), ); const unsafeText = setTool.prepareArguments({ mode: "append", tasks: [{ name: "unsafe\u061cname" }] }); assert.throws( () => validateToolArguments(setTool, { name: setTool.name, arguments: unsafeText } as never), /validation failed/i, ); }); test("set_tasks preparation bounds hostile recursive input before validation", () => { const { tools } = registeredTools(); const setTool = tools.get("set_tasks"); let nested: Record = { name: "Leaf" }; for (let depth = 0; depth < 10_000; depth += 1) nested = { name: `Level ${depth}`, children: [nested] }; const prepared = setTool.prepareArguments({ mode: "append", tasks: [nested] }); assert.throws( () => validateToolArguments(setTool, { name: setTool.name, arguments: prepared } as never), /validation failed/i, ); const oversized = setTool.prepareArguments({ mode: "append", tasks: Array.from({ length: 1_000 }, (_, index) => ({ name: `Task ${index}` })), }); assert.equal(oversized.tasks.length, 51); assert.throws( () => validateToolArguments(setTool, { name: setTool.name, arguments: oversized } as never), /validation failed/i, ); const updateTool = tools.get("patch_tasks"); const oversizedUpdate = updateTool.prepareArguments({ tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })), }); assert.equal(oversizedUpdate.tasks.length, 51); assert.throws( () => validateToolArguments(updateTool, { name: updateTool.name, arguments: oversizedUpdate } as never), /validation failed/i, ); }); test("strict task schemas normalize legacy optional arguments", () => { const { tools } = registeredTools(); const setTool = tools.get("set_tasks"); const updateTool = tools.get("patch_tasks"); assert.deepEqual(updateTool.parameters.properties.tasks.items.required, [ "id", "name", "description", "status", "parentId", "position", ]); assert.deepEqual(setTool.prepareArguments({ mode: "append", tasks: [{ name: "Parent", children: [{ name: "Child" }] }], }), { mode: "append", tasks: [ { name: "Parent", description: null, status: "pending", parentId: null, children: [ { name: "Child", description: null, status: "pending", parentId: null, children: [], }, ], }, ], force: false, }); assert.deepEqual(updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }), { tasks: [ { id: 1, name: null, description: null, status: "done", parentId: -1, position: -1, }, ], }); assert.equal(updateTool.prepareArguments({ tasks: [{ id: 1, parentId: null }] }).tasks[0].parentId, 0); const validated = validateToolArguments(updateTool, { name: "patch_tasks", arguments: updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }), } as never); assert.equal(validated.tasks[0].parentId, -1); assert.equal(validated.tasks[0].position, -1); }); test("validated update sentinels preserve order and map legacy null parent to root", async () => { const harness = registeredTools(); await execute(harness.tools.get("set_tasks"), { mode: "append", tasks: [ { name: "Parent", children: [{ name: "Child" }] }, { name: "Sibling" }, ], }); await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 3, status: "blocked" }] }); assert.deepEqual( harness.state.list().filter((task) => task.parentId === null).map((task) => task.id), [1, 3], ); await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 2, parentId: null }] }); assert.equal(harness.state.get([2])[0]?.parentId, null); }); test("patch_tasks explains an auto-cleared empty tree without recreating tasks", async () => { const harness = registeredTools(); await assert.rejects( execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, status: "done" }] }), /Patch failed Ā· 1 validation error\n- task tree is empty and may have auto-cleared after completion; do not recreate completed tasks\. Use set_tasks only for genuinely new or remaining work/, ); assert.deepEqual(harness.state.list(), []); assert.equal(harness.changes(), 0); }); test("set_tasks appends optional-description tasks and persists a complete snapshot", async () => { const harness = registeredTools(); const result = await execute(harness.tools.get("set_tasks"), { mode: "append", tasks: [{ name: "Parent", children: [{ name: "Child", description: "Useful detail" }] }], }); assert.equal(result.content[0].text, "Added 2 tasks: 1, 2. 0/2 done."); assert.equal(result.details.action, "append"); assert.equal(result.details.snapshot.tasks.length, 2); assert.equal(result.details.snapshot.tasks[0].description, undefined); assert.equal(result.details.snapshot.nextId, 3); assert.equal(harness.changes(), 1); assert.deepEqual(harness.autoClearRestarts, [false]); }); test("set_tasks atomically replaces or clears the tree and resets IDs", async () => { const harness = registeredTools(); await execute(harness.tools.get("set_tasks"), { mode: "append", tasks: [{ name: "Old", status: "done" }], }); const replaced = await execute(harness.tools.get("set_tasks"), { mode: "replace", tasks: [{ name: "New", status: "in-progress" }], }); assert.equal(replaced.content[0].text, "Replaced task tree with 1 task. 0/1 done. Task IDs restart at 1."); assert.deepEqual(harness.state.list().map((task) => [task.id, task.name]), [[1, "New"]]); const cleared = await execute(harness.tools.get("set_tasks"), { mode: "replace", tasks: [], force: true, }); assert.equal(cleared.content[0].text, "Cleared task tree. Task IDs reset to 1."); assert.deepEqual(harness.state.snapshot(), { tasks: [], nextId: 1 }); assert.deepEqual(harness.autoClearRestarts, [false, true, true]); }); test("tool validation errors preserve atomic state", async () => { const harness = registeredTools(); await execute(harness.tools.get("set_tasks"), { mode: "append", tasks: [{ name: "Parent", children: [{ name: "Child" }] }], }); const before = harness.state.snapshot(); await assert.rejects( execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, parentId: 2 }] }), /Patch failed Ā· 1 validation error\n- task [12]: parentId creates a cycle/, ); assert.deepEqual(harness.state.snapshot(), before); assert.equal(harness.changes(), 1); }); test("rm_task requires and reports a constrained removal reason", async () => { const harness = registeredTools(); await execute(harness.tools.get("set_tasks"), { mode: "append", tasks: [{ name: "Duplicate" }], }); const removed = await execute(harness.tools.get("rm_task"), { ids: [1], reason: "duplicate" }); assert.match(removed.content[0].text, /Reason: duplicate\.$/); assert.deepEqual(harness.state.list(), []); }); test("list is compact and get returns available details for multiple IDs", async () => { const harness = registeredTools(); await execute(harness.tools.get("set_tasks"), { mode: "append", tasks: [{ name: "Parent", description: "Detailed", children: [{ name: "Child" }] }], }); const list = await execute(harness.tools.get("list_task"), {}); assert.equal(list.content[0].text, "1 [pending] Parent\n└─ 2 [pending] Child"); const get = await execute(harness.tools.get("get_task"), { ids: [1, 2] }); const parsed = JSON.parse(get.content[0].text); assert.equal(parsed[0].description, "Detailed"); assert.deepEqual(parsed[0].childrenIds, [2]); assert.equal(parsed[1].description, undefined); assert.equal(parsed[1].parentId, 1); }); test("get_task exposes cumulative timing while list output stays compact", async () => { const startedAt = Date.UTC(2026, 0, 1); let now = startedAt; const harness = registeredTools(new TaskState(() => now)); const added = await execute(harness.tools.get("set_tasks"), { mode: "append", tasks: [{ name: "Timed", status: "in-progress" }], }); assert.equal(added.content[0].text, "Added 1 task: 1. 0/1 done."); assert.equal(added.details.snapshot.timings[0].activeStartedAt, startedAt); now += 65_432; const list = await execute(harness.tools.get("list_task"), {}); assert.equal(list.content[0].text, "1 [in-progress] Timed"); const active = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text); assert.deepEqual(active[0], { id: 1, name: "Timed", status: "in-progress", parentId: null, childrenIds: [], startedAt: new Date(startedAt).toISOString(), durationMs: 65_432, }); now += 568; const updated = await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, status: "blocked" }], }); assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done."); now += 10_000; const paused = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text); assert.equal(paused[0].startedAt, new Date(startedAt).toISOString()); assert.equal(paused[0].stoppedAt, new Date(startedAt + 66_000).toISOString()); assert.equal(paused[0].durationMs, 66_000); });