Make task schema fields explicit with null sentinels

This commit is contained in:
lesetong
2026-08-11 13:36:09 +08:00
parent 28d7e66d8a
commit 8af1c53c83
8 changed files with 270 additions and 75 deletions
+1
View File
@@ -15,6 +15,7 @@ test("keeps task guidance short while requiring planning before large work", ()
const prompt = TASK_PROMPT_GUIDELINES.join("\n");
assert.match(prompt, /large, multi-stage work/);
assert.match(prompt, /set_tasks before exploration as the only first-batch tool/);
assert.match(prompt, /append with force false by default/);
assert.match(prompt, /Start one task in-progress; skip smaller work/);
assert.match(prompt, /Use update_task when tracked work starts, blocks, or finishes/);
assert.ok(prompt.split(/\s+/).length < 60);
+30 -8
View File
@@ -27,7 +27,7 @@ test("adds a nested batch with generated preorder IDs and derived relations", ()
assert.equal(state.snapshot().nextId, 4);
});
test("rejects invalid nested parentId atomically", () => {
test("rejects invalid nested parentId atomically while accepting null sentinels", () => {
const state = new TaskState();
state.add([{ name: "Existing", description: "Existing" }]);
const before = state.snapshot();
@@ -44,6 +44,16 @@ test("rejects invalid nested parentId atomically", () => {
TaskValidationError,
);
assert.deepEqual(state.snapshot(), before);
const added = state.add([
{
name: "Nullable parent",
parentId: null,
children: [{ name: "Implicit child", parentId: null }],
},
]);
assert.equal(added[0]?.parentId, null);
assert.equal(added[1]?.parentId, added[0]?.id);
});
test("enforces done descendants against the final atomic update state", () => {
@@ -308,23 +318,31 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
});
test("supports optional descriptions and rejects invalid provided values", () => {
test("supports nullable descriptions and reports invalid children as validation errors", () => {
const state = new TaskState();
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
assert.throws(() => state.add([{ name: "Blank description", description: " " }]), /must not be empty/);
assert.throws(
() => state.add([{ name: "Invalid children", children: "not-an-array" } as never]),
/children must be an array/,
);
state.add([
{ name: "No description" },
{ name: "Null description", description: null },
{ name: "Detailed", description: "Line one\nLine two" },
]);
assert.equal(state.list()[0]?.description, undefined);
assert.equal(state.list()[1]?.description, "Line one\nLine two");
assert.equal(state.list()[1]?.description, undefined);
assert.equal(state.list()[2]?.description, "Line one\nLine two");
});
test("set append rejects force and still requires at least one task", () => {
test("set append accepts false force and rejects true force", () => {
const state = new TaskState();
assert.throws(() => state.set("append", [], undefined), /tasks must not be empty/);
assert.throws(() => state.set("append", [{ name: "Task" }], false), /force is allowed only in replace mode/);
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
assert.throws(() => state.set("append", [], false), /tasks must not be empty/);
state.set("append", [{ name: "Task" }], false);
const before = state.snapshot();
assert.throws(() => state.set("append", [{ name: "Other" }], true), /force must be false in append mode/);
assert.deepEqual(state.snapshot(), before);
});
test("set replace is atomic, guarded, and resets IDs", () => {
@@ -339,7 +357,11 @@ test("set replace is atomic, guarded, and resets IDs", () => {
);
assert.deepEqual(state.snapshot(), before);
const replaced = state.set("replace", [{ name: "New", children: [{ name: "Child" }] }], true);
const replaced = state.set(
"replace",
[{ name: "New", parentId: null, children: [{ name: "Child", parentId: null }] }],
true,
);
assert.deepEqual(replaced.map((task) => task.id), [1, 2]);
assert.deepEqual(state.list().map((task) => task.name), ["New", "Child"]);
assert.equal(state.snapshot().nextId, 3);
+85 -1
View File
@@ -1,5 +1,6 @@
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";
@@ -18,7 +19,9 @@ function registeredTools(state = new TaskState()) {
}
async function execute(tool: any, params: unknown) {
return tool.execute("call", params, undefined, undefined, {});
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", () => {
@@ -38,6 +41,8 @@ test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
// $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");
@@ -46,6 +51,85 @@ test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain");
});
test("strict task schemas normalize legacy optional arguments", () => {
const { tools } = registeredTools();
const setTool = tools.get("set_tasks");
const updateTool = tools.get("update_task");
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: "update_task",
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("update_task"), { 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("update_task"), { tasks: [{ id: 2, parentId: null }] });
assert.equal(harness.state.get([2])[0]?.parentId, null);
});
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"), {