Refs like "#/$defs/AddTask" resolve from the document root, but Type.Cyclic nested the $defs inside tasks.items, so the moonshot-flavored gateway reported "$defs not found for reference". Hoist all $defs entries to the parameter schema root and strip the synthetic $id that would change fragment resolution. Update the regression test to pin the layout. Bump version to 0.2.3.
170 lines
6.9 KiB
TypeScript
170 lines
6.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { TaskState } from "../src/state.ts";
|
|
import { registerTaskTools } from "../src/tools.ts";
|
|
|
|
function registeredTools(state = new TaskState()) {
|
|
const tools = new Map<string, any>();
|
|
let changes = 0;
|
|
const pi = {
|
|
registerTool(tool: any) {
|
|
tools.set(tool.name, tool);
|
|
},
|
|
};
|
|
registerTaskTools(pi as never, state, () => {
|
|
changes += 1;
|
|
});
|
|
return { tools, state, changes: () => changes };
|
|
}
|
|
|
|
async function execute(tool: any, params: unknown) {
|
|
return tool.execute("call", params, undefined, undefined, {});
|
|
}
|
|
|
|
test("registers five focused tools without the old add_task alias", () => {
|
|
const { tools } = registeredTools();
|
|
assert.deepEqual([...tools.keys()], ["set_tasks", "update_task", "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("update_task").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"]);
|
|
});
|
|
|
|
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.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("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);
|
|
});
|
|
|
|
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 });
|
|
});
|
|
|
|
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("update_task"), { tasks: [{ id: 1, parentId: 2 }] }),
|
|
/Update 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("update_task"), {
|
|
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);
|
|
});
|